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
0170d19f
Commit
0170d19f
authored
Feb 02, 2026
by
song
Browse files
merge upstream main
parent
7ade9baa
Changes
319
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/admin/AnnouncementsView.vue
0 → 100644
View file @
0170d19f
<
template
>
<AppLayout>
<TablePageLayout>
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadAnnouncements"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
<button
@
click=
"openCreateDialog"
class=
"btn btn-primary"
>
<Icon
name=
"plus"
size=
"md"
class=
"mr-1"
/>
{{
t
(
'
admin.announcements.createAnnouncement
'
)
}}
</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.announcements.searchAnnouncements')"
class=
"input"
@
input=
"handleSearch"
/>
</div>
<div
class=
"flex gap-2"
>
<Select
v-model=
"filters.status"
:options=
"statusFilterOptions"
class=
"w-40"
@
change=
"handleStatusChange"
/>
</div>
</div>
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"announcements"
:loading=
"loading"
>
<template
#cell-title
="
{ value, row }">
<div
class=
"min-w-0"
>
<div
class=
"flex items-center gap-2"
>
<span
class=
"truncate font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</div>
<div
class=
"mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400"
>
<span>
#
{{
row
.
id
}}
</span>
<span
class=
"text-gray-300 dark:text-dark-700"
>
·
</span>
<span>
{{
formatDateTime
(
row
.
created_at
)
}}
</span>
</div>
</div>
</
template
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"[
'badge',
value === 'active'
? 'badge-success'
: value === 'draft'
? 'badge-gray'
: 'badge-warning'
]"
>
{{
statusLabel
(
value
)
}}
</span>
</
template
>
<
template
#cell-targeting=
"{ row }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-300"
>
{{
targetingSummary
(
row
.
targeting
)
}}
</span>
</
template
>
<
template
#cell-timeRange=
"{ row }"
>
<div
class=
"text-sm text-gray-600 dark:text-gray-300"
>
<div>
<span
class=
"font-medium"
>
{{
t
(
'
admin.announcements.form.startsAt
'
)
}}
:
</span>
<span
class=
"ml-1"
>
{{
row
.
starts_at
?
formatDateTime
(
row
.
starts_at
)
:
t
(
'
admin.announcements.timeImmediate
'
)
}}
</span>
</div>
<div
class=
"mt-0.5"
>
<span
class=
"font-medium"
>
{{
t
(
'
admin.announcements.form.endsAt
'
)
}}
:
</span>
<span
class=
"ml-1"
>
{{
row
.
ends_at
?
formatDateTime
(
row
.
ends_at
)
:
t
(
'
admin.announcements.timeNever
'
)
}}
</span>
</div>
</div>
</
template
>
<
template
#cell-createdAt=
"{ 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=
"openReadStatus(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.announcements.readStatus')"
>
<Icon
name=
"eye"
size=
"sm"
/>
</button>
<button
@
click=
"openEditDialog(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
>
<
template
#empty
>
<EmptyState
:title=
"t('empty.noData')"
:description=
"t('admin.announcements.failedToLoad')"
:action-text=
"t('admin.announcements.createAnnouncement')"
@
action=
"openCreateDialog"
/>
</
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/Edit Dialog -->
<BaseDialog
:show=
"showEditDialog"
:title=
"isEditing ? t('admin.announcements.editAnnouncement') : t('admin.announcements.createAnnouncement')"
width=
"wide"
@
close=
"closeEdit"
>
<form
id=
"announcement-form"
@
submit.prevent=
"handleSave"
class=
"space-y-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.announcements.form.title') }}
</label>
<input
v-model=
"form.title"
type=
"text"
class=
"input"
required
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.announcements.form.content') }}
</label>
<textarea
v-model=
"form.content"
rows=
"6"
class=
"input"
required
></textarea>
</div>
<div
class=
"grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.announcements.form.status') }}
</label>
<Select
v-model=
"form.status"
:options=
"statusOptions"
/>
</div>
<div></div>
</div>
<div
class=
"grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.announcements.form.startsAt') }}
</label>
<input
v-model=
"form.starts_at_str"
type=
"datetime-local"
class=
"input"
/>
<p
class=
"input-hint"
>
{{ t('admin.announcements.form.startsAtHint') }}
</p>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.announcements.form.endsAt') }}
</label>
<input
v-model=
"form.ends_at_str"
type=
"datetime-local"
class=
"input"
/>
<p
class=
"input-hint"
>
{{ t('admin.announcements.form.endsAtHint') }}
</p>
</div>
</div>
<AnnouncementTargetingEditor
v-model=
"form.targeting"
:groups=
"subscriptionGroups"
/>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
@
click=
"closeEdit"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"announcement-form"
:disabled=
"saving"
class=
"btn btn-primary"
>
{{
saving
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Delete Confirmation -->
<ConfirmDialog
:show=
"showDeleteDialog"
:title=
"t('admin.announcements.deleteAnnouncement')"
:message=
"t('admin.announcements.deleteConfirm')"
:confirm-text=
"t('common.delete')"
:cancel-text=
"t('common.cancel')"
danger
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
/>
<!-- Read Status Dialog -->
<AnnouncementReadStatusDialog
:show=
"showReadStatusDialog"
:announcement-id=
"readStatusAnnouncementId"
@
close=
"showReadStatusDialog = false"
/>
</AppLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
reactive
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
,
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
type
{
AdminGroup
,
Announcement
,
AnnouncementTargeting
}
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
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
AnnouncementTargetingEditor
from
'
@/components/admin/announcements/AnnouncementTargetingEditor.vue
'
import
AnnouncementReadStatusDialog
from
'
@/components/admin/announcements/AnnouncementReadStatusDialog.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
announcements
=
ref
<
Announcement
[]
>
([])
const
loading
=
ref
(
false
)
const
filters
=
reactive
({
status
:
''
,
})
const
searchQuery
=
ref
(
''
)
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
,
pages
:
0
})
const
statusFilterOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.announcements.allStatus
'
)
},
{
value
:
'
draft
'
,
label
:
t
(
'
admin.announcements.statusLabels.draft
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.announcements.statusLabels.active
'
)
},
{
value
:
'
archived
'
,
label
:
t
(
'
admin.announcements.statusLabels.archived
'
)
}
])
const
statusOptions
=
computed
(()
=>
[
{
value
:
'
draft
'
,
label
:
t
(
'
admin.announcements.statusLabels.draft
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.announcements.statusLabels.active
'
)
},
{
value
:
'
archived
'
,
label
:
t
(
'
admin.announcements.statusLabels.archived
'
)
}
])
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
title
'
,
label
:
t
(
'
admin.announcements.columns.title
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.announcements.columns.status
'
)
},
{
key
:
'
targeting
'
,
label
:
t
(
'
admin.announcements.columns.targeting
'
)
},
{
key
:
'
timeRange
'
,
label
:
t
(
'
admin.announcements.columns.timeRange
'
)
},
{
key
:
'
createdAt
'
,
label
:
t
(
'
admin.announcements.columns.createdAt
'
)
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.announcements.columns.actions
'
)
}
])
const
statusLabel
=
(
status
:
string
)
=>
{
if
(
status
===
'
draft
'
)
return
t
(
'
admin.announcements.statusLabels.draft
'
)
if
(
status
===
'
active
'
)
return
t
(
'
admin.announcements.statusLabels.active
'
)
if
(
status
===
'
archived
'
)
return
t
(
'
admin.announcements.statusLabels.archived
'
)
return
status
}
const
targetingSummary
=
(
targeting
:
AnnouncementTargeting
)
=>
{
const
anyOf
=
targeting
?.
any_of
??
[]
if
(
!
anyOf
||
anyOf
.
length
===
0
)
return
t
(
'
admin.announcements.targetingSummaryAll
'
)
return
t
(
'
admin.announcements.targetingSummaryCustom
'
,
{
groups
:
anyOf
.
length
})
}
// ===== CRUD / list =====
let
currentController
:
AbortController
|
null
=
null
async
function
loadAnnouncements
()
{
if
(
currentController
)
currentController
.
abort
()
currentController
=
new
AbortController
()
try
{
loading
.
value
=
true
const
res
=
await
adminAPI
.
announcements
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
})
announcements
.
value
=
res
.
items
pagination
.
total
=
res
.
total
pagination
.
pages
=
res
.
pages
pagination
.
page
=
res
.
page
pagination
.
page_size
=
res
.
page_size
}
catch
(
error
:
any
)
{
if
(
currentController
.
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
)
return
console
.
error
(
'
Error loading announcements:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.announcements.failedToLoad
'
))
}
finally
{
loading
.
value
=
false
}
}
function
handlePageChange
(
page
:
number
)
{
pagination
.
page
=
page
loadAnnouncements
()
}
function
handlePageSizeChange
(
pageSize
:
number
)
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadAnnouncements
()
}
function
handleStatusChange
()
{
pagination
.
page
=
1
loadAnnouncements
()
}
let
searchDebounceTimer
:
number
|
null
=
null
function
handleSearch
()
{
if
(
searchDebounceTimer
)
window
.
clearTimeout
(
searchDebounceTimer
)
searchDebounceTimer
=
window
.
setTimeout
(()
=>
{
pagination
.
page
=
1
loadAnnouncements
()
},
300
)
}
// ===== Create/Edit dialog =====
const
showEditDialog
=
ref
(
false
)
const
saving
=
ref
(
false
)
const
editingAnnouncement
=
ref
<
Announcement
|
null
>
(
null
)
const
isEditing
=
computed
(()
=>
!!
editingAnnouncement
.
value
)
const
form
=
reactive
({
title
:
''
,
content
:
''
,
status
:
'
draft
'
,
starts_at_str
:
''
,
ends_at_str
:
''
,
targeting
:
{
any_of
:
[]
}
as
AnnouncementTargeting
})
const
subscriptionGroups
=
ref
<
AdminGroup
[]
>
([])
async
function
loadSubscriptionGroups
()
{
try
{
const
all
=
await
adminAPI
.
groups
.
getAll
()
subscriptionGroups
.
value
=
(
all
||
[]).
filter
((
g
)
=>
g
.
subscription_type
===
'
subscription
'
)
}
catch
(
error
:
any
)
{
console
.
error
(
'
Error loading groups:
'
,
error
)
// not fatal
}
}
function
resetForm
()
{
form
.
title
=
''
form
.
content
=
''
form
.
status
=
'
draft
'
form
.
starts_at_str
=
''
form
.
ends_at_str
=
''
form
.
targeting
=
{
any_of
:
[]
}
}
function
fillFormFromAnnouncement
(
a
:
Announcement
)
{
form
.
title
=
a
.
title
form
.
content
=
a
.
content
form
.
status
=
a
.
status
// Backend returns RFC3339 strings
form
.
starts_at_str
=
a
.
starts_at
?
formatDateTimeLocalInput
(
Math
.
floor
(
new
Date
(
a
.
starts_at
).
getTime
()
/
1000
))
:
''
form
.
ends_at_str
=
a
.
ends_at
?
formatDateTimeLocalInput
(
Math
.
floor
(
new
Date
(
a
.
ends_at
).
getTime
()
/
1000
))
:
''
form
.
targeting
=
a
.
targeting
??
{
any_of
:
[]
}
}
function
openCreateDialog
()
{
editingAnnouncement
.
value
=
null
resetForm
()
showEditDialog
.
value
=
true
}
function
openEditDialog
(
row
:
Announcement
)
{
editingAnnouncement
.
value
=
row
fillFormFromAnnouncement
(
row
)
showEditDialog
.
value
=
true
}
function
closeEdit
()
{
showEditDialog
.
value
=
false
editingAnnouncement
.
value
=
null
}
function
buildCreatePayload
()
{
const
startsAt
=
parseDateTimeLocalInput
(
form
.
starts_at_str
)
const
endsAt
=
parseDateTimeLocalInput
(
form
.
ends_at_str
)
return
{
title
:
form
.
title
,
content
:
form
.
content
,
status
:
form
.
status
as
any
,
targeting
:
form
.
targeting
,
starts_at
:
startsAt
??
undefined
,
ends_at
:
endsAt
??
undefined
}
}
function
buildUpdatePayload
(
original
:
Announcement
)
{
const
payload
:
any
=
{}
if
(
form
.
title
!==
original
.
title
)
payload
.
title
=
form
.
title
if
(
form
.
content
!==
original
.
content
)
payload
.
content
=
form
.
content
if
(
form
.
status
!==
original
.
status
)
payload
.
status
=
form
.
status
// starts_at / ends_at: distinguish unchanged vs clear(0) vs set
const
originalStarts
=
original
.
starts_at
?
Math
.
floor
(
new
Date
(
original
.
starts_at
).
getTime
()
/
1000
)
:
null
const
originalEnds
=
original
.
ends_at
?
Math
.
floor
(
new
Date
(
original
.
ends_at
).
getTime
()
/
1000
)
:
null
const
newStarts
=
parseDateTimeLocalInput
(
form
.
starts_at_str
)
const
newEnds
=
parseDateTimeLocalInput
(
form
.
ends_at_str
)
if
(
newStarts
!==
originalStarts
)
{
payload
.
starts_at
=
newStarts
===
null
?
0
:
newStarts
}
if
(
newEnds
!==
originalEnds
)
{
payload
.
ends_at
=
newEnds
===
null
?
0
:
newEnds
}
// targeting: do shallow compare by JSON
if
(
JSON
.
stringify
(
form
.
targeting
??
{})
!==
JSON
.
stringify
(
original
.
targeting
??
{}))
{
payload
.
targeting
=
form
.
targeting
}
return
payload
}
async
function
handleSave
()
{
// Frontend validation for targeting (to avoid ANNOUNCEMENT_INVALID_TARGET)
const
anyOf
=
form
.
targeting
?.
any_of
??
[]
if
(
anyOf
.
length
>
50
)
{
appStore
.
showError
(
t
(
'
admin.announcements.failedToCreate
'
))
return
}
for
(
const
g
of
anyOf
)
{
const
allOf
=
g
?.
all_of
??
[]
if
(
allOf
.
length
>
50
)
{
appStore
.
showError
(
t
(
'
admin.announcements.failedToCreate
'
))
return
}
}
saving
.
value
=
true
try
{
if
(
!
editingAnnouncement
.
value
)
{
const
payload
=
buildCreatePayload
()
await
adminAPI
.
announcements
.
create
(
payload
)
appStore
.
showSuccess
(
t
(
'
common.success
'
))
showEditDialog
.
value
=
false
await
loadAnnouncements
()
return
}
const
original
=
editingAnnouncement
.
value
const
payload
=
buildUpdatePayload
(
original
)
await
adminAPI
.
announcements
.
update
(
original
.
id
,
payload
)
appStore
.
showSuccess
(
t
(
'
common.success
'
))
showEditDialog
.
value
=
false
editingAnnouncement
.
value
=
null
await
loadAnnouncements
()
}
catch
(
error
:
any
)
{
console
.
error
(
'
Failed to save announcement:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
(
editingAnnouncement
.
value
?
t
(
'
admin.announcements.failedToUpdate
'
)
:
t
(
'
admin.announcements.failedToCreate
'
)))
}
finally
{
saving
.
value
=
false
}
}
// ===== Delete =====
const
showDeleteDialog
=
ref
(
false
)
const
deletingAnnouncement
=
ref
<
Announcement
|
null
>
(
null
)
function
handleDelete
(
row
:
Announcement
)
{
deletingAnnouncement
.
value
=
row
showDeleteDialog
.
value
=
true
}
async
function
confirmDelete
()
{
if
(
!
deletingAnnouncement
.
value
)
return
try
{
await
adminAPI
.
announcements
.
delete
(
deletingAnnouncement
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
common.success
'
))
showDeleteDialog
.
value
=
false
deletingAnnouncement
.
value
=
null
await
loadAnnouncements
()
}
catch
(
error
:
any
)
{
console
.
error
(
'
Failed to delete announcement:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.announcements.failedToDelete
'
))
}
}
// ===== Read status =====
const
showReadStatusDialog
=
ref
(
false
)
const
readStatusAnnouncementId
=
ref
<
number
|
null
>
(
null
)
function
openReadStatus
(
row
:
Announcement
)
{
readStatusAnnouncementId
.
value
=
row
.
id
showReadStatusDialog
.
value
=
true
}
onMounted
(
async
()
=>
{
await
loadSubscriptionGroups
()
await
loadAnnouncements
()
})
</
script
>
frontend/src/views/admin/GroupsView.vue
View file @
0170d19f
...
...
@@ -243,7 +243,7 @@
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.groups.platformHint
'
)
}}
<
/p
>
<
/div
>
<
div
v
-
if
=
"
createForm.subscription_type !== 'subscription'
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.rateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
createForm.rate_multiplier
"
...
...
@@ -739,7 +739,7 @@
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.groups.platformNotEditable
'
)
}}
<
/p
>
<
/div
>
<
div
v
-
if
=
"
editForm.subscription_type !== 'subscription'
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.rateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
editForm.rate_multiplier
"
...
...
@@ -1225,7 +1225,7 @@ import { useI18n } from 'vue-i18n'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Group
,
GroupPlatform
,
SubscriptionType
}
from
'
@/types
'
import
type
{
Admin
Group
,
GroupPlatform
,
SubscriptionType
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
...
...
@@ -1358,7 +1358,7 @@ const invalidRequestFallbackOptionsForEdit = computed(() => {
return
options
}
)
const
groups
=
ref
<
Group
[]
>
([])
const
groups
=
ref
<
Admin
Group
[]
>
([])
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
filters
=
reactive
({
...
...
@@ -1379,8 +1379,8 @@ const showCreateModal = ref(false)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
submitting
=
ref
(
false
)
const
editingGroup
=
ref
<
Group
|
null
>
(
null
)
const
deletingGroup
=
ref
<
Group
|
null
>
(
null
)
const
editingGroup
=
ref
<
Admin
Group
|
null
>
(
null
)
const
deletingGroup
=
ref
<
Admin
Group
|
null
>
(
null
)
const
createForm
=
reactive
({
name
:
''
,
...
...
@@ -1691,7 +1691,7 @@ const handleCreateGroup = async () => {
}
}
const
handleEdit
=
async
(
group
:
Group
)
=>
{
const
handleEdit
=
async
(
group
:
Admin
Group
)
=>
{
editingGroup
.
value
=
group
editForm
.
name
=
group
.
name
editForm
.
description
=
group
.
description
||
''
...
...
@@ -1753,7 +1753,7 @@ const handleUpdateGroup = async () => {
}
}
const
handleDelete
=
(
group
:
Group
)
=>
{
const
handleDelete
=
(
group
:
Admin
Group
)
=>
{
deletingGroup
.
value
=
group
showDeleteDialog
.
value
=
true
}
...
...
@@ -1773,12 +1773,11 @@ const confirmDelete = async () => {
}
}
// 监听 subscription_type 变化,订阅模式时
重置 rate_multiplier 为 1,
is_exclusive 为 true
// 监听 subscription_type 变化,订阅模式时
is_exclusive
默认
为 true
watch
(
()
=>
createForm
.
subscription_type
,
(
newVal
)
=>
{
if
(
newVal
===
'
subscription
'
)
{
createForm
.
rate_multiplier
=
1.0
createForm
.
is_exclusive
=
true
createForm
.
fallback_group_id_on_invalid_request
=
null
}
...
...
frontend/src/views/admin/RedeemView.vue
View file @
0170d19f
...
...
@@ -238,7 +238,30 @@
v
-
model
=
"
generateForm.group_id
"
:
options
=
"
subscriptionGroupOptions
"
:
placeholder
=
"
t('admin.redeem.selectGroupPlaceholder')
"
/>
>
<
template
#
selected
=
"
{ option
}
"
>
<
GroupBadge
v
-
if
=
"
option
"
:
name
=
"
(option as unknown as GroupOption).label
"
:
platform
=
"
(option as unknown as GroupOption).platform
"
:
subscription
-
type
=
"
(option as unknown as GroupOption).subscriptionType
"
:
rate
-
multiplier
=
"
(option as unknown as GroupOption).rate
"
/>
<
span
v
-
else
class
=
"
text-gray-400
"
>
{{
t
(
'
admin.redeem.selectGroupPlaceholder
'
)
}}
<
/span
>
<
/template
>
<
template
#
option
=
"
{ option, selected
}
"
>
<
GroupOptionItem
:
name
=
"
(option as unknown as GroupOption).label
"
:
platform
=
"
(option as unknown as GroupOption).platform
"
:
subscription
-
type
=
"
(option as unknown as GroupOption).subscriptionType
"
:
rate
-
multiplier
=
"
(option as unknown as GroupOption).rate
"
:
description
=
"
(option as unknown as GroupOption).description
"
:
selected
=
"
selected
"
/>
<
/template
>
<
/Select
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.redeem.validityDays
'
)
}}
<
/label
>
...
...
@@ -370,7 +393,7 @@ import { useAppStore } from '@/stores/app'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
RedeemCode
,
RedeemCodeType
,
Group
}
from
'
@/types
'
import
type
{
RedeemCode
,
RedeemCodeType
,
Group
,
GroupPlatform
,
SubscriptionType
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
...
...
@@ -378,12 +401,23 @@ import DataTable from '@/components/common/DataTable.vue'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
{
copyToClipboard
:
clipboardCopy
}
=
useClipboard
()
interface
GroupOption
{
value
:
number
label
:
string
description
:
string
|
null
platform
:
GroupPlatform
subscriptionType
:
SubscriptionType
rate
:
number
}
const
showGenerateDialog
=
ref
(
false
)
const
showResultDialog
=
ref
(
false
)
const
generatedCodes
=
ref
<
RedeemCode
[]
>
([])
...
...
@@ -395,7 +429,11 @@ const subscriptionGroupOptions = computed(() => {
.
filter
((
g
)
=>
g
.
subscription_type
===
'
subscription
'
)
.
map
((
g
)
=>
({
value
:
g
.
id
,
label
:
g
.
name
label
:
g
.
name
,
description
:
g
.
description
,
platform
:
g
.
platform
,
subscriptionType
:
g
.
subscription_type
,
rate
:
g
.
rate_multiplier
}
))
}
)
...
...
frontend/src/views/admin/SettingsView.vue
View file @
0170d19f
...
...
@@ -323,6 +323,62 @@
</div>
<Toggle
v-model=
"form.email_verify_enabled"
/>
</div>
<!-- Promo 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.promoCode')
}}
</label>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.registration.promoCodeHint') }}
</p>
</div>
<Toggle
v-model=
"form.promo_code_enabled"
/>
</div>
<!-- Password Reset - Only show when email verification is enabled -->
<div
v-if=
"form.email_verify_enabled"
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.passwordReset')
}}
</label>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.registration.passwordResetHint') }}
</p>
</div>
<Toggle
v-model=
"form.password_reset_enabled"
/>
</div>
<!-- TOTP 2FA -->
<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.totp')
}}
</label>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.registration.totpHint') }}
</p>
<!-- Warning when encryption key not configured -->
<p
v-if=
"!form.totp_encryption_key_configured"
class=
"mt-2 text-sm text-amber-600 dark:text-amber-400"
>
{{ t('admin.settings.registration.totpKeyNotConfigured') }}
</p>
</div>
<Toggle
v-model=
"form.totp_enabled"
:disabled=
"!form.totp_encryption_key_configured"
/>
</div>
</div>
</div>
...
...
@@ -720,6 +776,21 @@
{{ t('admin.settings.site.homeContentIframeWarning') }}
</p>
</div>
<!-- Hide CCS Import Button -->
<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.site.hideCcsImportButton')
}}
</label>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.site.hideCcsImportButtonHint') }}
</p>
</div>
<Toggle
v-model=
"form.hide_ccs_import_button"
/>
</div>
</div>
</div>
...
...
@@ -864,6 +935,51 @@
</div>
</div>
<!-- Purchase Subscription Page -->
<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.purchase.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.purchase.description') }}
</p>
</div>
<div
class=
"space-y-6 p-6"
>
<!-- Enable Toggle -->
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t('admin.settings.purchase.enabled')
}}
</label>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.purchase.enabledHint') }}
</p>
</div>
<Toggle
v-model=
"form.purchase_subscription_enabled"
/>
</div>
<!-- URL -->
<div>
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.settings.purchase.url') }}
</label>
<input
v-model=
"form.purchase_subscription_url"
type=
"url"
class=
"input font-mono text-sm"
:placeholder=
"t('admin.settings.purchase.urlPlaceholder')"
/>
<p
class=
"mt-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.purchase.urlHint') }}
</p>
<p
class=
"mt-2 text-xs text-amber-600 dark:text-amber-400"
>
{{ t('admin.settings.purchase.iframeWarning') }}
</p>
</div>
</div>
</div>
<!-- Send Test Email - Only show when email verification is enabled -->
<div
v-if=
"form.email_verify_enabled"
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
...
...
@@ -998,6 +1114,10 @@ type SettingsForm = SystemSettings & {
const
form
=
reactive
<
SettingsForm
>
({
registration_enabled
:
true
,
email_verify_enabled
:
false
,
promo_code_enabled
:
true
,
password_reset_enabled
:
false
,
totp_enabled
:
false
,
totp_encryption_key_configured
:
false
,
default_balance
:
0
,
default_concurrency
:
1
,
site_name
:
'
Sub2API
'
,
...
...
@@ -1007,6 +1127,9 @@ const form = reactive<SettingsForm>({
contact_info
:
''
,
doc_url
:
''
,
home_content
:
''
,
hide_ccs_import_button
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
smtp_host
:
''
,
smtp_port
:
587
,
smtp_username
:
''
,
...
...
@@ -1119,6 +1242,9 @@ async function saveSettings() {
const
payload
:
UpdateSettingsRequest
=
{
registration_enabled
:
form
.
registration_enabled
,
email_verify_enabled
:
form
.
email_verify_enabled
,
promo_code_enabled
:
form
.
promo_code_enabled
,
password_reset_enabled
:
form
.
password_reset_enabled
,
totp_enabled
:
form
.
totp_enabled
,
default_balance
:
form
.
default_balance
,
default_concurrency
:
form
.
default_concurrency
,
site_name
:
form
.
site_name
,
...
...
@@ -1128,6 +1254,9 @@ async function saveSettings() {
contact_info
:
form
.
contact_info
,
doc_url
:
form
.
doc_url
,
home_content
:
form
.
home_content
,
hide_ccs_import_button
:
form
.
hide_ccs_import_button
,
purchase_subscription_enabled
:
form
.
purchase_subscription_enabled
,
purchase_subscription_url
:
form
.
purchase_subscription_url
,
smtp_host
:
form
.
smtp_host
,
smtp_port
:
form
.
smtp_port
,
smtp_username
:
form
.
smtp_username
,
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
0170d19f
...
...
@@ -93,6 +93,57 @@
>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
<!-- Column Settings Dropdown -->
<div
class=
"relative"
ref=
"columnDropdownRef"
>
<button
@
click=
"showColumnDropdown = !showColumnDropdown"
class=
"btn btn-secondary px-2 md:px-3"
:title=
"t('admin.users.columnSettings')"
>
<svg
class=
"h-4 w-4 md:mr-1.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
<span
class=
"hidden md:inline"
>
{{
t
(
'
admin.users.columnSettings
'
)
}}
</span>
</button>
<!-- Dropdown menu -->
<div
v-if=
"showColumnDropdown"
class=
"absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
<div
class=
"p-2"
>
<!-- User column mode selection -->
<div
class=
"mb-2 border-b border-gray-200 pb-2 dark:border-gray-700"
>
<div
class=
"px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.subscriptions.columns.user
'
)
}}
</div>
<button
@
click=
"setUserColumnMode('email')"
class=
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>
{{
t
(
'
admin.users.columns.email
'
)
}}
</span>
<Icon
v-if=
"userColumnMode === 'email'"
name=
"check"
size=
"sm"
class=
"text-primary-500"
/>
</button>
<button
@
click=
"setUserColumnMode('username')"
class=
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>
{{
t
(
'
admin.users.columns.username
'
)
}}
</span>
<Icon
v-if=
"userColumnMode === 'username'"
name=
"check"
size=
"sm"
class=
"text-primary-500"
/>
</button>
</div>
<!-- Other columns toggle -->
<button
v-for=
"col in toggleableColumns"
:key=
"col.key"
@
click=
"toggleColumn(col.key)"
class=
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>
{{
col
.
label
}}
</span>
<Icon
v-if=
"isColumnVisible(col.key)"
name=
"check"
size=
"sm"
class=
"text-primary-500"
/>
</button>
</div>
</div>
</div>
<button
@
click=
"showAssignModal = true"
class=
"btn btn-primary"
>
<Icon
name=
"plus"
size=
"md"
class=
"mr-2"
/>
{{
t
(
'
admin.subscriptions.assignSubscription
'
)
}}
...
...
@@ -103,19 +154,31 @@
<!-- Subscriptions Table -->
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"subscriptions"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"subscriptions"
:loading=
"loading"
:server-side-sort=
"true"
@
sort=
"handleSort"
>
<template
#cell-user
="
{ row }">
<div
class=
"flex items-center gap-2"
>
<div
class=
"flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
>
<span
class=
"text-sm font-medium text-primary-700 dark:text-primary-300"
>
{{
row
.
user
?.
email
?.
charAt
(
0
).
toUpperCase
()
||
'
?
'
}}
{{
userColumnMode
===
'
email
'
?
(
row
.
user
?.
email
?.
charAt
(
0
).
toUpperCase
()
||
'
?
'
)
:
(
row
.
user
?.
username
?.
charAt
(
0
).
toUpperCase
()
||
'
?
'
)
}}
</span>
</div>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
user
?.
email
||
t
(
'
admin.redeem.userPrefix
'
,
{
id
:
row
.
user_id
}
)
}}
<
/span
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
userColumnMode
===
'
email
'
?
(
row
.
user
?.
email
||
t
(
'
admin.redeem.userPrefix
'
,
{
id
:
row
.
user_id
}
))
:
(
row
.
user
?.
username
||
'
-
'
)
}}
<
/span
>
<
/div
>
<
/template
>
...
...
@@ -300,12 +363,12 @@
<
template
#
cell
-
actions
=
"
{ row
}
"
>
<
div
class
=
"
flex items-center gap-1
"
>
<
button
v
-
if
=
"
row.status === 'active'
"
v
-
if
=
"
row.status === 'active'
|| row.status === 'expired'
"
@
click
=
"
handleExtend(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
"
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
"
>
<
Icon
name
=
"
c
lock
"
size
=
"
sm
"
/>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.subscriptions.
extend
'
)
}}
<
/span
>
<
Icon
name
=
"
c
alendar
"
size
=
"
sm
"
/>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.subscriptions.
adjust
'
)
}}
<
/span
>
<
/button
>
<
button
v
-
if
=
"
row.status === 'active'
"
...
...
@@ -409,7 +472,28 @@
v
-
model
=
"
assignForm.group_id
"
:
options
=
"
subscriptionGroupOptions
"
:
placeholder
=
"
t('admin.subscriptions.selectGroup')
"
/>
>
<
template
#
selected
=
"
{ option
}
"
>
<
GroupBadge
v
-
if
=
"
option
"
:
name
=
"
(option as unknown as GroupOption).label
"
:
platform
=
"
(option as unknown as GroupOption).platform
"
:
subscription
-
type
=
"
(option as unknown as GroupOption).subscriptionType
"
:
rate
-
multiplier
=
"
(option as unknown as GroupOption).rate
"
/>
<
span
v
-
else
class
=
"
text-gray-400
"
>
{{
t
(
'
admin.subscriptions.selectGroup
'
)
}}
<
/span
>
<
/template
>
<
template
#
option
=
"
{ option, selected
}
"
>
<
GroupOptionItem
:
name
=
"
(option as unknown as GroupOption).label
"
:
platform
=
"
(option as unknown as GroupOption).platform
"
:
subscription
-
type
=
"
(option as unknown as GroupOption).subscriptionType
"
:
rate
-
multiplier
=
"
(option as unknown as GroupOption).rate
"
:
description
=
"
(option as unknown as GroupOption).description
"
:
selected
=
"
selected
"
/>
<
/template
>
<
/Select
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.subscriptions.groupHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
...
...
@@ -455,10 +539,10 @@
<
/template
>
<
/BaseDialog
>
<!--
Extend
Subscription
Modal
-->
<!--
Adjust
Subscription
Modal
-->
<
BaseDialog
:
show
=
"
showExtendModal
"
:
title
=
"
t('admin.subscriptions.
extend
Subscription')
"
:
title
=
"
t('admin.subscriptions.
adjust
Subscription')
"
width
=
"
narrow
"
@
close
=
"
closeExtendModal
"
>
...
...
@@ -470,7 +554,7 @@
>
<
div
class
=
"
rounded-lg bg-gray-50 p-4 dark:bg-dark-700
"
>
<
p
class
=
"
text-sm text-gray-600 dark:text-gray-400
"
>
{{
t
(
'
admin.subscriptions.
extend
ingFor
'
)
}}
{{
t
(
'
admin.subscriptions.
adjust
ingFor
'
)
}}
<
span
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
extendingSubscription
.
user
?.
email
}}
<
/span
>
...
...
@@ -485,10 +569,25 @@
}}
<
/span
>
<
/p
>
<
p
v
-
if
=
"
extendingSubscription.expires_at
"
class
=
"
mt-1 text-sm text-gray-600 dark:text-gray-400
"
>
{{
t
(
'
admin.subscriptions.remainingDays
'
)
}}
:
<
span
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
getDaysRemaining
(
extendingSubscription
.
expires_at
)
??
0
}}
<
/span
>
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.extendDays
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
extendForm.days
"
type
=
"
number
"
min
=
"
1
"
required
class
=
"
input
"
/>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.adjustDays
'
)
}}
<
/label
>
<
div
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
.
number
=
"
extendForm.days
"
type
=
"
number
"
required
class
=
"
input text-center
"
:
placeholder
=
"
t('admin.subscriptions.adjustDaysPlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.subscriptions.adjustHint
'
)
}}
<
/p
>
<
/div
>
<
/form
>
<
template
#
footer
>
...
...
@@ -502,7 +601,7 @@
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
{{
submitting
?
t
(
'
admin.subscriptions.
extend
ing
'
)
:
t
(
'
admin.subscriptions.
extend
'
)
}}
{{
submitting
?
t
(
'
admin.subscriptions.
adjust
ing
'
)
:
t
(
'
admin.subscriptions.
adjust
'
)
}}
<
/button
>
<
/div
>
<
/template
>
...
...
@@ -527,7 +626,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
UserSubscription
,
Group
}
from
'
@/types
'
import
type
{
UserSubscription
,
Group
,
GroupPlatform
,
SubscriptionType
}
from
'
@/types
'
import
type
{
SimpleUser
}
from
'
@/api/admin/usage
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
formatDateOnly
}
from
'
@/utils/format
'
...
...
@@ -540,20 +639,128 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
user
'
,
label
:
t
(
'
admin.subscriptions.columns.user
'
),
sortable
:
true
}
,
{
key
:
'
group
'
,
label
:
t
(
'
admin.subscriptions.columns.group
'
),
sortable
:
true
}
,
interface
GroupOption
{
value
:
number
label
:
string
description
:
string
|
null
platform
:
GroupPlatform
subscriptionType
:
SubscriptionType
rate
:
number
}
// User column display mode: 'email' or 'username'
const
userColumnMode
=
ref
<
'
email
'
|
'
username
'
>
(
'
email
'
)
const
USER_COLUMN_MODE_KEY
=
'
subscription-user-column-mode
'
const
loadUserColumnMode
=
()
=>
{
try
{
const
saved
=
localStorage
.
getItem
(
USER_COLUMN_MODE_KEY
)
if
(
saved
===
'
email
'
||
saved
===
'
username
'
)
{
userColumnMode
.
value
=
saved
}
}
catch
(
e
)
{
console
.
error
(
'
Failed to load user column mode:
'
,
e
)
}
}
const
saveUserColumnMode
=
()
=>
{
try
{
localStorage
.
setItem
(
USER_COLUMN_MODE_KEY
,
userColumnMode
.
value
)
}
catch
(
e
)
{
console
.
error
(
'
Failed to save user column mode:
'
,
e
)
}
}
const
setUserColumnMode
=
(
mode
:
'
email
'
|
'
username
'
)
=>
{
userColumnMode
.
value
=
mode
saveUserColumnMode
()
}
// All available columns
const
allColumns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
user
'
,
label
:
userColumnMode
.
value
===
'
email
'
?
t
(
'
admin.subscriptions.columns.user
'
)
:
t
(
'
admin.users.columns.username
'
),
sortable
:
false
}
,
{
key
:
'
group
'
,
label
:
t
(
'
admin.subscriptions.columns.group
'
),
sortable
:
false
}
,
{
key
:
'
usage
'
,
label
:
t
(
'
admin.subscriptions.columns.usage
'
),
sortable
:
false
}
,
{
key
:
'
expires_at
'
,
label
:
t
(
'
admin.subscriptions.columns.expires
'
),
sortable
:
true
}
,
{
key
:
'
status
'
,
label
:
t
(
'
admin.subscriptions.columns.status
'
),
sortable
:
true
}
,
{
key
:
'
actions
'
,
label
:
t
(
'
admin.subscriptions.columns.actions
'
),
sortable
:
false
}
])
// Columns that can be toggled (exclude user and actions which are always visible)
const
toggleableColumns
=
computed
(()
=>
allColumns
.
value
.
filter
(
col
=>
col
.
key
!==
'
user
'
&&
col
.
key
!==
'
actions
'
)
)
// Hidden columns set
const
hiddenColumns
=
reactive
<
Set
<
string
>>
(
new
Set
())
// Default hidden columns
const
DEFAULT_HIDDEN_COLUMNS
:
string
[]
=
[]
// localStorage key
const
HIDDEN_COLUMNS_KEY
=
'
subscription-hidden-columns
'
// Load saved column settings
const
loadSavedColumns
=
()
=>
{
try
{
const
saved
=
localStorage
.
getItem
(
HIDDEN_COLUMNS_KEY
)
if
(
saved
)
{
const
parsed
=
JSON
.
parse
(
saved
)
as
string
[]
parsed
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
}
else
{
DEFAULT_HIDDEN_COLUMNS
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
}
}
catch
(
e
)
{
console
.
error
(
'
Failed to load saved columns:
'
,
e
)
DEFAULT_HIDDEN_COLUMNS
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
}
}
// Save column settings to localStorage
const
saveColumnsToStorage
=
()
=>
{
try
{
localStorage
.
setItem
(
HIDDEN_COLUMNS_KEY
,
JSON
.
stringify
([...
hiddenColumns
]))
}
catch
(
e
)
{
console
.
error
(
'
Failed to save columns:
'
,
e
)
}
}
// Toggle column visibility
const
toggleColumn
=
(
key
:
string
)
=>
{
if
(
hiddenColumns
.
has
(
key
))
{
hiddenColumns
.
delete
(
key
)
}
else
{
hiddenColumns
.
add
(
key
)
}
saveColumnsToStorage
()
}
// Check if column is visible
const
isColumnVisible
=
(
key
:
string
)
=>
!
hiddenColumns
.
has
(
key
)
// Filtered columns for display
const
columns
=
computed
<
Column
[]
>
(()
=>
allColumns
.
value
.
filter
(
col
=>
col
.
key
===
'
user
'
||
col
.
key
===
'
actions
'
||
!
hiddenColumns
.
has
(
col
.
key
)
)
)
// Column dropdown state
const
showColumnDropdown
=
ref
(
false
)
const
columnDropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
// Filter options
const
statusOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.subscriptions.allStatus
'
)
}
,
...
...
@@ -584,10 +791,17 @@ const selectedUser = ref<SimpleUser | null>(null)
let
userSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
filters
=
reactive
({
status
:
''
,
status
:
'
active
'
,
group_id
:
''
,
user_id
:
null
as
number
|
null
}
)
// Sorting state
const
sortState
=
reactive
({
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
}
)
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
...
...
@@ -622,7 +836,14 @@ const groupOptions = computed(() => [
const
subscriptionGroupOptions
=
computed
(()
=>
groups
.
value
.
filter
((
g
)
=>
g
.
subscription_type
===
'
subscription
'
&&
g
.
status
===
'
active
'
)
.
map
((
g
)
=>
({
value
:
g
.
id
,
label
:
g
.
name
}
))
.
map
((
g
)
=>
({
value
:
g
.
id
,
label
:
g
.
name
,
description
:
g
.
description
,
platform
:
g
.
platform
,
subscriptionType
:
g
.
subscription_type
,
rate
:
g
.
rate_multiplier
}
))
)
const
applyFilters
=
()
=>
{
...
...
@@ -646,7 +867,9 @@ const loadSubscriptions = async () => {
{
status
:
(
filters
.
status
as
any
)
||
undefined
,
group_id
:
filters
.
group_id
?
parseInt
(
filters
.
group_id
)
:
undefined
,
user_id
:
filters
.
user_id
||
undefined
user_id
:
filters
.
user_id
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
}
,
{
signal
...
...
@@ -787,6 +1010,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadSubscriptions
()
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadSubscriptions
()
}
const
closeAssignModal
=
()
=>
{
showAssignModal
.
value
=
false
assignForm
.
user_id
=
null
...
...
@@ -845,17 +1075,27 @@ const closeExtendModal = () => {
const
handleExtendSubscription
=
async
()
=>
{
if
(
!
extendingSubscription
.
value
)
return
// 前端验证:调整后的过期时间必须在未来
if
(
extendingSubscription
.
value
.
expires_at
)
{
const
expiresAt
=
new
Date
(
extendingSubscription
.
value
.
expires_at
)
const
newExpiresAt
=
new
Date
(
expiresAt
.
getTime
()
+
extendForm
.
days
*
24
*
60
*
60
*
1000
)
if
(
newExpiresAt
<=
new
Date
())
{
appStore
.
showError
(
t
(
'
admin.subscriptions.adjustWouldExpire
'
))
return
}
}
submitting
.
value
=
true
try
{
await
adminAPI
.
subscriptions
.
extend
(
extendingSubscription
.
value
.
id
,
{
days
:
extendForm
.
days
}
)
appStore
.
showSuccess
(
t
(
'
admin.subscriptions.subscription
Extend
ed
'
))
appStore
.
showSuccess
(
t
(
'
admin.subscriptions.subscription
Adjust
ed
'
))
closeExtendModal
()
loadSubscriptions
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.subscriptions.failedTo
Extend
'
))
console
.
error
(
'
Error
extend
ing subscription:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.subscriptions.failedTo
Adjust
'
))
console
.
error
(
'
Error
adjust
ing subscription:
'
,
error
)
}
finally
{
submitting
.
value
=
false
}
...
...
@@ -949,14 +1189,19 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont
}
}
// Handle click outside to close
user
dropdown
// Handle click outside to close dropdown
s
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
if
(
!
target
.
closest
(
'
[data-assign-user-search]
'
))
showUserDropdown
.
value
=
false
if
(
!
target
.
closest
(
'
[data-filter-user-search]
'
))
showFilterUserDropdown
.
value
=
false
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
showColumnDropdown
.
value
=
false
}
}
onMounted
(()
=>
{
loadUserColumnMode
()
loadSavedColumns
()
loadSubscriptions
()
loadGroups
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
...
...
frontend/src/views/admin/UsageView.vue
View file @
0170d19f
...
...
@@ -17,12 +17,19 @@
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
</div>
</div>
<UsageFilters
v-model=
"filters"
v-model:startDate=
"startDate"
v-model:endDate=
"endDate"
:exporting=
"exporting"
@
change=
"applyFilters"
@
reset=
"resetFilters"
@
export=
"exportToExcel"
/>
<UsageFilters
v-model=
"filters"
v-model:startDate=
"startDate"
v-model:endDate=
"endDate"
:exporting=
"exporting"
@
change=
"applyFilters"
@
reset=
"resetFilters"
@
cleanup=
"openCleanupDialog"
@
export=
"exportToExcel"
/>
<UsageTable
:data=
"usageLogs"
:loading=
"loading"
/>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</div>
</AppLayout>
<UsageExportProgress
:show=
"exportProgress.show"
:progress=
"exportProgress.progress"
:current=
"exportProgress.current"
:total=
"exportProgress.total"
:estimated-time=
"exportProgress.estimatedTime"
@
cancel=
"cancelExport"
/>
<UsageCleanupDialog
:show=
"cleanupDialogVisible"
:filters=
"filters"
:start-date=
"startDate"
:end-date=
"endDate"
@
close=
"cleanupDialogVisible = false"
/>
</
template
>
<
script
setup
lang=
"ts"
>
...
...
@@ -33,15 +40,17 @@ import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admi
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
;
import
Pagination
from
'
@/components/common/Pagination.vue
'
;
import
Select
from
'
@/components/common/Select.vue
'
import
UsageStatsCards
from
'
@/components/admin/usage/UsageStatsCards.vue
'
;
import
UsageFilters
from
'
@/components/admin/usage/UsageFilters.vue
'
import
UsageTable
from
'
@/components/admin/usage/UsageTable.vue
'
;
import
UsageExportProgress
from
'
@/components/admin/usage/UsageExportProgress.vue
'
import
UsageCleanupDialog
from
'
@/components/admin/usage/UsageCleanupDialog.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
;
import
TokenUsageTrend
from
'
@/components/charts/TokenUsageTrend.vue
'
import
type
{
UsageLog
,
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
;
import
type
{
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
import
type
{
Admin
UsageLog
,
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
;
import
type
{
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
);
const
usageLogs
=
ref
<
UsageLog
[]
>
([]);
const
loading
=
ref
(
false
);
const
exporting
=
ref
(
false
)
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
);
const
usageLogs
=
ref
<
Admin
UsageLog
[]
>
([]);
const
loading
=
ref
(
false
);
const
exporting
=
ref
(
false
)
const
trendData
=
ref
<
TrendDataPoint
[]
>
([]);
const
modelStats
=
ref
<
ModelStat
[]
>
([]);
const
chartsLoading
=
ref
(
false
);
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
day
'
)
let
abortController
:
AbortController
|
null
=
null
;
let
exportAbortController
:
AbortController
|
null
=
null
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
const
cleanupDialogVisible
=
ref
(
false
)
const
granularityOptions
=
computed
(()
=>
[{
value
:
'
day
'
,
label
:
t
(
'
admin.dashboard.day
'
)
},
{
value
:
'
hour
'
,
label
:
t
(
'
admin.dashboard.hour
'
)
}])
// Use local timezone to avoid UTC timezone issues
...
...
@@ -53,7 +62,7 @@ const formatLD = (d: Date) => {
}
const
now
=
new
Date
();
const
weekAgo
=
new
Date
();
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
const
startDate
=
ref
(
formatLD
(
weekAgo
));
const
endDate
=
ref
(
formatLD
(
now
))
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
model
:
undefined
,
group_id
:
undefined
,
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
})
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
model
:
undefined
,
group_id
:
undefined
,
billing_type
:
null
,
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
})
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
})
const
loadLogs
=
async
()
=>
{
...
...
@@ -67,22 +76,23 @@ const loadStats = async () => { try { const s = await adminAPI.usage.getStats(fi
const
loadChartData
=
async
()
=>
{
chartsLoading
.
value
=
true
try
{
const
params
=
{
start_date
:
filters
.
value
.
start_date
||
startDate
.
value
,
end_date
:
filters
.
value
.
end_date
||
endDate
.
value
,
granularity
:
granularity
.
value
,
user_id
:
filters
.
value
.
user_id
,
model
:
filters
.
value
.
model
,
api_key_id
:
filters
.
value
.
api_key_id
,
account_id
:
filters
.
value
.
account_id
,
group_id
:
filters
.
value
.
group_id
,
stream
:
filters
.
value
.
stream
}
const
[
trendRes
,
modelRes
]
=
await
Promise
.
all
([
adminAPI
.
dashboard
.
getUsageTrend
(
params
),
adminAPI
.
dashboard
.
getModelStats
({
start_date
:
params
.
start_date
,
end_date
:
params
.
end_date
,
user_id
:
params
.
user_id
,
model
:
params
.
model
,
api_key_id
:
params
.
api_key_id
,
account_id
:
params
.
account_id
,
group_id
:
params
.
group_id
,
stream
:
params
.
stream
})])
const
params
=
{
start_date
:
filters
.
value
.
start_date
||
startDate
.
value
,
end_date
:
filters
.
value
.
end_date
||
endDate
.
value
,
granularity
:
granularity
.
value
,
user_id
:
filters
.
value
.
user_id
,
model
:
filters
.
value
.
model
,
api_key_id
:
filters
.
value
.
api_key_id
,
account_id
:
filters
.
value
.
account_id
,
group_id
:
filters
.
value
.
group_id
,
stream
:
filters
.
value
.
stream
,
billing_type
:
filters
.
value
.
billing_type
}
const
[
trendRes
,
modelRes
]
=
await
Promise
.
all
([
adminAPI
.
dashboard
.
getUsageTrend
(
params
),
adminAPI
.
dashboard
.
getModelStats
({
start_date
:
params
.
start_date
,
end_date
:
params
.
end_date
,
user_id
:
params
.
user_id
,
model
:
params
.
model
,
api_key_id
:
params
.
api_key_id
,
account_id
:
params
.
account_id
,
group_id
:
params
.
group_id
,
stream
:
params
.
stream
,
billing_type
:
params
.
billing_type
})])
trendData
.
value
=
trendRes
.
trend
||
[];
modelStats
.
value
=
modelRes
.
models
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load chart data:
'
,
error
)
}
finally
{
chartsLoading
.
value
=
false
}
}
const
applyFilters
=
()
=>
{
pagination
.
page
=
1
;
loadLogs
();
loadStats
();
loadChartData
()
}
const
resetFilters
=
()
=>
{
startDate
.
value
=
formatLD
(
weekAgo
);
endDate
.
value
=
formatLD
(
now
);
filters
.
value
=
{
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
};
granularity
.
value
=
'
day
'
;
applyFilters
()
}
const
resetFilters
=
()
=>
{
startDate
.
value
=
formatLD
(
weekAgo
);
endDate
.
value
=
formatLD
(
now
);
filters
.
value
=
{
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
,
billing_type
:
null
};
granularity
.
value
=
'
day
'
;
applyFilters
()
}
const
handlePageChange
=
(
p
:
number
)
=>
{
pagination
.
page
=
p
;
loadLogs
()
}
const
handlePageSizeChange
=
(
s
:
number
)
=>
{
pagination
.
page_size
=
s
;
pagination
.
page
=
1
;
loadLogs
()
}
const
cancelExport
=
()
=>
exportAbortController
?.
abort
()
const
openCleanupDialog
=
()
=>
{
cleanupDialogVisible
.
value
=
true
}
const
exportToExcel
=
async
()
=>
{
if
(
exporting
.
value
)
return
;
exporting
.
value
=
true
;
exportProgress
.
show
=
true
const
c
=
new
AbortController
();
exportAbortController
=
c
try
{
const
all
:
UsageLog
[]
=
[];
let
p
=
1
;
let
total
=
pagination
.
total
const
all
:
Admin
UsageLog
[]
=
[];
let
p
=
1
;
let
total
=
pagination
.
total
while
(
true
)
{
const
res
=
await
adminUsageAPI
.
list
({
page
:
p
,
page_size
:
100
,
...
filters
.
value
},
{
signal
:
c
.
signal
})
if
(
c
.
signal
.
aborted
)
break
;
if
(
p
===
1
)
{
total
=
res
.
total
;
exportProgress
.
total
=
total
}
...
...
frontend/src/views/admin/UsersView.vue
View file @
0170d19f
...
...
@@ -492,7 +492,7 @@ import Icon from '@/components/icons/Icon.vue'
const
{
t
}
=
useI18n
()
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
UserAttributeDefinition
}
from
'
@/types
'
import
type
{
Admin
User
,
UserAttributeDefinition
}
from
'
@/types
'
import
type
{
BatchUserUsageStats
}
from
'
@/api/admin/dashboard
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
...
...
@@ -637,7 +637,7 @@ const columns = computed<Column[]>(() =>
)
)
const
users
=
ref
<
User
[]
>
([])
const
users
=
ref
<
Admin
User
[]
>
([])
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
...
...
@@ -736,16 +736,16 @@ const showEditModal = ref(false)
const
showDeleteDialog
=
ref
(
false
)
const
showApiKeysModal
=
ref
(
false
)
const
showAttributesModal
=
ref
(
false
)
const
editingUser
=
ref
<
User
|
null
>
(
null
)
const
deletingUser
=
ref
<
User
|
null
>
(
null
)
const
viewingUser
=
ref
<
User
|
null
>
(
null
)
const
editingUser
=
ref
<
Admin
User
|
null
>
(
null
)
const
deletingUser
=
ref
<
Admin
User
|
null
>
(
null
)
const
viewingUser
=
ref
<
Admin
User
|
null
>
(
null
)
let
abortController
:
AbortController
|
null
=
null
// Action Menu State
const
activeMenuId
=
ref
<
number
|
null
>
(
null
)
const
menuPosition
=
ref
<
{
top
:
number
;
left
:
number
}
|
null
>
(
null
)
const
openActionMenu
=
(
user
:
User
,
e
:
MouseEvent
)
=>
{
const
openActionMenu
=
(
user
:
Admin
User
,
e
:
MouseEvent
)
=>
{
if
(
activeMenuId
.
value
===
user
.
id
)
{
closeActionMenu
()
}
else
{
...
...
@@ -821,11 +821,11 @@ const handleClickOutside = (event: MouseEvent) => {
// Allowed groups modal state
const
showAllowedGroupsModal
=
ref
(
false
)
const
allowedGroupsUser
=
ref
<
User
|
null
>
(
null
)
const
allowedGroupsUser
=
ref
<
Admin
User
|
null
>
(
null
)
// Balance (Deposit/Withdraw) modal state
const
showBalanceModal
=
ref
(
false
)
const
balanceUser
=
ref
<
User
|
null
>
(
null
)
const
balanceUser
=
ref
<
Admin
User
|
null
>
(
null
)
const
balanceOperation
=
ref
<
'
add
'
|
'
subtract
'
>
(
'
add
'
)
// 计算剩余天数
...
...
@@ -998,7 +998,7 @@ const applyFilter = () => {
loadUsers
()
}
const
handleEdit
=
(
user
:
User
)
=>
{
const
handleEdit
=
(
user
:
Admin
User
)
=>
{
editingUser
.
value
=
user
showEditModal
.
value
=
true
}
...
...
@@ -1008,7 +1008,7 @@ const closeEditModal = () => {
editingUser
.
value
=
null
}
const
handleToggleStatus
=
async
(
user
:
User
)
=>
{
const
handleToggleStatus
=
async
(
user
:
Admin
User
)
=>
{
const
newStatus
=
user
.
status
===
'
active
'
?
'
disabled
'
:
'
active
'
try
{
await
adminAPI
.
users
.
toggleStatus
(
user
.
id
,
newStatus
)
...
...
@@ -1022,7 +1022,7 @@ const handleToggleStatus = async (user: User) => {
}
}
const
handleViewApiKeys
=
(
user
:
User
)
=>
{
const
handleViewApiKeys
=
(
user
:
Admin
User
)
=>
{
viewingUser
.
value
=
user
showApiKeysModal
.
value
=
true
}
...
...
@@ -1032,7 +1032,7 @@ const closeApiKeysModal = () => {
viewingUser
.
value
=
null
}
const
handleAllowedGroups
=
(
user
:
User
)
=>
{
const
handleAllowedGroups
=
(
user
:
Admin
User
)
=>
{
allowedGroupsUser
.
value
=
user
showAllowedGroupsModal
.
value
=
true
}
...
...
@@ -1042,7 +1042,7 @@ const closeAllowedGroupsModal = () => {
allowedGroupsUser
.
value
=
null
}
const
handleDelete
=
(
user
:
User
)
=>
{
const
handleDelete
=
(
user
:
Admin
User
)
=>
{
deletingUser
.
value
=
user
showDeleteDialog
.
value
=
true
}
...
...
@@ -1061,13 +1061,13 @@ const confirmDelete = async () => {
}
}
const
handleDeposit
=
(
user
:
User
)
=>
{
const
handleDeposit
=
(
user
:
Admin
User
)
=>
{
balanceUser
.
value
=
user
balanceOperation
.
value
=
'
add
'
showBalanceModal
.
value
=
true
}
const
handleWithdraw
=
(
user
:
User
)
=>
{
const
handleWithdraw
=
(
user
:
Admin
User
)
=>
{
balanceUser
.
value
=
user
balanceOperation
.
value
=
'
subtract
'
showBalanceModal
.
value
=
true
...
...
frontend/src/views/admin/ops/components/OpsSettingsDialog.vue
View file @
0170d19f
...
...
@@ -505,6 +505,16 @@ async function saveAllSettings() {
</div>
<Toggle
v-model=
"advancedSettings.ignore_no_available_accounts"
/>
</div>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.ignoreInvalidApiKeyErrors
'
)
}}
</label>
<p
class=
"mt-1 text-xs text-gray-500"
>
{{
t
(
'
admin.ops.settings.ignoreInvalidApiKeyErrorsHint
'
)
}}
</p>
</div>
<Toggle
v-model=
"advancedSettings.ignore_invalid_api_key_errors"
/>
</div>
</div>
<!-- Auto Refresh -->
...
...
frontend/src/views/auth/ForgotPasswordView.vue
0 → 100644
View file @
0170d19f
<
template
>
<AuthLayout>
<div
class=
"space-y-6"
>
<!-- Title -->
<div
class=
"text-center"
>
<h2
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
auth.forgotPasswordTitle
'
)
}}
</h2>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.forgotPasswordHint
'
)
}}
</p>
</div>
<!-- Success State -->
<div
v-if=
"isSubmitted"
class=
"space-y-6"
>
<div
class=
"rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20"
>
<div
class=
"flex flex-col items-center gap-4 text-center"
>
<div
class=
"flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50"
>
<Icon
name=
"checkCircle"
size=
"lg"
class=
"text-green-600 dark:text-green-400"
/>
</div>
<div>
<h3
class=
"text-lg font-semibold text-green-800 dark:text-green-200"
>
{{
t
(
'
auth.resetEmailSent
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-green-700 dark:text-green-300"
>
{{
t
(
'
auth.resetEmailSentHint
'
)
}}
</p>
</div>
</div>
</div>
<div
class=
"text-center"
>
<router-link
to=
"/login"
class=
"inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon
name=
"arrowLeft"
size=
"sm"
/>
{{
t
(
'
auth.backToLogin
'
)
}}
</router-link>
</div>
</div>
<!-- Form State -->
<form
v-else
@
submit.prevent=
"handleSubmit"
class=
"space-y-5"
>
<!-- Email Input -->
<div>
<label
for=
"email"
class=
"input-label"
>
{{
t
(
'
auth.emailLabel
'
)
}}
</label>
<div
class=
"relative"
>
<div
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5"
>
<Icon
name=
"mail"
size=
"md"
class=
"text-gray-400 dark:text-dark-500"
/>
</div>
<input
id=
"email"
v-model=
"formData.email"
type=
"email"
required
autofocus
autocomplete=
"email"
:disabled=
"isLoading"
class=
"input pl-11"
:class=
"
{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p
v-if=
"errors.email"
class=
"input-error-text"
>
{{
errors
.
email
}}
</p>
</div>
<!-- Turnstile Widget -->
<div
v-if=
"turnstileEnabled && turnstileSiteKey"
>
<TurnstileWidget
ref=
"turnstileRef"
:site-key=
"turnstileSiteKey"
@
verify=
"onTurnstileVerify"
@
expire=
"onTurnstileExpire"
@
error=
"onTurnstileError"
/>
<p
v-if=
"errors.turnstile"
class=
"input-error-text mt-2 text-center"
>
{{
errors
.
turnstile
}}
</p>
</div>
<!-- Error Message -->
<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>
<p
class=
"text-sm text-red-700 dark:text-red-400"
>
{{
errorMessage
}}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type=
"submit"
:disabled=
"isLoading || (turnstileEnabled && !turnstileToken)"
class=
"btn btn-primary w-full"
>
<svg
v-if=
"isLoading"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin text-white"
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>
<Icon
v-else
name=
"mail"
size=
"md"
class=
"mr-2"
/>
{{
isLoading
?
t
(
'
auth.sendingResetLink
'
)
:
t
(
'
auth.sendResetLink
'
)
}}
</button>
</form>
</div>
<!-- Footer -->
<template
#footer
>
<p
class=
"text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.rememberedPassword
'
)
}}
<router-link
to=
"/login"
class=
"font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{
t
(
'
auth.signIn
'
)
}}
</router-link>
</p>
</
template
>
</AuthLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
getPublicSettings
,
forgotPassword
}
from
'
@/api/auth
'
const
{
t
}
=
useI18n
()
// ==================== Stores ====================
const
appStore
=
useAppStore
()
// ==================== State ====================
const
isLoading
=
ref
<
boolean
>
(
false
)
const
isSubmitted
=
ref
<
boolean
>
(
false
)
const
errorMessage
=
ref
<
string
>
(
''
)
// Public settings
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
// Turnstile
const
turnstileRef
=
ref
<
InstanceType
<
typeof
TurnstileWidget
>
|
null
>
(
null
)
const
turnstileToken
=
ref
<
string
>
(
''
)
const
formData
=
reactive
({
email
:
''
})
const
errors
=
reactive
({
email
:
''
,
turnstile
:
''
})
// ==================== Lifecycle ====================
onMounted
(
async
()
=>
{
try
{
const
settings
=
await
getPublicSettings
()
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
}
})
// ==================== Turnstile Handlers ====================
function
onTurnstileVerify
(
token
:
string
):
void
{
turnstileToken
.
value
=
token
errors
.
turnstile
=
''
}
function
onTurnstileExpire
():
void
{
turnstileToken
.
value
=
''
errors
.
turnstile
=
t
(
'
auth.turnstileExpired
'
)
}
function
onTurnstileError
():
void
{
turnstileToken
.
value
=
''
errors
.
turnstile
=
t
(
'
auth.turnstileFailed
'
)
}
// ==================== Validation ====================
function
validateForm
():
boolean
{
errors
.
email
=
''
errors
.
turnstile
=
''
let
isValid
=
true
// Email validation
if
(
!
formData
.
email
.
trim
())
{
errors
.
email
=
t
(
'
auth.emailRequired
'
)
isValid
=
false
}
else
if
(
!
/^
[^\s
@
]
+@
[^\s
@
]
+
\.[^\s
@
]
+$/
.
test
(
formData
.
email
))
{
errors
.
email
=
t
(
'
auth.invalidEmail
'
)
isValid
=
false
}
// Turnstile validation
if
(
turnstileEnabled
.
value
&&
!
turnstileToken
.
value
)
{
errors
.
turnstile
=
t
(
'
auth.completeVerification
'
)
isValid
=
false
}
return
isValid
}
// ==================== Form Handlers ====================
async
function
handleSubmit
():
Promise
<
void
>
{
errorMessage
.
value
=
''
if
(
!
validateForm
())
{
return
}
isLoading
.
value
=
true
try
{
await
forgotPassword
({
email
:
formData
.
email
,
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
})
isSubmitted
.
value
=
true
appStore
.
showSuccess
(
t
(
'
auth.resetEmailSent
'
))
}
catch
(
error
:
unknown
)
{
// Reset Turnstile on error
if
(
turnstileRef
.
value
)
{
turnstileRef
.
value
.
reset
()
turnstileToken
.
value
=
''
}
const
err
=
error
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
}
}
}
if
(
err
.
response
?.
data
?.
detail
)
{
errorMessage
.
value
=
err
.
response
.
data
.
detail
}
else
if
(
err
.
message
)
{
errorMessage
.
value
=
err
.
message
}
else
{
errorMessage
.
value
=
t
(
'
auth.sendResetLinkFailed
'
)
}
appStore
.
showError
(
errorMessage
.
value
)
}
finally
{
isLoading
.
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 @
0170d19f
...
...
@@ -72,9 +72,19 @@
<Icon
v-else
name=
"eye"
size=
"md"
/>
</button>
</div>
<p
v-if=
"errors.password"
class=
"input-error-text"
>
{{
errors
.
password
}}
</p>
<div
class=
"mt-1 flex items-center justify-between"
>
<p
v-if=
"errors.password"
class=
"input-error-text"
>
{{
errors
.
password
}}
</p>
<span
v-else
></span>
<router-link
v-if=
"passwordResetEnabled"
to=
"/forgot-password"
class=
"text-sm font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{
t
(
'
auth.forgotPassword
'
)
}}
</router-link>
</div>
</div>
<!-- Turnstile Widget -->
...
...
@@ -153,6 +163,16 @@
</p>
</
template
>
</AuthLayout>
<!-- 2FA Modal -->
<TotpLoginModal
v-if=
"show2FAModal"
ref=
"totpModalRef"
:temp-token=
"totpTempToken"
:user-email-masked=
"totpUserEmailMasked"
@
verify=
"handle2FAVerify"
@
cancel=
"handle2FACancel"
/>
</template>
<
script
setup
lang=
"ts"
>
...
...
@@ -161,10 +181,12 @@ import { useRouter } from 'vue-router'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
TotpLoginModal
from
'
@/components/auth/TotpLoginModal.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
,
isTotp2FARequired
}
from
'
@/api/auth
'
import
type
{
TotpLoginResponse
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
...
...
@@ -184,11 +206,18 @@ const showPassword = ref<boolean>(false)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
passwordResetEnabled
=
ref
<
boolean
>
(
false
)
// Turnstile
const
turnstileRef
=
ref
<
InstanceType
<
typeof
TurnstileWidget
>
|
null
>
(
null
)
const
turnstileToken
=
ref
<
string
>
(
''
)
// 2FA state
const
show2FAModal
=
ref
<
boolean
>
(
false
)
const
totpTempToken
=
ref
<
string
>
(
''
)
const
totpUserEmailMasked
=
ref
<
string
>
(
''
)
const
totpModalRef
=
ref
<
InstanceType
<
typeof
TotpLoginModal
>
|
null
>
(
null
)
const
formData
=
reactive
({
email
:
''
,
password
:
''
...
...
@@ -216,6 +245,7 @@ onMounted(async () => {
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
passwordResetEnabled
.
value
=
settings
.
password_reset_enabled
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
}
...
...
@@ -290,12 +320,22 @@ async function handleLogin(): Promise<void> {
try
{
// Call auth store login
await
authStore
.
login
({
const
response
=
await
authStore
.
login
({
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
})
// Check if 2FA is required
if
(
isTotp2FARequired
(
response
))
{
const
totpResponse
=
response
as
TotpLoginResponse
totpTempToken
.
value
=
totpResponse
.
temp_token
||
''
totpUserEmailMasked
.
value
=
totpResponse
.
user_email_masked
||
''
show2FAModal
.
value
=
true
isLoading
.
value
=
false
return
}
// Show success toast
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
...
...
@@ -326,6 +366,40 @@ async function handleLogin(): Promise<void> {
isLoading
.
value
=
false
}
}
// ==================== 2FA Handlers ====================
async
function
handle2FAVerify
(
code
:
string
):
Promise
<
void
>
{
if
(
totpModalRef
.
value
)
{
totpModalRef
.
value
.
setVerifying
(
true
)
}
try
{
await
authStore
.
login2FA
(
totpTempToken
.
value
,
code
)
// Close modal and show success
show2FAModal
.
value
=
false
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
// Redirect to dashboard or intended route
const
redirectTo
=
(
router
.
currentRoute
.
value
.
query
.
redirect
as
string
)
||
'
/dashboard
'
await
router
.
push
(
redirectTo
)
}
catch
(
error
:
unknown
)
{
const
err
=
error
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
const
message
=
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
profile.totp.loginFailed
'
)
if
(
totpModalRef
.
value
)
{
totpModalRef
.
value
.
setError
(
message
)
totpModalRef
.
value
.
setVerifying
(
false
)
}
}
}
function
handle2FACancel
():
void
{
show2FAModal
.
value
=
false
totpTempToken
.
value
=
''
totpUserEmailMasked
.
value
=
''
}
</
script
>
<
style
scoped
>
...
...
frontend/src/views/auth/RegisterView.vue
View file @
0170d19f
...
...
@@ -96,7 +96,7 @@
<
/div
>
<!--
Promo
Code
Input
(
Optional
)
-->
<
div
>
<
div
v
-
if
=
"
promoCodeEnabled
"
>
<
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
>
...
...
@@ -260,6 +260,7 @@ const showPassword = ref<boolean>(false)
// Public settings
const
registrationEnabled
=
ref
<
boolean
>
(
true
)
const
emailVerifyEnabled
=
ref
<
boolean
>
(
false
)
const
promoCodeEnabled
=
ref
<
boolean
>
(
true
)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
siteName
=
ref
<
string
>
(
'
Sub2API
'
)
...
...
@@ -294,22 +295,25 @@ 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
emailVerifyEnabled
.
value
=
settings
.
email_verify_enabled
promoCodeEnabled
.
value
=
settings
.
promo_code_enabled
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
// Read promo code from URL parameter only if promo code is enabled
if
(
promoCodeEnabled
.
value
)
{
const
promoParam
=
route
.
query
.
promo
as
string
if
(
promoParam
)
{
formData
.
promo_code
=
promoParam
// Validate the promo code from URL
await
validatePromoCodeDebounced
(
promoParam
)
}
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
}
finally
{
...
...
frontend/src/views/auth/ResetPasswordView.vue
0 → 100644
View file @
0170d19f
<
template
>
<AuthLayout>
<div
class=
"space-y-6"
>
<!-- Title -->
<div
class=
"text-center"
>
<h2
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
auth.resetPasswordTitle
'
)
}}
</h2>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.resetPasswordHint
'
)
}}
</p>
</div>
<!-- Invalid Link State -->
<div
v-if=
"isInvalidLink"
class=
"space-y-6"
>
<div
class=
"rounded-xl border border-red-200 bg-red-50 p-6 dark:border-red-800/50 dark:bg-red-900/20"
>
<div
class=
"flex flex-col items-center gap-4 text-center"
>
<div
class=
"flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-800/50"
>
<Icon
name=
"exclamationCircle"
size=
"lg"
class=
"text-red-600 dark:text-red-400"
/>
</div>
<div>
<h3
class=
"text-lg font-semibold text-red-800 dark:text-red-200"
>
{{
t
(
'
auth.invalidResetLink
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-red-700 dark:text-red-300"
>
{{
t
(
'
auth.invalidResetLinkHint
'
)
}}
</p>
</div>
</div>
</div>
<div
class=
"text-center"
>
<router-link
to=
"/forgot-password"
class=
"inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{
t
(
'
auth.requestNewResetLink
'
)
}}
</router-link>
</div>
</div>
<!-- Success State -->
<div
v-else-if=
"isSuccess"
class=
"space-y-6"
>
<div
class=
"rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20"
>
<div
class=
"flex flex-col items-center gap-4 text-center"
>
<div
class=
"flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50"
>
<Icon
name=
"checkCircle"
size=
"lg"
class=
"text-green-600 dark:text-green-400"
/>
</div>
<div>
<h3
class=
"text-lg font-semibold text-green-800 dark:text-green-200"
>
{{
t
(
'
auth.passwordResetSuccess
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-green-700 dark:text-green-300"
>
{{
t
(
'
auth.passwordResetSuccessHint
'
)
}}
</p>
</div>
</div>
</div>
<div
class=
"text-center"
>
<router-link
to=
"/login"
class=
"btn btn-primary inline-flex items-center gap-2"
>
<Icon
name=
"login"
size=
"md"
/>
{{
t
(
'
auth.signIn
'
)
}}
</router-link>
</div>
</div>
<!-- Form State -->
<form
v-else
@
submit.prevent=
"handleSubmit"
class=
"space-y-5"
>
<!-- Email (readonly) -->
<div>
<label
for=
"email"
class=
"input-label"
>
{{
t
(
'
auth.emailLabel
'
)
}}
</label>
<div
class=
"relative"
>
<div
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5"
>
<Icon
name=
"mail"
size=
"md"
class=
"text-gray-400 dark:text-dark-500"
/>
</div>
<input
id=
"email"
:value=
"email"
type=
"email"
readonly
disabled
class=
"input pl-11 bg-gray-50 dark:bg-dark-700"
/>
</div>
</div>
<!-- New Password Input -->
<div>
<label
for=
"password"
class=
"input-label"
>
{{
t
(
'
auth.newPassword
'
)
}}
</label>
<div
class=
"relative"
>
<div
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5"
>
<Icon
name=
"lock"
size=
"md"
class=
"text-gray-400 dark:text-dark-500"
/>
</div>
<input
id=
"password"
v-model=
"formData.password"
:type=
"showPassword ? 'text' : 'password'"
required
autocomplete=
"new-password"
:disabled=
"isLoading"
class=
"input pl-11 pr-11"
:class=
"
{ 'input-error': errors.password }"
:placeholder="t('auth.newPasswordPlaceholder')"
/>
<button
type=
"button"
@
click=
"showPassword = !showPassword"
class=
"absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon
v-if=
"showPassword"
name=
"eyeOff"
size=
"md"
/>
<Icon
v-else
name=
"eye"
size=
"md"
/>
</button>
</div>
<p
v-if=
"errors.password"
class=
"input-error-text"
>
{{
errors
.
password
}}
</p>
</div>
<!-- Confirm Password Input -->
<div>
<label
for=
"confirmPassword"
class=
"input-label"
>
{{
t
(
'
auth.confirmPassword
'
)
}}
</label>
<div
class=
"relative"
>
<div
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5"
>
<Icon
name=
"lock"
size=
"md"
class=
"text-gray-400 dark:text-dark-500"
/>
</div>
<input
id=
"confirmPassword"
v-model=
"formData.confirmPassword"
:type=
"showConfirmPassword ? 'text' : 'password'"
required
autocomplete=
"new-password"
:disabled=
"isLoading"
class=
"input pl-11 pr-11"
:class=
"
{ 'input-error': errors.confirmPassword }"
:placeholder="t('auth.confirmPasswordPlaceholder')"
/>
<button
type=
"button"
@
click=
"showConfirmPassword = !showConfirmPassword"
class=
"absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon
v-if=
"showConfirmPassword"
name=
"eyeOff"
size=
"md"
/>
<Icon
v-else
name=
"eye"
size=
"md"
/>
</button>
</div>
<p
v-if=
"errors.confirmPassword"
class=
"input-error-text"
>
{{
errors
.
confirmPassword
}}
</p>
</div>
<!-- Error Message -->
<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>
<p
class=
"text-sm text-red-700 dark:text-red-400"
>
{{
errorMessage
}}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type=
"submit"
:disabled=
"isLoading"
class=
"btn btn-primary w-full"
>
<svg
v-if=
"isLoading"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin text-white"
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>
<Icon
v-else
name=
"checkCircle"
size=
"md"
class=
"mr-2"
/>
{{
isLoading
?
t
(
'
auth.resettingPassword
'
)
:
t
(
'
auth.resetPassword
'
)
}}
</button>
</form>
</div>
<!-- Footer -->
<template
#footer
>
<p
class=
"text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.rememberedPassword
'
)
}}
<router-link
to=
"/login"
class=
"font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{
t
(
'
auth.signIn
'
)
}}
</router-link>
</p>
</
template
>
</AuthLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
resetPassword
}
from
'
@/api/auth
'
const
{
t
}
=
useI18n
()
// ==================== Router & Stores ====================
const
route
=
useRoute
()
const
appStore
=
useAppStore
()
// ==================== State ====================
const
isLoading
=
ref
<
boolean
>
(
false
)
const
isSuccess
=
ref
<
boolean
>
(
false
)
const
errorMessage
=
ref
<
string
>
(
''
)
const
showPassword
=
ref
<
boolean
>
(
false
)
const
showConfirmPassword
=
ref
<
boolean
>
(
false
)
// URL parameters
const
email
=
ref
<
string
>
(
''
)
const
token
=
ref
<
string
>
(
''
)
const
formData
=
reactive
({
password
:
''
,
confirmPassword
:
''
})
const
errors
=
reactive
({
password
:
''
,
confirmPassword
:
''
})
// Check if the reset link is valid (has email and token)
const
isInvalidLink
=
computed
(()
=>
!
email
.
value
||
!
token
.
value
)
// ==================== Lifecycle ====================
onMounted
(()
=>
{
// Get email and token from URL query parameters
email
.
value
=
(
route
.
query
.
email
as
string
)
||
''
token
.
value
=
(
route
.
query
.
token
as
string
)
||
''
})
// ==================== Validation ====================
function
validateForm
():
boolean
{
errors
.
password
=
''
errors
.
confirmPassword
=
''
let
isValid
=
true
// Password validation
if
(
!
formData
.
password
)
{
errors
.
password
=
t
(
'
auth.passwordRequired
'
)
isValid
=
false
}
else
if
(
formData
.
password
.
length
<
6
)
{
errors
.
password
=
t
(
'
auth.passwordMinLength
'
)
isValid
=
false
}
// Confirm password validation
if
(
!
formData
.
confirmPassword
)
{
errors
.
confirmPassword
=
t
(
'
auth.confirmPasswordRequired
'
)
isValid
=
false
}
else
if
(
formData
.
password
!==
formData
.
confirmPassword
)
{
errors
.
confirmPassword
=
t
(
'
auth.passwordsDoNotMatch
'
)
isValid
=
false
}
return
isValid
}
// ==================== Form Handlers ====================
async
function
handleSubmit
():
Promise
<
void
>
{
errorMessage
.
value
=
''
if
(
!
validateForm
())
{
return
}
isLoading
.
value
=
true
try
{
await
resetPassword
({
email
:
email
.
value
,
token
:
token
.
value
,
new_password
:
formData
.
password
})
isSuccess
.
value
=
true
appStore
.
showSuccess
(
t
(
'
auth.passwordResetSuccess
'
))
}
catch
(
error
:
unknown
)
{
const
err
=
error
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
code
?:
string
}
}
}
// Check for invalid/expired token error
if
(
err
.
response
?.
data
?.
code
===
'
INVALID_RESET_TOKEN
'
)
{
errorMessage
.
value
=
t
(
'
auth.invalidOrExpiredToken
'
)
}
else
if
(
err
.
response
?.
data
?.
detail
)
{
errorMessage
.
value
=
err
.
response
.
data
.
detail
}
else
if
(
err
.
message
)
{
errorMessage
.
value
=
err
.
message
}
else
{
errorMessage
.
value
=
t
(
'
auth.resetPasswordFailed
'
)
}
appStore
.
showError
(
errorMessage
.
value
)
}
finally
{
isLoading
.
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/setup/SetupWizardView.vue
View file @
0170d19f
...
...
@@ -91,6 +91,18 @@
</div>
</div>
<div
class=
"flex items-center justify-between rounded-xl border border-gray-200 p-3 dark:border-dark-700"
>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ t("setup.redis.enableTls") }}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{ t("setup.redis.enableTlsHint") }}
</p>
</div>
<Toggle
v-model=
"formData.redis.enable_tls"
/>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
{{ t('setup.database.username') }}
</label>
...
...
@@ -517,7 +529,8 @@ const formData = reactive<InstallRequest>({
host
:
'
localhost
'
,
port
:
6379
,
password
:
''
,
db
:
0
db
:
0
,
enable_tls
:
false
},
admin
:
{
email
:
''
,
...
...
frontend/src/views/user/KeysView.vue
View file @
0170d19f
...
...
@@ -133,6 +133,7 @@
</button>
<!-- Import to CC Switch Button -->
<button
v-if=
"!publicSettings?.hide_ccs_import_button"
@
click=
"importToCcswitch(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"
>
...
...
frontend/src/views/user/ProfileView.vue
View file @
0170d19f
...
...
@@ -15,6 +15,7 @@
</div>
<ProfileEditForm
:initial-username=
"user?.username || ''"
/>
<ProfilePasswordForm
/>
<ProfileTotpCard
/>
</div>
</AppLayout>
</
template
>
...
...
@@ -27,6 +28,7 @@ import StatCard from '@/components/common/StatCard.vue'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
import
ProfileTotpCard
from
'
@/components/user/profile/ProfileTotpCard.vue
'
import
{
Icon
}
from
'
@/components/icons
'
const
{
t
}
=
useI18n
();
const
authStore
=
useAuthStore
();
const
user
=
computed
(()
=>
authStore
.
user
)
...
...
frontend/src/views/user/PurchaseSubscriptionView.vue
0 → 100644
View file @
0170d19f
<
template
>
<AppLayout>
<div
class=
"purchase-page-layout"
>
<div
class=
"flex items-start justify-between gap-4"
>
<div>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
purchase.title
'
)
}}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
purchase.description
'
)
}}
</p>
</div>
<div
class=
"flex items-center gap-2"
>
<a
v-if=
"isValidUrl"
:href=
"purchaseUrl"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"btn btn-secondary btn-sm"
>
<Icon
name=
"externalLink"
size=
"sm"
class=
"mr-1.5"
:stroke-width=
"2"
/>
{{
t
(
'
purchase.openInNewTab
'
)
}}
</a>
</div>
</div>
<div
class=
"card flex-1 min-h-0 overflow-hidden"
>
<div
v-if=
"loading"
class=
"flex h-full items-center justify-center py-12"
>
<div
class=
"h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else-if=
"!purchaseEnabled"
class=
"flex h-full items-center justify-center p-10 text-center"
>
<div
class=
"max-w-md"
>
<div
class=
"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon
name=
"creditCard"
size=
"lg"
class=
"text-gray-400"
/>
</div>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
purchase.notEnabledTitle
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
purchase.notEnabledDesc
'
)
}}
</p>
</div>
</div>
<div
v-else-if=
"!isValidUrl"
class=
"flex h-full items-center justify-center p-10 text-center"
>
<div
class=
"max-w-md"
>
<div
class=
"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon
name=
"link"
size=
"lg"
class=
"text-gray-400"
/>
</div>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
purchase.notConfiguredTitle
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
purchase.notConfiguredDesc
'
)
}}
</p>
</div>
</div>
<iframe
v-else
:src=
"purchaseUrl"
class=
"h-full w-full border-0"
allowfullscreen
></iframe>
</div>
</div>
</AppLayout>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
loading
=
ref
(
false
)
const
purchaseEnabled
=
computed
(()
=>
{
return
appStore
.
cachedPublicSettings
?.
purchase_subscription_enabled
??
false
})
const
purchaseUrl
=
computed
(()
=>
{
return
(
appStore
.
cachedPublicSettings
?.
purchase_subscription_url
||
''
).
trim
()
})
const
isValidUrl
=
computed
(()
=>
{
const
url
=
purchaseUrl
.
value
return
url
.
startsWith
(
'
http://
'
)
||
url
.
startsWith
(
'
https://
'
)
})
onMounted
(
async
()
=>
{
if
(
appStore
.
publicSettingsLoaded
)
return
loading
.
value
=
true
try
{
await
appStore
.
fetchPublicSettings
()
}
finally
{
loading
.
value
=
false
}
})
</
script
>
<
style
scoped
>
.purchase-page-layout
{
@apply
flex
flex-col
gap-6;
height
:
calc
(
100vh
-
64px
-
4rem
);
/* 减去 header + lg:p-8 的上下padding */
}
</
style
>
frontend/src/views/user/RedeemView.vue
View file @
0170d19f
...
...
@@ -312,6 +312,14 @@
<
p
v
-
else
class
=
"
text-xs text-gray-400 dark:text-dark-500
"
>
{{
t
(
'
redeem.adminAdjustment
'
)
}}
<
/p
>
<!--
Display
notes
for
admin
adjustments
-->
<
p
v
-
if
=
"
item.notes
"
class
=
"
mt-1 text-xs text-gray-500 dark:text-dark-400 italic max-w-[200px] truncate
"
:
title
=
"
item.notes
"
>
{{
item
.
notes
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
...
...
frontend/tsconfig.json
View file @
0170d19f
...
...
@@ -21,5 +21,6 @@
"types"
:
[
"vite/client"
]
},
"include"
:
[
"src/**/*.ts"
,
"src/**/*.tsx"
,
"src/**/*.vue"
],
"exclude"
:
[
"src/**/__tests__/**"
,
"src/**/*.spec.ts"
,
"src/**/*.test.ts"
],
"references"
:
[{
"path"
:
"./tsconfig.node.json"
}]
}
frontend/vite.config.ts
View file @
0170d19f
import
{
defineConfig
,
Plugin
}
from
'
vite
'
import
{
defineConfig
,
loadEnv
,
Plugin
}
from
'
vite
'
import
vue
from
'
@vitejs/plugin-vue
'
import
checker
from
'
vite-plugin-checker
'
import
{
resolve
}
from
'
path
'
...
...
@@ -7,9 +7,7 @@ import { resolve } from 'path'
* Vite 插件:开发模式下注入公开配置到 index.html
* 与生产模式的后端注入行为保持一致,消除闪烁
*/
function
injectPublicSettings
():
Plugin
{
const
backendUrl
=
process
.
env
.
VITE_DEV_PROXY_TARGET
||
'
http://localhost:8080
'
function
injectPublicSettings
(
backendUrl
:
string
):
Plugin
{
return
{
name
:
'
inject-public-settings
'
,
transformIndexHtml
:
{
...
...
@@ -35,15 +33,21 @@ function injectPublicSettings(): Plugin {
}
}
export
default
defineConfig
({
plugins
:
[
vue
(),
checker
({
typescript
:
true
,
vueTsc
:
true
}),
injectPublicSettings
()
],
export
default
defineConfig
(({
mode
})
=>
{
// 加载环境变量
const
env
=
loadEnv
(
mode
,
process
.
cwd
(),
''
)
const
backendUrl
=
env
.
VITE_DEV_PROXY_TARGET
||
'
http://localhost:8080
'
const
devPort
=
Number
(
env
.
VITE_DEV_PORT
||
3000
)
return
{
plugins
:
[
vue
(),
checker
({
typescript
:
true
,
vueTsc
:
true
}),
injectPublicSettings
(
backendUrl
)
],
resolve
:
{
alias
:
{
'
@
'
:
resolve
(
__dirname
,
'
src
'
),
...
...
@@ -102,17 +106,18 @@ export default defineConfig({
}
}
},
server
:
{
host
:
'
0.0.0.0
'
,
port
:
Number
(
process
.
env
.
VITE_DEV_PORT
||
3000
),
proxy
:
{
'
/api
'
:
{
target
:
process
.
env
.
VITE_DEV_PROXY_TARGET
||
'
http://localhost:8080
'
,
changeOrigin
:
true
},
'
/setup
'
:
{
target
:
process
.
env
.
VITE_DEV_PROXY_TARGET
||
'
http://localhost:8080
'
,
changeOrigin
:
true
server
:
{
host
:
'
0.0.0.0
'
,
port
:
devPort
,
proxy
:
{
'
/api
'
:
{
target
:
backendUrl
,
changeOrigin
:
true
},
'
/setup
'
:
{
target
:
backendUrl
,
changeOrigin
:
true
}
}
}
}
...
...
Prev
1
…
12
13
14
15
16
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