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
"backend/internal/vscode:/vscode.git/clone" did not exist on "31fef105c721629ab8f59ca81e88c728590e70f8"
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