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
ff06583c
Commit
ff06583c
authored
Dec 28, 2025
by
song
Browse files
Merge branch 'main' into feature/antigravity_auth
parents
b0389ca4
fb9d0878
Changes
49
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/account/SyncFromCrsModal.vue
View file @
ff06583c
<
template
>
<
template
>
<
Modal
<
BaseDialog
:show=
"show"
:show=
"show"
:title=
"t('admin.accounts.syncFromCrsTitle')"
:title=
"t('admin.accounts.syncFromCrsTitle')"
size=
"lg
"
width=
"normal
"
close-on-click-outside
close-on-click-outside
@
close=
"handleClose"
@
close=
"handleClose"
>
>
<
div
class=
"space-y-4
"
>
<
form
id=
"sync-from-crs-form"
class=
"space-y-4"
@
submit.prevent=
"handleSync
"
>
<div
class=
"text-sm text-gray-600 dark:text-dark-300"
>
<div
class=
"text-sm text-gray-600 dark:text-dark-300"
>
{{
t
(
'
admin.accounts.syncFromCrsDesc
'
)
}}
{{
t
(
'
admin.accounts.syncFromCrsDesc
'
)
}}
</div>
</div>
...
@@ -84,25 +84,30 @@
...
@@ -84,25 +84,30 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/
div
>
<
/
form
>
<
template
#
footer
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3
"
>
<
div
class
=
"
flex justify-end gap-3
"
>
<
button
class
=
"
btn btn-secondary
"
:
disabled
=
"
syncing
"
@
click
=
"
handleClose
"
>
<
button
class
=
"
btn btn-secondary
"
type
=
"
button
"
:
disabled
=
"
syncing
"
@
click
=
"
handleClose
"
>
{{
t
(
'
common.cancel
'
)
}}
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
/button
>
<
button
class
=
"
btn btn-primary
"
:
disabled
=
"
syncing
"
@
click
=
"
handleSync
"
>
<
button
class
=
"
btn btn-primary
"
type
=
"
submit
"
form
=
"
sync-from-crs-form
"
:
disabled
=
"
syncing
"
>
{{
syncing
?
t
(
'
admin.accounts.syncing
'
)
:
t
(
'
admin.accounts.syncNow
'
)
}}
{{
syncing
?
t
(
'
admin.accounts.syncing
'
)
:
t
(
'
admin.accounts.syncNow
'
)
}}
<
/button
>
<
/button
>
<
/div
>
<
/div
>
<
/template
>
<
/template
>
<
/
Modal
>
<
/
BaseDialog
>
<
/template
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
,
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
computed
,
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Modal
from
'
@/components/common/
Modal
.vue
'
import
BaseDialog
from
'
@/components/common/
BaseDialog
.vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
...
...
frontend/src/components/common/BaseDialog.vue
0 → 100644
View file @
ff06583c
<
template
>
<Teleport
to=
"body"
>
<div
v-if=
"show"
class=
"modal-overlay"
aria-labelledby=
"modal-title"
role=
"dialog"
aria-modal=
"true"
@
click.self=
"handleClose"
>
<!-- Modal panel -->
<div
:class=
"['modal-content', widthClasses]"
@
click.stop
>
<!-- Header -->
<div
class=
"modal-header"
>
<h3
id=
"modal-title"
class=
"modal-title"
>
{{
title
}}
</h3>
<button
@
click=
"emit('close')"
class=
"-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label=
"Close modal"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Body -->
<div
class=
"modal-body"
>
<slot></slot>
</div>
<!-- Footer -->
<div
v-if=
"$slots.footer"
class=
"modal-footer"
>
<slot
name=
"footer"
></slot>
</div>
</div>
</div>
</Teleport>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
watch
,
onMounted
,
onUnmounted
}
from
'
vue
'
type
DialogWidth
=
'
narrow
'
|
'
normal
'
|
'
wide
'
|
'
extra-wide
'
|
'
full
'
interface
Props
{
show
:
boolean
title
:
string
width
?:
DialogWidth
closeOnEscape
?:
boolean
closeOnClickOutside
?:
boolean
}
interface
Emits
{
(
e
:
'
close
'
):
void
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
width
:
'
normal
'
,
closeOnEscape
:
true
,
closeOnClickOutside
:
false
})
const
emit
=
defineEmits
<
Emits
>
()
const
widthClasses
=
computed
(()
=>
{
const
widths
:
Record
<
DialogWidth
,
string
>
=
{
narrow
:
'
max-w-md
'
,
normal
:
'
max-w-lg
'
,
wide
:
'
max-w-4xl
'
,
'
extra-wide
'
:
'
max-w-6xl
'
,
full
:
'
max-w-7xl
'
}
return
widths
[
props
.
width
]
})
const
handleClose
=
()
=>
{
if
(
props
.
closeOnClickOutside
)
{
emit
(
'
close
'
)
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
if
(
props
.
show
&&
props
.
closeOnEscape
&&
event
.
key
===
'
Escape
'
)
{
emit
(
'
close
'
)
}
}
// Prevent body scroll when modal is open
watch
(
()
=>
props
.
show
,
(
isOpen
)
=>
{
if
(
isOpen
)
{
document
.
body
.
style
.
overflow
=
'
hidden
'
}
else
{
document
.
body
.
style
.
overflow
=
''
}
},
{
immediate
:
true
}
)
onMounted
(()
=>
{
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
document
.
body
.
style
.
overflow
=
''
})
</
script
>
frontend/src/components/common/ConfirmDialog.vue
View file @
ff06583c
<
template
>
<
template
>
<
Modal
:show=
"show"
:title=
"title"
size=
"sm
"
@
close=
"handleCancel"
>
<
BaseDialog
:show=
"show"
:title=
"title"
width=
"narrow
"
@
close=
"handleCancel"
>
<div
class=
"space-y-4"
>
<div
class=
"space-y-4"
>
<p
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
message
}}
</p>
<p
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
message
}}
</p>
</div>
</div>
...
@@ -27,13 +27,13 @@
...
@@ -27,13 +27,13 @@
</button>
</button>
</div>
</div>
</
template
>
</
template
>
</
Modal
>
</
BaseDialog
>
</template>
</template>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Modal
from
'
./Modal
.vue
'
import
BaseDialog
from
'
./BaseDialog
.vue
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
...
...
frontend/src/components/common/DataTable.vue
View file @
ff06583c
...
@@ -24,37 +24,6 @@
...
@@ -24,37 +24,6 @@
>
>
<div
class=
"flex items-center space-x-1"
>
<div
class=
"flex items-center space-x-1"
>
<span>
{{
column
.
label
}}
</span>
<span>
{{
column
.
label
}}
</span>
<!-- 操作列展开/折叠按钮 -->
<button
v-if=
"column.key === 'actions' && hasExpandableActions"
type=
"button"
@
click.stop=
"toggleActionsExpanded"
class=
"ml-2 flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title=
"actionsExpanded ? t('table.collapseActions') : t('table.expandActions')"
>
<!-- 展开状态:收起图标 -->
<svg
v-if=
"actionsExpanded"
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5"
/>
</svg>
<!-- 折叠状态:展开图标 -->
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
<span
v-if=
"column.sortable"
class=
"text-gray-400 dark:text-dark-500"
>
<span
v-if=
"column.sortable"
class=
"text-gray-400 dark:text-dark-500"
>
<svg
<svg
v-if=
"sortKey === column.key"
v-if=
"sortKey === column.key"
...
@@ -182,8 +151,8 @@ const checkActionsColumnWidth = () => {
...
@@ -182,8 +151,8 @@ const checkActionsColumnWidth = () => {
// 等待DOM更新
// 等待DOM更新
nextTick
(()
=>
{
nextTick
(()
=>
{
// 测量所有按钮的总宽度
// 测量所有按钮的总宽度
const
button
s
=
actionsContainer
.
querySelectorAll
(
'
button
'
)
const
actionItem
s
=
actionsContainer
.
querySelectorAll
(
'
button
, a, [role="button"]
'
)
if
(
button
s
.
length
<=
2
)
{
if
(
actionItem
s
.
length
<=
2
)
{
actionsColumnNeedsExpanding
.
value
=
false
actionsColumnNeedsExpanding
.
value
=
false
actionsExpanded
.
value
=
wasExpanded
actionsExpanded
.
value
=
wasExpanded
return
return
...
@@ -191,9 +160,9 @@ const checkActionsColumnWidth = () => {
...
@@ -191,9 +160,9 @@ const checkActionsColumnWidth = () => {
// 计算所有按钮的总宽度(包括gap)
// 计算所有按钮的总宽度(包括gap)
let
totalWidth
=
0
let
totalWidth
=
0
button
s
.
forEach
((
btn
,
index
)
=>
{
actionItem
s
.
forEach
((
item
,
index
)
=>
{
totalWidth
+=
(
btn
as
HTMLElement
).
offsetWidth
totalWidth
+=
(
item
as
HTMLElement
).
offsetWidth
if
(
index
<
button
s
.
length
-
1
)
{
if
(
index
<
actionItem
s
.
length
-
1
)
{
totalWidth
+=
4
// gap-1 = 4px
totalWidth
+=
4
// gap-1 = 4px
}
}
})
})
...
@@ -211,6 +180,7 @@ const checkActionsColumnWidth = () => {
...
@@ -211,6 +180,7 @@ const checkActionsColumnWidth = () => {
// 监听尺寸变化
// 监听尺寸变化
let
resizeObserver
:
ResizeObserver
|
null
=
null
let
resizeObserver
:
ResizeObserver
|
null
=
null
let
resizeHandler
:
(()
=>
void
)
|
null
=
null
onMounted
(()
=>
{
onMounted
(()
=>
{
checkScrollable
()
checkScrollable
()
...
@@ -223,17 +193,20 @@ onMounted(() => {
...
@@ -223,17 +193,20 @@ onMounted(() => {
resizeObserver
.
observe
(
tableWrapperRef
.
value
)
resizeObserver
.
observe
(
tableWrapperRef
.
value
)
}
else
{
}
else
{
// 降级方案:不支持 ResizeObserver 时使用 window resize
// 降级方案:不支持 ResizeObserver 时使用 window resize
const
handleResize
=
()
=>
{
resizeHandler
=
()
=>
{
checkScrollable
()
checkScrollable
()
checkActionsColumnWidth
()
checkActionsColumnWidth
()
}
}
window
.
addEventListener
(
'
resize
'
,
handleResize
)
window
.
addEventListener
(
'
resize
'
,
resizeHandler
)
}
}
})
})
onUnmounted
(()
=>
{
onUnmounted
(()
=>
{
resizeObserver
?.
disconnect
()
resizeObserver
?.
disconnect
()
window
.
removeEventListener
(
'
resize
'
,
checkScrollable
)
if
(
resizeHandler
)
{
window
.
removeEventListener
(
'
resize
'
,
resizeHandler
)
resizeHandler
=
null
}
})
})
interface
Props
{
interface
Props
{
...
@@ -298,26 +271,6 @@ const sortedData = computed(() => {
...
@@ -298,26 +271,6 @@ const sortedData = computed(() => {
})
})
})
})
// 检查是否有可展开的操作列
const
hasExpandableActions
=
computed
(()
=>
{
// 如果明确指定了actionsCount,使用它来判断
if
(
props
.
actionsCount
!==
undefined
)
{
return
props
.
expandableActions
&&
props
.
columns
.
some
((
col
)
=>
col
.
key
===
'
actions
'
)
&&
props
.
actionsCount
>
2
}
// 否则使用原来的检测逻辑
return
(
props
.
expandableActions
&&
props
.
columns
.
some
((
col
)
=>
col
.
key
===
'
actions
'
)
&&
actionsColumnNeedsExpanding
.
value
)
})
// 切换操作列展开/折叠状态
const
toggleActionsExpanded
=
()
=>
{
actionsExpanded
.
value
=
!
actionsExpanded
.
value
}
// 检查第一列是否为勾选列
// 检查第一列是否为勾选列
const
hasSelectColumn
=
computed
(()
=>
{
const
hasSelectColumn
=
computed
(()
=>
{
return
props
.
columns
.
length
>
0
&&
props
.
columns
[
0
].
key
===
'
select
'
return
props
.
columns
.
length
>
0
&&
props
.
columns
[
0
].
key
===
'
select
'
...
...
frontend/src/components/common/Pagination.vue
View file @
ff06583c
...
@@ -206,10 +206,6 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
...
@@ -206,10 +206,6 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
if
(
value
===
null
||
typeof
value
===
'
boolean
'
)
return
if
(
value
===
null
||
typeof
value
===
'
boolean
'
)
return
const
newPageSize
=
typeof
value
===
'
string
'
?
parseInt
(
value
)
:
value
const
newPageSize
=
typeof
value
===
'
string
'
?
parseInt
(
value
)
:
value
emit
(
'
update:pageSize
'
,
newPageSize
)
emit
(
'
update:pageSize
'
,
newPageSize
)
// Reset to first page when page size changes
if
(
props
.
page
!==
1
)
{
emit
(
'
update:page
'
,
1
)
}
}
}
<
/script
>
<
/script
>
...
...
frontend/src/components/common/Select.vue
View file @
ff06583c
...
@@ -30,7 +30,11 @@
...
@@ -30,7 +30,11 @@
</button>
</button>
<Transition
name=
"select-dropdown"
>
<Transition
name=
"select-dropdown"
>
<div
v-if=
"isOpen"
class=
"select-dropdown"
>
<div
v-if=
"isOpen"
ref=
"dropdownRef"
:class=
"['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']"
>
<!-- Search input -->
<!-- Search input -->
<div
v-if=
"searchable"
class=
"select-search"
>
<div
v-if=
"searchable"
class=
"select-search"
>
<svg
<svg
...
@@ -141,6 +145,8 @@ const isOpen = ref(false)
...
@@ -141,6 +145,8 @@ const isOpen = ref(false)
const
searchQuery
=
ref
(
''
)
const
searchQuery
=
ref
(
''
)
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownPosition
=
ref
<
'
bottom
'
|
'
top
'
>
(
'
bottom
'
)
const
getOptionValue
=
(
const
getOptionValue
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
option
:
SelectOption
|
Record
<
string
,
unknown
>
...
@@ -184,13 +190,37 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
...
@@ -184,13 +190,37 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
return
getOptionValue
(
option
)
===
props
.
modelValue
return
getOptionValue
(
option
)
===
props
.
modelValue
}
}
const
calculateDropdownPosition
=
()
=>
{
if
(
!
containerRef
.
value
)
return
nextTick
(()
=>
{
if
(
!
containerRef
.
value
||
!
dropdownRef
.
value
)
return
const
triggerRect
=
containerRef
.
value
.
getBoundingClientRect
()
const
dropdownHeight
=
dropdownRef
.
value
.
offsetHeight
||
240
// Max height fallback
const
viewportHeight
=
window
.
innerHeight
const
spaceBelow
=
viewportHeight
-
triggerRect
.
bottom
const
spaceAbove
=
triggerRect
.
top
// If not enough space below but enough space above, show dropdown on top
if
(
spaceBelow
<
dropdownHeight
&&
spaceAbove
>
dropdownHeight
)
{
dropdownPosition
.
value
=
'
top
'
}
else
{
dropdownPosition
.
value
=
'
bottom
'
}
})
}
const
toggle
=
()
=>
{
const
toggle
=
()
=>
{
if
(
props
.
disabled
)
return
if
(
props
.
disabled
)
return
isOpen
.
value
=
!
isOpen
.
value
isOpen
.
value
=
!
isOpen
.
value
if
(
isOpen
.
value
&&
props
.
searchable
)
{
if
(
isOpen
.
value
)
{
nextTick
(()
=>
{
calculateDropdownPosition
()
searchInputRef
.
value
?.
focus
()
if
(
props
.
searchable
)
{
})
nextTick
(()
=>
{
searchInputRef
.
value
?.
focus
()
})
}
}
}
}
}
...
@@ -275,6 +305,10 @@ onUnmounted(() => {
...
@@ -275,6 +305,10 @@ onUnmounted(() => {
@apply
overflow-hidden;
@apply
overflow-hidden;
}
}
.select-dropdown-top
{
@apply
bottom-full
mb-2
mt-0;
}
.select-search
{
.select-search
{
@apply
flex
items-center
gap-2
px-3
py-2;
@apply
flex
items-center
gap-2
px-3
py-2;
@apply
border-b
border-gray-100
dark
:
border-dark-700
;
@apply
border-b
border-gray-100
dark
:
border-dark-700
;
...
@@ -322,6 +356,17 @@ onUnmounted(() => {
...
@@ -322,6 +356,17 @@ onUnmounted(() => {
.select-dropdown-enter-from
,
.select-dropdown-enter-from
,
.select-dropdown-leave-to
{
.select-dropdown-leave-to
{
opacity
:
0
;
opacity
:
0
;
}
/* Animation for dropdown opening downward (default) */
.select-dropdown
:not
(
.select-dropdown-top
)
.select-dropdown-enter-from
,
.select-dropdown
:not
(
.select-dropdown-top
)
.select-dropdown-leave-to
{
transform
:
translateY
(
-8px
);
transform
:
translateY
(
-8px
);
}
}
/* Animation for dropdown opening upward */
.select-dropdown-top.select-dropdown-enter-from
,
.select-dropdown-top.select-dropdown-leave-to
{
transform
:
translateY
(
8px
);
}
</
style
>
</
style
>
frontend/src/components/common/SubscriptionProgressMini.vue
View file @
ff06583c
...
@@ -178,17 +178,19 @@
...
@@ -178,17 +178,19 @@
<
script
setup
lang
=
"
ts
"
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
s
ubscription
sAPI
from
'
@/
api/subscription
s
'
import
{
useS
ubscription
Store
}
from
'
@/
store
s
'
import
type
{
UserSubscription
}
from
'
@/types
'
import
type
{
UserSubscription
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
subscriptionStore
=
useSubscriptionStore
()
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
tooltipOpen
=
ref
(
false
)
const
tooltipOpen
=
ref
(
false
)
const
activeSubscriptions
=
ref
<
UserSubscription
[]
>
([])
const
loading
=
ref
(
false
)
const
hasActiveSubscriptions
=
computed
(()
=>
activeSubscriptions
.
value
.
length
>
0
)
// Use store data instead of local state
const
activeSubscriptions
=
computed
(()
=>
subscriptionStore
.
activeSubscriptions
)
const
hasActiveSubscriptions
=
computed
(()
=>
subscriptionStore
.
hasActiveSubscriptions
)
const
displaySubscriptions
=
computed
(()
=>
{
const
displaySubscriptions
=
computed
(()
=>
{
// Sort by most usage (highest percentage first)
// Sort by most usage (highest percentage first)
...
@@ -275,37 +277,18 @@ function handleClickOutside(event: MouseEvent) {
...
@@ -275,37 +277,18 @@ function handleClickOutside(event: MouseEvent) {
}
}
}
}
async
function
loadSubscriptions
()
{
try
{
loading
.
value
=
true
activeSubscriptions
.
value
=
await
subscriptionsAPI
.
getActiveSubscriptions
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to load subscriptions:
'
,
error
)
activeSubscriptions
.
value
=
[]
}
finally
{
loading
.
value
=
false
}
}
onMounted
(()
=>
{
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
loadSubscriptions
()
// Trigger initial fetch if not already loaded
// The actual data loading is handled by App.vue globally
subscriptionStore
.
fetchActiveSubscriptions
().
catch
((
error
)
=>
{
console
.
error
(
'
Failed to load subscriptions in SubscriptionProgressMini:
'
,
error
)
}
)
}
)
}
)
onBeforeUnmount
(()
=>
{
onBeforeUnmount
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
}
)
}
)
// Refresh subscriptions periodically (every 5 minutes)
let
refreshInterval
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
onMounted
(()
=>
{
refreshInterval
=
setInterval
(
loadSubscriptions
,
5
*
60
*
1000
)
}
)
onBeforeUnmount
(()
=>
{
if
(
refreshInterval
)
{
clearInterval
(
refreshInterval
)
}
}
)
<
/script
>
<
/script
>
<
style
scoped
>
<
style
scoped
>
...
...
frontend/src/components/common/index.ts
View file @
ff06583c
...
@@ -2,6 +2,7 @@
...
@@ -2,6 +2,7 @@
export
{
default
as
DataTable
}
from
'
./DataTable.vue
'
export
{
default
as
DataTable
}
from
'
./DataTable.vue
'
export
{
default
as
Pagination
}
from
'
./Pagination.vue
'
export
{
default
as
Pagination
}
from
'
./Pagination.vue
'
export
{
default
as
Modal
}
from
'
./Modal.vue
'
export
{
default
as
Modal
}
from
'
./Modal.vue
'
export
{
default
as
BaseDialog
}
from
'
./BaseDialog.vue
'
export
{
default
as
ConfirmDialog
}
from
'
./ConfirmDialog.vue
'
export
{
default
as
ConfirmDialog
}
from
'
./ConfirmDialog.vue
'
export
{
default
as
StatCard
}
from
'
./StatCard.vue
'
export
{
default
as
StatCard
}
from
'
./StatCard.vue
'
export
{
default
as
Toast
}
from
'
./Toast.vue
'
export
{
default
as
Toast
}
from
'
./Toast.vue
'
...
...
frontend/src/components/keys/UseKeyModal.vue
View file @
ff06583c
<
template
>
<
template
>
<
Modal
<
BaseDialog
:show=
"show"
:show=
"show"
:title=
"t('keys.useKeyModal.title')"
:title=
"t('keys.useKeyModal.title')"
size=
"lg
"
width=
"wide
"
@
close=
"emit('close')"
@
close=
"emit('close')"
>
>
<div
class=
"space-y-4"
>
<div
class=
"space-y-4"
>
...
@@ -112,13 +112,13 @@
...
@@ -112,13 +112,13 @@
</button>
</button>
</div>
</div>
</
template
>
</
template
>
</
Modal
>
</
BaseDialog
>
</template>
</template>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
h
,
watch
,
type
Component
}
from
'
vue
'
import
{
ref
,
computed
,
h
,
watch
,
type
Component
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Modal
from
'
@/components/common/
Modal
.vue
'
import
BaseDialog
from
'
@/components/common/
BaseDialog
.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
type
{
GroupPlatform
}
from
'
@/types
'
import
type
{
GroupPlatform
}
from
'
@/types
'
...
...
frontend/src/i18n/locales/en.ts
View file @
ff06583c
...
@@ -119,6 +119,7 @@ export default {
...
@@ -119,6 +119,7 @@ export default {
info
:
'
Info
'
,
info
:
'
Info
'
,
active
:
'
Active
'
,
active
:
'
Active
'
,
inactive
:
'
Inactive
'
,
inactive
:
'
Inactive
'
,
more
:
'
More
'
,
close
:
'
Close
'
,
close
:
'
Close
'
,
enabled
:
'
Enabled
'
,
enabled
:
'
Enabled
'
,
disabled
:
'
Disabled
'
,
disabled
:
'
Disabled
'
,
...
@@ -344,6 +345,8 @@ export default {
...
@@ -344,6 +345,8 @@ export default {
allApiKeys
:
'
All API Keys
'
,
allApiKeys
:
'
All API Keys
'
,
timeRange
:
'
Time Range
'
,
timeRange
:
'
Time Range
'
,
exportCsv
:
'
Export CSV
'
,
exportCsv
:
'
Export CSV
'
,
exporting
:
'
Exporting...
'
,
preparingExport
:
'
Preparing export...
'
,
model
:
'
Model
'
,
model
:
'
Model
'
,
type
:
'
Type
'
,
type
:
'
Type
'
,
tokens
:
'
Tokens
'
,
tokens
:
'
Tokens
'
,
...
@@ -364,6 +367,7 @@ export default {
...
@@ -364,6 +367,7 @@ export default {
failedToLoad
:
'
Failed to load usage logs
'
,
failedToLoad
:
'
Failed to load usage logs
'
,
noDataToExport
:
'
No data to export
'
,
noDataToExport
:
'
No data to export
'
,
exportSuccess
:
'
Usage data exported successfully
'
,
exportSuccess
:
'
Usage data exported successfully
'
,
exportFailed
:
'
Failed to export usage data
'
,
billingType
:
'
Billing
'
,
billingType
:
'
Billing
'
,
balance
:
'
Balance
'
,
balance
:
'
Balance
'
,
subscription
:
'
Subscription
'
subscription
:
'
Subscription
'
...
@@ -406,7 +410,8 @@ export default {
...
@@ -406,7 +410,8 @@ export default {
subscriptionDays
:
'
{days} days
'
,
subscriptionDays
:
'
{days} days
'
,
days
:
'
days
'
,
days
:
'
days
'
,
codeRedeemSuccess
:
'
Code redeemed successfully!
'
,
codeRedeemSuccess
:
'
Code redeemed successfully!
'
,
failedToRedeem
:
'
Failed to redeem code. Please check the code and try again.
'
failedToRedeem
:
'
Failed to redeem code. Please check the code and try again.
'
,
subscriptionRefreshFailed
:
'
Redeemed successfully, but failed to refresh subscription status.
'
},
},
// Profile
// Profile
...
@@ -427,6 +432,7 @@ export default {
...
@@ -427,6 +432,7 @@ export default {
updating
:
'
Updating...
'
,
updating
:
'
Updating...
'
,
updateSuccess
:
'
Profile updated successfully
'
,
updateSuccess
:
'
Profile updated successfully
'
,
updateFailed
:
'
Failed to update profile
'
,
updateFailed
:
'
Failed to update profile
'
,
usernameRequired
:
'
Username is required
'
,
changePassword
:
'
Change Password
'
,
changePassword
:
'
Change Password
'
,
currentPassword
:
'
Current Password
'
,
currentPassword
:
'
Current Password
'
,
newPassword
:
'
New Password
'
,
newPassword
:
'
New Password
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
ff06583c
...
@@ -116,6 +116,7 @@ export default {
...
@@ -116,6 +116,7 @@ export default {
info
:
'
提示
'
,
info
:
'
提示
'
,
active
:
'
启用
'
,
active
:
'
启用
'
,
inactive
:
'
禁用
'
,
inactive
:
'
禁用
'
,
more
:
'
更多
'
,
close
:
'
关闭
'
,
close
:
'
关闭
'
,
enabled
:
'
已启用
'
,
enabled
:
'
已启用
'
,
disabled
:
'
已禁用
'
,
disabled
:
'
已禁用
'
,
...
@@ -340,6 +341,8 @@ export default {
...
@@ -340,6 +341,8 @@ export default {
allApiKeys
:
'
全部密钥
'
,
allApiKeys
:
'
全部密钥
'
,
timeRange
:
'
时间范围
'
,
timeRange
:
'
时间范围
'
,
exportCsv
:
'
导出 CSV
'
,
exportCsv
:
'
导出 CSV
'
,
exporting
:
'
导出中...
'
,
preparingExport
:
'
正在准备导出...
'
,
model
:
'
模型
'
,
model
:
'
模型
'
,
type
:
'
类型
'
,
type
:
'
类型
'
,
tokens
:
'
Token
'
,
tokens
:
'
Token
'
,
...
@@ -360,6 +363,7 @@ export default {
...
@@ -360,6 +363,7 @@ export default {
failedToLoad
:
'
加载使用记录失败
'
,
failedToLoad
:
'
加载使用记录失败
'
,
noDataToExport
:
'
没有可导出的数据
'
,
noDataToExport
:
'
没有可导出的数据
'
,
exportSuccess
:
'
使用数据导出成功
'
,
exportSuccess
:
'
使用数据导出成功
'
,
exportFailed
:
'
使用数据导出失败
'
,
billingType
:
'
消费类型
'
,
billingType
:
'
消费类型
'
,
balance
:
'
余额
'
,
balance
:
'
余额
'
,
subscription
:
'
订阅
'
subscription
:
'
订阅
'
...
@@ -402,7 +406,8 @@ export default {
...
@@ -402,7 +406,8 @@ export default {
subscriptionDays
:
'
{days} 天
'
,
subscriptionDays
:
'
{days} 天
'
,
days
:
'
天
'
,
days
:
'
天
'
,
codeRedeemSuccess
:
'
兑换成功!
'
,
codeRedeemSuccess
:
'
兑换成功!
'
,
failedToRedeem
:
'
兑换失败,请检查兑换码后重试。
'
failedToRedeem
:
'
兑换失败,请检查兑换码后重试。
'
,
subscriptionRefreshFailed
:
'
兑换成功,但订阅状态刷新失败。
'
},
},
// Profile
// Profile
...
@@ -423,6 +428,7 @@ export default {
...
@@ -423,6 +428,7 @@ export default {
updating
:
'
更新中...
'
,
updating
:
'
更新中...
'
,
updateSuccess
:
'
资料更新成功
'
,
updateSuccess
:
'
资料更新成功
'
,
updateFailed
:
'
资料更新失败
'
,
updateFailed
:
'
资料更新失败
'
,
usernameRequired
:
'
用户名不能为空
'
,
changePassword
:
'
修改密码
'
,
changePassword
:
'
修改密码
'
,
currentPassword
:
'
当前密码
'
,
currentPassword
:
'
当前密码
'
,
newPassword
:
'
新密码
'
,
newPassword
:
'
新密码
'
,
...
...
frontend/src/stores/index.ts
View file @
ff06583c
...
@@ -5,6 +5,7 @@
...
@@ -5,6 +5,7 @@
export
{
useAuthStore
}
from
'
./auth
'
export
{
useAuthStore
}
from
'
./auth
'
export
{
useAppStore
}
from
'
./app
'
export
{
useAppStore
}
from
'
./app
'
export
{
useSubscriptionStore
}
from
'
./subscriptions
'
// Re-export types for convenience
// Re-export types for convenience
export
type
{
User
,
LoginRequest
,
RegisterRequest
,
AuthResponse
}
from
'
@/types
'
export
type
{
User
,
LoginRequest
,
RegisterRequest
,
AuthResponse
}
from
'
@/types
'
...
...
frontend/src/stores/subscriptions.ts
0 → 100644
View file @
ff06583c
/**
* Subscription Store
* Global state management for user subscriptions with caching and deduplication
*/
import
{
defineStore
}
from
'
pinia
'
import
{
ref
,
computed
}
from
'
vue
'
import
subscriptionsAPI
from
'
@/api/subscriptions
'
import
type
{
UserSubscription
}
from
'
@/types
'
// Cache TTL: 60 seconds
const
CACHE_TTL_MS
=
60
_000
// Request generation counter to invalidate stale in-flight responses
let
requestGeneration
=
0
export
const
useSubscriptionStore
=
defineStore
(
'
subscriptions
'
,
()
=>
{
// State
const
activeSubscriptions
=
ref
<
UserSubscription
[]
>
([])
const
loading
=
ref
(
false
)
const
loaded
=
ref
(
false
)
const
lastFetchedAt
=
ref
<
number
|
null
>
(
null
)
// In-flight request deduplication
let
activePromise
:
Promise
<
UserSubscription
[]
>
|
null
=
null
// Auto-refresh interval
let
pollerInterval
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
// Computed
const
hasActiveSubscriptions
=
computed
(()
=>
activeSubscriptions
.
value
.
length
>
0
)
/**
* Fetch active subscriptions with caching and deduplication
* @param force - Force refresh even if cache is valid
*/
async
function
fetchActiveSubscriptions
(
force
=
false
):
Promise
<
UserSubscription
[]
>
{
const
now
=
Date
.
now
()
// Return cached data if valid
if
(
!
force
&&
loaded
.
value
&&
lastFetchedAt
.
value
&&
now
-
lastFetchedAt
.
value
<
CACHE_TTL_MS
)
{
return
activeSubscriptions
.
value
}
// Return in-flight request if exists (deduplication)
if
(
activePromise
&&
!
force
)
{
return
activePromise
}
const
currentGeneration
=
++
requestGeneration
// Start new request
loading
.
value
=
true
const
requestPromise
=
subscriptionsAPI
.
getActiveSubscriptions
()
.
then
((
data
)
=>
{
if
(
currentGeneration
===
requestGeneration
)
{
activeSubscriptions
.
value
=
data
loaded
.
value
=
true
lastFetchedAt
.
value
=
Date
.
now
()
}
return
data
})
.
catch
((
error
)
=>
{
console
.
error
(
'
Failed to fetch active subscriptions:
'
,
error
)
throw
error
})
.
finally
(()
=>
{
if
(
activePromise
===
requestPromise
)
{
loading
.
value
=
false
activePromise
=
null
}
})
activePromise
=
requestPromise
return
activePromise
}
/**
* Start auto-refresh polling
*/
function
startPolling
()
{
if
(
pollerInterval
)
return
pollerInterval
=
setInterval
(()
=>
{
fetchActiveSubscriptions
(
true
).
catch
((
error
)
=>
{
console
.
error
(
'
Subscription polling failed:
'
,
error
)
})
},
5
*
60
*
1000
)
}
/**
* Stop auto-refresh polling
*/
function
stopPolling
()
{
if
(
pollerInterval
)
{
clearInterval
(
pollerInterval
)
pollerInterval
=
null
}
}
/**
* Clear all subscription data and stop polling
*/
function
clear
()
{
requestGeneration
++
activePromise
=
null
activeSubscriptions
.
value
=
[]
loaded
.
value
=
false
lastFetchedAt
.
value
=
null
stopPolling
()
}
/**
* Invalidate cache (force next fetch to reload)
*/
function
invalidateCache
()
{
lastFetchedAt
.
value
=
null
}
return
{
// State
activeSubscriptions
,
loading
,
hasActiveSubscriptions
,
// Actions
fetchActiveSubscriptions
,
startPolling
,
stopPolling
,
clear
,
invalidateCache
}
})
frontend/src/style.css
View file @
ff06583c
...
@@ -307,6 +307,35 @@
...
@@ -307,6 +307,35 @@
@apply
flex
items-center
justify-end
gap-3;
@apply
flex
items-center
justify-end
gap-3;
}
}
/* ============ Dialog ============ */
.dialog-overlay
{
@apply
fixed
inset-0
z-50;
@apply
bg-black/40
dark
:
bg-black
/
60
;
@apply
flex
items-center
justify-center
p-4;
}
.dialog-container
{
@apply
flex
w-full
flex-col;
@apply
max-h-[90vh];
@apply
rounded-2xl
bg-white
dark
:
bg-dark-800
;
@apply
shadow-xl;
}
.dialog-header
{
@apply
border-b
border-gray-100
px-6
py-4
dark
:
border-dark-700
;
@apply
flex
items-center
justify-between;
}
.dialog-body
{
@apply
overflow-y-auto
px-6
py-4;
}
.dialog-footer
{
@apply
border-t
border-gray-100
px-6
py-4
dark
:
border-dark-700
;
@apply
bg-gray-50/60
dark
:
bg-dark-900
/
40
;
@apply
flex
items-center
justify-end
gap-3;
}
/* ============ Toast 通知 ============ */
/* ============ Toast 通知 ============ */
.toast
{
.toast
{
@apply
fixed
right-4
top-4
z-[100];
@apply
fixed
right-4
top-4
z-[100];
...
...
frontend/src/views/admin/AccountsView.vue
View file @
ff06583c
...
@@ -165,7 +165,7 @@
...
@@ -165,7 +165,7 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
DataTable
:
columns
=
"
columns
"
:
data
=
"
accounts
"
:
loading
=
"
loading
"
:
actions
-
count
=
"
6
"
>
<
DataTable
:
columns
=
"
columns
"
:
data
=
"
accounts
"
:
loading
=
"
loading
"
>
<
template
#
cell
-
select
=
"
{ row
}
"
>
<
template
#
cell
-
select
=
"
{ row
}
"
>
<
input
<
input
type
=
"
checkbox
"
type
=
"
checkbox
"
...
@@ -275,9 +275,9 @@
...
@@ -275,9 +275,9 @@
<
/span
>
<
/span
>
<
/template
>
<
/template
>
<
template
#
cell
-
actions
=
"
{ row
, expanded
}
"
>
<
template
#
cell
-
actions
=
"
{ row
}
"
>
<
div
class
=
"
flex items-center gap-1
"
>
<
div
class
=
"
flex items-center gap-1
"
>
<!--
主要操作
:
编辑和删除
(
始终显示
)
-->
<!--
Edit
Button
-->
<
button
<
button
@
click
=
"
handleEdit(row)
"
@
click
=
"
handleEdit(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
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-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
...
@@ -297,6 +297,8 @@
...
@@ -297,6 +297,8 @@
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.edit
'
)
}}
<
/span
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.edit
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<!--
Delete
Button
-->
<
button
<
button
@
click
=
"
handleDelete(row)
"
@
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
"
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
"
...
@@ -317,131 +319,28 @@
...
@@ -317,131 +319,28 @@
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.delete
'
)
}}
<
/span
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.delete
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<!--
次要操作
:
展开时显示
-->
<!--
More
Actions
Menu
Trigger
-->
<
template
v
-
if
=
"
expanded
"
>
<
button
<!--
Reset
Status
button
for
error
accounts
-->
:
ref
=
"
(el) => setActionButtonRef(row.id, el)
"
<
button
@
click
=
"
openActionMenu(row)
"
v
-
if
=
"
row.status === 'error'
"
class
=
"
action-menu-trigger 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-900 dark:hover:bg-dark-700 dark:hover:text-white
"
@
click
=
"
handleResetStatus(row)
"
:
class
=
"
{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id
}
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
fill
=
"
none
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
stroke
-
width
=
"
1.5
"
>
>
<
path
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M
9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3
"
d
=
"
M
6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.resetStatus
'
)
}}
<
/span
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.more
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<!--
Clear
Rate
Limit
button
-->
<
button
v
-
if
=
"
isRateLimited(row) || isOverloaded(row)
"
@
click
=
"
handleClearRateLimit(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.clearRateLimit
'
)
}}
<
/span
>
<
/button
>
<!--
Test
Connection
button
-->
<
button
@
click
=
"
handleTest(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
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
<
/span
>
<
/button
>
<!--
View
Stats
button
-->
<
button
@
click
=
"
handleViewStats(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
<
/span
>
<
/button
>
<
button
v
-
if
=
"
row.type === 'oauth' || row.type === 'setup-token'
"
@
click
=
"
handleReAuth(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
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
<
/span
>
<
/button
>
<
button
v
-
if
=
"
row.type === 'oauth' || row.type === 'setup-token'
"
@
click
=
"
handleRefreshToken(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
<
/span
>
<
/button
>
<
/template
>
<
/div
>
<
/div
>
<
/template
>
<
/template
>
...
@@ -463,6 +362,7 @@
...
@@ -463,6 +362,7 @@
:
total
=
"
pagination.total
"
:
total
=
"
pagination.total
"
:
page
-
size
=
"
pagination.page_size
"
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
pageSize
=
"
handlePageSizeChange
"
/>
/>
<
/template
>
<
/template
>
<
/TablePageLayout
>
<
/TablePageLayout
>
...
@@ -537,11 +437,61 @@
...
@@ -537,11 +437,61 @@
@
close
=
"
showBulkEditModal = false
"
@
close
=
"
showBulkEditModal = false
"
@
updated
=
"
handleBulkUpdated
"
@
updated
=
"
handleBulkUpdated
"
/>
/>
<!--
Action
Menu
(
Teleported
)
-->
<
Teleport
to
=
"
body
"
>
<
div
v
-
if
=
"
activeMenuId !== null && menuPosition
"
class
=
"
action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10
"
:
style
=
"
{ top: menuPosition.top + 'px', left: menuPosition.left + 'px'
}
"
>
<
div
class
=
"
py-1
"
>
<
template
v
-
for
=
"
account in accounts
"
:
key
=
"
account.id
"
>
<
template
v
-
if
=
"
account.id === activeMenuId
"
>
<
button
@
click
=
"
handleTest(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4 text-green-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z
"
/><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/><
/svg
>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
<
/button
>
<
button
@
click
=
"
handleViewStats(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4 text-indigo-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z
"
/><
/svg
>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
<
/button
>
<
template
v
-
if
=
"
account.type === 'oauth' || account.type === 'setup-token'
"
>
<
button
@
click
=
"
handleReAuth(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4 text-blue-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1
"
/><
/svg
>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
<
/button
>
<
button
@
click
=
"
handleRefreshToken(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4 text-purple-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M4 4v5h5M20 20v-5h-5M4 4l16 16
"
/><
/svg
>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
<
/button
>
<
/template
>
<
div
v
-
if
=
"
account.status === 'error' || isRateLimited(account) || isOverloaded(account)
"
class
=
"
my-1 border-t border-gray-100 dark:border-dark-700
"
><
/div
>
<
button
v
-
if
=
"
account.status === 'error'
"
@
click
=
"
handleResetStatus(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:text-yellow-400 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z
"
/><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/><
/svg
>
{{
t
(
'
admin.accounts.resetStatus
'
)
}}
<
/button
>
<
button
v
-
if
=
"
isRateLimited(account) || isOverloaded(account)
"
@
click
=
"
handleClearRateLimit(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:text-amber-400 dark:hover:bg-dark-700
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z
"
/><
/svg
>
{{
t
(
'
admin.accounts.clearRateLimit
'
)
}}
<
/button
>
<
/template
>
<
/template
>
<
/div
>
<
/div
>
<
/Teleport
>
<
/AppLayout
>
<
/AppLayout
>
<
/template
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
type
ComponentPublicInstance
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
...
@@ -629,6 +579,7 @@ const pagination = reactive({
...
@@ -629,6 +579,7 @@ const pagination = reactive({
total
:
0
,
total
:
0
,
pages
:
0
pages
:
0
}
)
}
)
let
abortController
:
AbortController
|
null
=
null
// Modal states
// Modal states
const
showCreateModal
=
ref
(
false
)
const
showCreateModal
=
ref
(
false
)
...
@@ -648,6 +599,49 @@ const statsAccount = ref<Account | null>(null)
...
@@ -648,6 +599,49 @@ const statsAccount = ref<Account | null>(null)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
bulkDeleting
=
ref
(
false
)
const
bulkDeleting
=
ref
(
false
)
// Action Menu State
const
activeMenuId
=
ref
<
number
|
null
>
(
null
)
const
menuPosition
=
ref
<
{
top
:
number
;
left
:
number
}
|
null
>
(
null
)
const
actionButtonRefs
=
ref
<
Map
<
number
,
HTMLElement
>>
(
new
Map
())
const
setActionButtonRef
=
(
accountId
:
number
,
el
:
Element
|
ComponentPublicInstance
|
null
)
=>
{
if
(
el
instanceof
HTMLElement
)
{
actionButtonRefs
.
value
.
set
(
accountId
,
el
)
}
else
{
actionButtonRefs
.
value
.
delete
(
accountId
)
}
}
const
openActionMenu
=
(
account
:
Account
)
=>
{
if
(
activeMenuId
.
value
===
account
.
id
)
{
closeActionMenu
()
}
else
{
const
buttonEl
=
actionButtonRefs
.
value
.
get
(
account
.
id
)
if
(
buttonEl
)
{
const
rect
=
buttonEl
.
getBoundingClientRect
()
// Position menu to the left of the button, slightly below
menuPosition
.
value
=
{
top
:
rect
.
bottom
+
4
,
left
:
rect
.
right
-
208
// w-52 is 208px
}
}
activeMenuId
.
value
=
account
.
id
}
}
const
closeActionMenu
=
()
=>
{
activeMenuId
.
value
=
null
menuPosition
.
value
=
null
}
// Close menu when clicking outside
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
if
(
!
target
.
closest
(
'
.action-menu-trigger
'
)
&&
!
target
.
closest
(
'
.action-menu-content
'
))
{
closeActionMenu
()
}
}
// Bulk selection
// Bulk selection
const
selectedAccountIds
=
ref
<
number
[]
>
([])
const
selectedAccountIds
=
ref
<
number
[]
>
([])
const
selectCurrentPageAccounts
=
()
=>
{
const
selectCurrentPageAccounts
=
()
=>
{
...
@@ -669,6 +663,9 @@ const isOverloaded = (account: Account): boolean => {
...
@@ -669,6 +663,9 @@ const isOverloaded = (account: Account): boolean => {
// Data loading
// Data loading
const
loadAccounts
=
async
()
=>
{
const
loadAccounts
=
async
()
=>
{
abortController
?.
abort
()
const
currentAbortController
=
new
AbortController
()
abortController
=
currentAbortController
loading
.
value
=
true
loading
.
value
=
true
try
{
try
{
const
response
=
await
adminAPI
.
accounts
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
const
response
=
await
adminAPI
.
accounts
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
...
@@ -676,15 +673,24 @@ const loadAccounts = async () => {
...
@@ -676,15 +673,24 @@ const loadAccounts = async () => {
type
:
filters
.
type
||
undefined
,
type
:
filters
.
type
||
undefined
,
status
:
filters
.
status
||
undefined
,
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
search
:
searchQuery
.
value
||
undefined
}
,
{
signal
:
currentAbortController
.
signal
}
)
}
)
if
(
currentAbortController
.
signal
.
aborted
)
return
accounts
.
value
=
response
.
items
accounts
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
}
catch
(
error
)
{
const
errorInfo
=
error
as
{
name
?:
string
;
code
?:
string
}
if
(
errorInfo
?.
name
===
'
AbortError
'
||
errorInfo
?.
name
===
'
CanceledError
'
||
errorInfo
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.accounts.failedToLoad
'
))
appStore
.
showError
(
t
(
'
admin.accounts.failedToLoad
'
))
console
.
error
(
'
Error loading accounts:
'
,
error
)
console
.
error
(
'
Error loading accounts:
'
,
error
)
}
finally
{
}
finally
{
loading
.
value
=
false
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
}
}
}
}
}
...
@@ -721,6 +727,12 @@ const handlePageChange = (page: number) => {
...
@@ -721,6 +727,12 @@ const handlePageChange = (page: number) => {
loadAccounts
()
loadAccounts
()
}
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadAccounts
()
}
const
handleCrsSynced
=
()
=>
{
const
handleCrsSynced
=
()
=>
{
showCrsSyncModal
.
value
=
false
showCrsSyncModal
.
value
=
false
loadAccounts
()
loadAccounts
()
...
@@ -910,5 +922,12 @@ onMounted(() => {
...
@@ -910,5 +922,12 @@ onMounted(() => {
loadAccounts
()
loadAccounts
()
loadProxies
()
loadProxies
()
loadGroups
()
loadGroups
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
}
)
onUnmounted
(()
=>
{
abortController
?.
abort
()
abortController
=
null
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
}
)
}
)
<
/script
>
<
/script
>
frontend/src/views/admin/GroupsView.vue
View file @
ff06583c
...
@@ -223,18 +223,19 @@
...
@@ -223,18 +223,19 @@
:
total
=
"
pagination.total
"
:
total
=
"
pagination.total
"
:
page
-
size
=
"
pagination.page_size
"
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
pageSize
=
"
handlePageSizeChange
"
/>
/>
<
/template
>
<
/template
>
<
/TablePageLayout
>
<
/TablePageLayout
>
<!--
Create
Group
Modal
-->
<!--
Create
Group
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showCreateModal
"
:
show
=
"
showCreateModal
"
:
title
=
"
t('admin.groups.createGroup')
"
:
title
=
"
t('admin.groups.createGroup')
"
size
=
"
lg
"
width
=
"
normal
"
@
close
=
"
closeCreateModal
"
@
close
=
"
closeCreateModal
"
>
>
<
form
@
submit
.
prevent
=
"
handleCreateGroup
"
class
=
"
space-y-5
"
>
<
form
id
=
"
create-group-form
"
@
submit
.
prevent
=
"
handleCreateGroup
"
class
=
"
space-y-5
"
>
<
div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.name
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.name
'
)
}}
<
/label
>
<
input
<
input
...
@@ -345,11 +346,19 @@
...
@@ -345,11 +346,19 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/form
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
button
@
click
=
"
closeCreateModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
<
button
@
click
=
"
closeCreateModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
/button
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
form
=
"
create-group-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
<
svg
v
-
if
=
"
submitting
"
v
-
if
=
"
submitting
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
...
@@ -373,17 +382,22 @@
...
@@ -373,17 +382,22 @@
{{
submitting
?
t
(
'
admin.groups.creating
'
)
:
t
(
'
common.create
'
)
}}
{{
submitting
?
t
(
'
admin.groups.creating
'
)
:
t
(
'
common.create
'
)
}}
<
/button
>
<
/button
>
<
/div
>
<
/div
>
<
/
form
>
<
/
template
>
<
/
Modal
>
<
/
BaseDialog
>
<!--
Edit
Group
Modal
-->
<!--
Edit
Group
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showEditModal
"
:
show
=
"
showEditModal
"
:
title
=
"
t('admin.groups.editGroup')
"
:
title
=
"
t('admin.groups.editGroup')
"
size
=
"
lg
"
width
=
"
normal
"
@
close
=
"
closeEditModal
"
@
close
=
"
closeEditModal
"
>
>
<
form
v
-
if
=
"
editingGroup
"
@
submit
.
prevent
=
"
handleUpdateGroup
"
class
=
"
space-y-5
"
>
<
form
v
-
if
=
"
editingGroup
"
id
=
"
edit-group-form
"
@
submit
.
prevent
=
"
handleUpdateGroup
"
class
=
"
space-y-5
"
>
<
div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.name
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.name
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editForm.name
"
type
=
"
text
"
required
class
=
"
input
"
/>
<
input
v
-
model
=
"
editForm.name
"
type
=
"
text
"
required
class
=
"
input
"
/>
...
@@ -490,11 +504,19 @@
...
@@ -490,11 +504,19 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/form
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
button
@
click
=
"
closeEditModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
<
button
@
click
=
"
closeEditModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
/button
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
form
=
"
edit-group-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
<
svg
v
-
if
=
"
submitting
"
v
-
if
=
"
submitting
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
...
@@ -518,8 +540,8 @@
...
@@ -518,8 +540,8 @@
{{
submitting
?
t
(
'
admin.groups.updating
'
)
:
t
(
'
common.update
'
)
}}
{{
submitting
?
t
(
'
admin.groups.updating
'
)
:
t
(
'
common.update
'
)
}}
<
/button
>
<
/button
>
<
/div
>
<
/div
>
<
/
form
>
<
/
template
>
<
/
Modal
>
<
/
BaseDialog
>
<!--
Delete
Confirmation
Dialog
-->
<!--
Delete
Confirmation
Dialog
-->
<
ConfirmDialog
<
ConfirmDialog
...
@@ -546,7 +568,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
...
@@ -546,7 +568,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/
Modal
.vue
'
import
BaseDialog
from
'
@/components/common/
BaseDialog
.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Select
from
'
@/components/common/Select.vue
'
...
@@ -616,6 +638,8 @@ const pagination = reactive({
...
@@ -616,6 +638,8 @@ const pagination = reactive({
pages
:
0
pages
:
0
}
)
}
)
let
abortController
:
AbortController
|
null
=
null
const
showCreateModal
=
ref
(
false
)
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
...
@@ -660,21 +684,33 @@ const deleteConfirmMessage = computed(() => {
...
@@ -660,21 +684,33 @@ const deleteConfirmMessage = computed(() => {
}
)
}
)
const
loadGroups
=
async
()
=>
{
const
loadGroups
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentController
=
new
AbortController
()
abortController
=
currentController
const
{
signal
}
=
currentController
loading
.
value
=
true
loading
.
value
=
true
try
{
try
{
const
response
=
await
adminAPI
.
groups
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
const
response
=
await
adminAPI
.
groups
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
platform
:
(
filters
.
platform
as
GroupPlatform
)
||
undefined
,
platform
:
(
filters
.
platform
as
GroupPlatform
)
||
undefined
,
status
:
filters
.
status
as
any
,
status
:
filters
.
status
as
any
,
is_exclusive
:
filters
.
is_exclusive
?
filters
.
is_exclusive
===
'
true
'
:
undefined
is_exclusive
:
filters
.
is_exclusive
?
filters
.
is_exclusive
===
'
true
'
:
undefined
}
)
}
,
{
signal
}
)
if
(
signal
.
aborted
)
return
groups
.
value
=
response
.
items
groups
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
}
catch
(
error
:
any
)
{
if
(
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.groups.failedToLoad
'
))
appStore
.
showError
(
t
(
'
admin.groups.failedToLoad
'
))
console
.
error
(
'
Error loading groups:
'
,
error
)
console
.
error
(
'
Error loading groups:
'
,
error
)
}
finally
{
}
finally
{
loading
.
value
=
false
if
(
abortController
===
currentController
&&
!
signal
.
aborted
)
{
loading
.
value
=
false
}
}
}
}
}
...
@@ -683,6 +719,12 @@ const handlePageChange = (page: number) => {
...
@@ -683,6 +719,12 @@ const handlePageChange = (page: number) => {
loadGroups
()
loadGroups
()
}
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadGroups
()
}
const
closeCreateModal
=
()
=>
{
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
showCreateModal
.
value
=
false
createForm
.
name
=
''
createForm
.
name
=
''
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
ff06583c
...
@@ -209,15 +209,16 @@
...
@@ -209,15 +209,16 @@
:total=
"pagination.total"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
/>
</
template
>
</
template
>
</TablePageLayout>
</TablePageLayout>
<!-- Create Proxy Modal -->
<!-- Create Proxy Modal -->
<
Modal
<
BaseDialog
:show=
"showCreateModal"
:show=
"showCreateModal"
:title=
"t('admin.proxies.createProxy')"
:title=
"t('admin.proxies.createProxy')"
size=
"lg
"
width=
"normal
"
@
close=
"closeCreateModal"
@
close=
"closeCreateModal"
>
>
<!-- Tab Switch -->
<!-- Tab Switch -->
...
@@ -271,7 +272,12 @@
...
@@ -271,7 +272,12 @@
</div>
</div>
<!-- Standard Add Form -->
<!-- Standard Add Form -->
<form
v-if=
"createMode === 'standard'"
@
submit.prevent=
"handleCreateProxy"
class=
"space-y-5"
>
<form
v-if=
"createMode === 'standard'"
id=
"create-proxy-form"
@
submit.prevent=
"handleCreateProxy"
class=
"space-y-5"
>
<div>
<div>
<label
class=
"input-label"
>
{{ t('admin.proxies.name') }}
</label>
<label
class=
"input-label"
>
{{ t('admin.proxies.name') }}
</label>
<input
<input
...
@@ -329,34 +335,6 @@
...
@@ -329,34 +335,6 @@
/>
/>
</div>
</div>
<div
class=
"flex justify-end gap-3 pt-4"
>
<button
@
click=
"closeCreateModal"
type=
"button"
class=
"btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<svg
v-if=
"submitting"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin"
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>
{{ submitting ? t('admin.proxies.creating') : t('common.create') }}
</button>
</div>
</form>
</form>
<!-- Batch Add Form -->
<!-- Batch Add Form -->
...
@@ -435,11 +413,44 @@
...
@@ -435,11 +413,44 @@
</div>
</div>
</div>
</div>
<div
class=
"flex justify-end gap-3 pt-4"
>
</div>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"closeCreateModal"
type=
"button"
class=
"btn btn-secondary"
>
<button
@
click=
"closeCreateModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
{{
t
(
'
common.cancel
'
)
}}
</button>
</button>
<button
<button
v-if=
"createMode === 'standard'"
type=
"submit"
form=
"create-proxy-form"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<svg
v-if=
"submitting"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin"
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>
{{
submitting
?
t
(
'
admin.proxies.creating
'
)
:
t
(
'
common.create
'
)
}}
</button>
<button
v-else
@
click=
"handleBatchCreate"
@
click=
"handleBatchCreate"
type=
"button"
type=
"button"
:disabled=
"submitting || batchParseResult.valid === 0"
:disabled=
"submitting || batchParseResult.valid === 0"
...
@@ -472,17 +483,22 @@
...
@@ -472,17 +483,22 @@
}}
}}
<
/button
>
<
/button
>
<
/div
>
<
/div
>
</
div
>
<
/
template
>
</
Modal
>
<
/
BaseDialog
>
<!--
Edit
Proxy
Modal
-->
<!--
Edit
Proxy
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showEditModal
"
:
show
=
"
showEditModal
"
:
title
=
"
t('admin.proxies.editProxy')
"
:
title
=
"
t('admin.proxies.editProxy')
"
size=
"lg
"
width
=
"
normal
"
@
close
=
"
closeEditModal
"
@
close
=
"
closeEditModal
"
>
>
<form
v-if=
"editingProxy"
@
submit.prevent=
"handleUpdateProxy"
class=
"space-y-5"
>
<
form
v
-
if
=
"
editingProxy
"
id
=
"
edit-proxy-form
"
@
submit
.
prevent
=
"
handleUpdateProxy
"
class
=
"
space-y-5
"
>
<
div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.proxies.name
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.proxies.name
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editForm.name
"
type
=
"
text
"
required
class
=
"
input
"
/>
<
input
v
-
model
=
"
editForm.name
"
type
=
"
text
"
required
class
=
"
input
"
/>
...
@@ -526,11 +542,20 @@
...
@@ -526,11 +542,20 @@
<
Select
v
-
model
=
"
editForm.status
"
:
options
=
"
editStatusOptions
"
/>
<
Select
v
-
model
=
"
editForm.status
"
:
options
=
"
editStatusOptions
"
/>
<
/div
>
<
/div
>
<div
class=
"flex justify-end gap-3 pt-4"
>
<
/form
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3
"
>
<
button
@
click
=
"
closeEditModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
<
button
@
click
=
"
closeEditModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
/button
>
<button
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<
button
v
-
if
=
"
editingProxy
"
type
=
"
submit
"
form
=
"
edit-proxy-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
<
svg
v
-
if
=
"
submitting
"
v
-
if
=
"
submitting
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
...
@@ -554,8 +579,8 @@
...
@@ -554,8 +579,8 @@
{{
submitting
?
t
(
'
admin.proxies.updating
'
)
:
t
(
'
common.update
'
)
}}
{{
submitting
?
t
(
'
admin.proxies.updating
'
)
:
t
(
'
common.update
'
)
}}
<
/button
>
<
/button
>
<
/div
>
<
/div
>
</
form
>
<
/
template
>
</
Modal
>
<
/
BaseDialog
>
<!--
Delete
Confirmation
Dialog
-->
<!--
Delete
Confirmation
Dialog
-->
<
ConfirmDialog
<
ConfirmDialog
...
@@ -582,7 +607,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
...
@@ -582,7 +607,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/
Modal
.vue
'
import
BaseDialog
from
'
@/components/common/
BaseDialog
.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Select
from
'
@/components/common/Select.vue
'
...
@@ -682,22 +707,44 @@ const editForm = reactive({
...
@@ -682,22 +707,44 @@ const editForm = reactive({
status
:
'
active
'
as
'
active
'
|
'
inactive
'
status
:
'
active
'
as
'
active
'
|
'
inactive
'
}
)
}
)
let
abortController
:
AbortController
|
null
=
null
const
isAbortError
=
(
error
:
unknown
)
=>
{
if
(
!
error
||
typeof
error
!==
'
object
'
)
return
false
const
maybeError
=
error
as
{
name
?:
string
;
code
?:
string
}
return
maybeError
.
name
===
'
AbortError
'
||
maybeError
.
code
===
'
ERR_CANCELED
'
}
const
loadProxies
=
async
()
=>
{
const
loadProxies
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentAbortController
=
new
AbortController
()
abortController
=
currentAbortController
loading
.
value
=
true
loading
.
value
=
true
try
{
try
{
const
response
=
await
adminAPI
.
proxies
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
const
response
=
await
adminAPI
.
proxies
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
protocol
:
filters
.
protocol
||
undefined
,
protocol
:
filters
.
protocol
||
undefined
,
status
:
filters
.
status
as
any
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
search
:
searchQuery
.
value
||
undefined
})
}
,
{
signal
:
currentAbortController
.
signal
}
)
if
(
currentAbortController
.
signal
.
aborted
||
abortController
!==
currentAbortController
)
{
return
}
proxies
.
value
=
response
.
items
proxies
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
}
catch
(
error
)
{
if
(
isAbortError
(
error
))
{
return
}
appStore
.
showError
(
t
(
'
admin.proxies.failedToLoad
'
))
appStore
.
showError
(
t
(
'
admin.proxies.failedToLoad
'
))
console
.
error
(
'
Error loading proxies:
'
,
error
)
console
.
error
(
'
Error loading proxies:
'
,
error
)
}
finally
{
}
finally
{
loading
.
value
=
false
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
}
}
...
@@ -715,6 +762,12 @@ const handlePageChange = (page: number) => {
...
@@ -715,6 +762,12 @@ const handlePageChange = (page: number) => {
loadProxies
()
loadProxies
()
}
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadProxies
()
}
const
closeCreateModal
=
()
=>
{
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
showCreateModal
.
value
=
false
createMode
.
value
=
'
standard
'
createMode
.
value
=
'
standard
'
...
...
frontend/src/views/admin/RedeemView.vue
View file @
ff06583c
...
@@ -186,6 +186,7 @@
...
@@ -186,6 +186,7 @@
:
total
=
"
pagination.total
"
:
total
=
"
pagination.total
"
:
page
-
size
=
"
pagination.page_size
"
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
pageSize
=
"
handlePageSizeChange
"
/>
/>
<!--
Batch
Actions
-->
<!--
Batch
Actions
-->
...
@@ -542,6 +543,8 @@ const pagination = reactive({
...
@@ -542,6 +543,8 @@ const pagination = reactive({
pages
:
0
pages
:
0
}
)
}
)
let
abortController
:
AbortController
|
null
=
null
const
showDeleteDialog
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showDeleteUnusedDialog
=
ref
(
false
)
const
showDeleteUnusedDialog
=
ref
(
false
)
const
deletingCode
=
ref
<
RedeemCode
|
null
>
(
null
)
const
deletingCode
=
ref
<
RedeemCode
|
null
>
(
null
)
...
@@ -556,21 +559,46 @@ const generateForm = reactive({
...
@@ -556,21 +559,46 @@ const generateForm = reactive({
}
)
}
)
const
loadCodes
=
async
()
=>
{
const
loadCodes
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentController
=
new
AbortController
()
abortController
=
currentController
loading
.
value
=
true
loading
.
value
=
true
try
{
try
{
const
response
=
await
adminAPI
.
redeem
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
const
response
=
await
adminAPI
.
redeem
.
list
(
type
:
filters
.
type
as
RedeemCodeType
,
pagination
.
page
,
status
:
filters
.
status
as
any
,
pagination
.
page_size
,
search
:
searchQuery
.
value
||
undefined
{
}
)
type
:
filters
.
type
as
RedeemCodeType
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
}
,
{
signal
:
currentController
.
signal
}
)
if
(
currentController
.
signal
.
aborted
)
{
return
}
codes
.
value
=
response
.
items
codes
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
}
catch
(
error
:
any
)
{
if
(
currentController
.
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.redeem.failedToLoad
'
))
appStore
.
showError
(
t
(
'
admin.redeem.failedToLoad
'
))
console
.
error
(
'
Error loading redeem codes:
'
,
error
)
console
.
error
(
'
Error loading redeem codes:
'
,
error
)
}
finally
{
}
finally
{
loading
.
value
=
false
if
(
abortController
===
currentController
&&
!
currentController
.
signal
.
aborted
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
}
}
...
@@ -588,6 +616,12 @@ const handlePageChange = (page: number) => {
...
@@ -588,6 +616,12 @@ const handlePageChange = (page: number) => {
loadCodes
()
loadCodes
()
}
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadCodes
()
}
const
handleGenerateCodes
=
async
()
=>
{
const
handleGenerateCodes
=
async
()
=>
{
// 订阅类型必须选择分组
// 订阅类型必须选择分组
if
(
generateForm
.
type
===
'
subscription
'
&&
!
generateForm
.
group_id
)
{
if
(
generateForm
.
type
===
'
subscription
'
&&
!
generateForm
.
group_id
)
{
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
ff06583c
...
@@ -316,18 +316,23 @@
...
@@ -316,18 +316,23 @@
:
total
=
"
pagination.total
"
:
total
=
"
pagination.total
"
:
page
-
size
=
"
pagination.page_size
"
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
pageSize
=
"
handlePageSizeChange
"
/>
/>
<
/template
>
<
/template
>
<
/TablePageLayout
>
<
/TablePageLayout
>
<!--
Assign
Subscription
Modal
-->
<!--
Assign
Subscription
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showAssignModal
"
:
show
=
"
showAssignModal
"
:
title
=
"
t('admin.subscriptions.assignSubscription')
"
:
title
=
"
t('admin.subscriptions.assignSubscription')
"
size
=
"
lg
"
width
=
"
normal
"
@
close
=
"
closeAssignModal
"
@
close
=
"
closeAssignModal
"
>
>
<
form
@
submit
.
prevent
=
"
handleAssignSubscription
"
class
=
"
space-y-5
"
>
<
form
id
=
"
assign-subscription-form
"
@
submit
.
prevent
=
"
handleAssignSubscription
"
class
=
"
space-y-5
"
>
<
div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.user
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.user
'
)
}}
<
/label
>
<
Select
<
Select
...
@@ -351,12 +356,18 @@
...
@@ -351,12 +356,18 @@
<
input
v
-
model
.
number
=
"
assignForm.validity_days
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
assignForm.validity_days
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.subscriptions.validityHint
'
)
}}
<
/p
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.subscriptions.validityHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/form
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3
"
>
<
button
@
click
=
"
closeAssignModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
<
button
@
click
=
"
closeAssignModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
/button
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
form
=
"
assign-subscription-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
<
svg
v
-
if
=
"
submitting
"
v
-
if
=
"
submitting
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
...
@@ -380,18 +391,19 @@
...
@@ -380,18 +391,19 @@
{{
submitting
?
t
(
'
admin.subscriptions.assigning
'
)
:
t
(
'
admin.subscriptions.assign
'
)
}}
{{
submitting
?
t
(
'
admin.subscriptions.assigning
'
)
:
t
(
'
admin.subscriptions.assign
'
)
}}
<
/button
>
<
/button
>
<
/div
>
<
/div
>
<
/
form
>
<
/
template
>
<
/
Modal
>
<
/
BaseDialog
>
<!--
Extend
Subscription
Modal
-->
<!--
Extend
Subscription
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showExtendModal
"
:
show
=
"
showExtendModal
"
:
title
=
"
t('admin.subscriptions.extendSubscription')
"
:
title
=
"
t('admin.subscriptions.extendSubscription')
"
size
=
"
md
"
width
=
"
narrow
"
@
close
=
"
closeExtendModal
"
@
close
=
"
closeExtendModal
"
>
>
<
form
<
form
v
-
if
=
"
extendingSubscription
"
v
-
if
=
"
extendingSubscription
"
id
=
"
extend-subscription-form
"
@
submit
.
prevent
=
"
handleExtendSubscription
"
@
submit
.
prevent
=
"
handleExtendSubscription
"
class
=
"
space-y-5
"
class
=
"
space-y-5
"
>
>
...
@@ -417,17 +429,23 @@
...
@@ -417,17 +429,23 @@
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.extendDays
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.extendDays
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
extendForm.days
"
type
=
"
number
"
min
=
"
1
"
required
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
extendForm.days
"
type
=
"
number
"
min
=
"
1
"
required
class
=
"
input
"
/>
<
/div
>
<
/div
>
<
/form
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
template
#
footer
>
<
div
v
-
if
=
"
extendingSubscription
"
class
=
"
flex justify-end gap-3
"
>
<
button
@
click
=
"
closeExtendModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
<
button
@
click
=
"
closeExtendModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
/button
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
form
=
"
extend-subscription-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
{{
submitting
?
t
(
'
admin.subscriptions.extending
'
)
:
t
(
'
admin.subscriptions.extend
'
)
}}
{{
submitting
?
t
(
'
admin.subscriptions.extending
'
)
:
t
(
'
admin.subscriptions.extend
'
)
}}
<
/button
>
<
/button
>
<
/div
>
<
/div
>
<
/
form
>
<
/
template
>
<
/
Modal
>
<
/
BaseDialog
>
<!--
Revoke
Confirmation
Dialog
-->
<!--
Revoke
Confirmation
Dialog
-->
<
ConfirmDialog
<
ConfirmDialog
...
@@ -455,7 +473,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
...
@@ -455,7 +473,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/
Modal
.vue
'
import
BaseDialog
from
'
@/components/common/
BaseDialog
.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Select
from
'
@/components/common/Select.vue
'
...
@@ -485,6 +503,7 @@ const subscriptions = ref<UserSubscription[]>([])
...
@@ -485,6 +503,7 @@ const subscriptions = ref<UserSubscription[]>([])
const
groups
=
ref
<
Group
[]
>
([])
const
groups
=
ref
<
Group
[]
>
([])
const
users
=
ref
<
User
[]
>
([])
const
users
=
ref
<
User
[]
>
([])
const
loading
=
ref
(
false
)
const
loading
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
const
filters
=
reactive
({
const
filters
=
reactive
({
status
:
''
,
status
:
''
,
group_id
:
''
group_id
:
''
...
@@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() =>
...
@@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() =>
const
userOptions
=
computed
(()
=>
users
.
value
.
map
((
u
)
=>
({
value
:
u
.
id
,
label
:
u
.
email
}
)))
const
userOptions
=
computed
(()
=>
users
.
value
.
map
((
u
)
=>
({
value
:
u
.
id
,
label
:
u
.
email
}
)))
const
loadSubscriptions
=
async
()
=>
{
const
loadSubscriptions
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
requestController
=
new
AbortController
()
abortController
=
requestController
const
{
signal
}
=
requestController
loading
.
value
=
true
loading
.
value
=
true
try
{
try
{
const
response
=
await
adminAPI
.
subscriptions
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
const
response
=
await
adminAPI
.
subscriptions
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
status
:
(
filters
.
status
as
any
)
||
undefined
,
status
:
(
filters
.
status
as
any
)
||
undefined
,
group_id
:
filters
.
group_id
?
parseInt
(
filters
.
group_id
)
:
undefined
group_id
:
filters
.
group_id
?
parseInt
(
filters
.
group_id
)
:
undefined
}
,
{
signal
}
)
}
)
if
(
signal
.
aborted
||
abortController
!==
requestController
)
return
subscriptions
.
value
=
response
.
items
subscriptions
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
}
catch
(
error
:
any
)
{
if
(
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.subscriptions.failedToLoad
'
))
appStore
.
showError
(
t
(
'
admin.subscriptions.failedToLoad
'
))
console
.
error
(
'
Error loading subscriptions:
'
,
error
)
console
.
error
(
'
Error loading subscriptions:
'
,
error
)
}
finally
{
}
finally
{
loading
.
value
=
false
if
(
abortController
===
requestController
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
}
}
...
@@ -569,6 +604,12 @@ const handlePageChange = (page: number) => {
...
@@ -569,6 +604,12 @@ const handlePageChange = (page: number) => {
loadSubscriptions
()
loadSubscriptions
()
}
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadSubscriptions
()
}
const
closeAssignModal
=
()
=>
{
const
closeAssignModal
=
()
=>
{
showAssignModal
.
value
=
false
showAssignModal
.
value
=
false
assignForm
.
user_id
=
null
assignForm
.
user_id
=
null
...
...
frontend/src/views/admin/UsageView.vue
View file @
ff06583c
...
@@ -224,7 +224,7 @@
...
@@ -224,7 +224,7 @@
v-model=
"filters.api_key_id"
v-model=
"filters.api_key_id"
:options=
"apiKeyOptions"
:options=
"apiKeyOptions"
:placeholder=
"t('usage.allApiKeys')"
:placeholder=
"t('usage.allApiKeys')"
:disabled=
"!selectedUser && apiKeys.length === 0"
searchable
@
change=
"applyFilters"
@
change=
"applyFilters"
/>
/>
</div>
</div>
...
@@ -236,6 +236,7 @@
...
@@ -236,6 +236,7 @@
v-model=
"filters.model"
v-model=
"filters.model"
:options=
"modelOptions"
:options=
"modelOptions"
:placeholder=
"t('admin.usage.allModels')"
:placeholder=
"t('admin.usage.allModels')"
searchable
@
change=
"applyFilters"
@
change=
"applyFilters"
/>
/>
</div>
</div>
...
@@ -534,6 +535,7 @@
...
@@ -534,6 +535,7 @@
:total=
"pagination.total"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
/>
</div>
</div>
</AppLayout>
</AppLayout>
...
@@ -666,6 +668,7 @@ const models = ref<string[]>([])
...
@@ -666,6 +668,7 @@ const models = ref<string[]>([])
const
accounts
=
ref
<
any
[]
>
([])
const
accounts
=
ref
<
any
[]
>
([])
const
groups
=
ref
<
any
[]
>
([])
const
groups
=
ref
<
any
[]
>
([])
const
loading
=
ref
(
false
)
const
loading
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
// User search state
// User search state
const
userSearchKeyword
=
ref
(
''
)
const
userSearchKeyword
=
ref
(
''
)
...
@@ -675,7 +678,7 @@ const showUserDropdown = ref(false)
...
@@ -675,7 +678,7 @@ const showUserDropdown = ref(false)
const
selectedUser
=
ref
<
SimpleUser
|
null
>
(
null
)
const
selectedUser
=
ref
<
SimpleUser
|
null
>
(
null
)
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
// API Key options computed from
selected user's
keys
// API Key options computed from
loaded
keys
const
apiKeyOptions
=
computed
(()
=>
{
const
apiKeyOptions
=
computed
(()
=>
{
return
[
return
[
{
value
:
null
,
label
:
t
(
'
usage.allApiKeys
'
)
},
{
value
:
null
,
label
:
t
(
'
usage.allApiKeys
'
)
},
...
@@ -796,7 +799,7 @@ const selectUser = async (user: SimpleUser) => {
...
@@ -796,7 +799,7 @@ const selectUser = async (user: SimpleUser) => {
filters
.
value
.
api_key_id
=
undefined
filters
.
value
.
api_key_id
=
undefined
// Load API keys for selected user
// Load API keys for selected user
await
loadApiKeys
ForUser
(
user
.
id
)
await
loadApiKeys
(
user
.
id
)
applyFilters
()
applyFilters
()
}
}
...
@@ -807,10 +810,11 @@ const clearUserFilter = () => {
...
@@ -807,10 +810,11 @@ const clearUserFilter = () => {
filters
.
value
.
user_id
=
undefined
filters
.
value
.
user_id
=
undefined
filters
.
value
.
api_key_id
=
undefined
filters
.
value
.
api_key_id
=
undefined
apiKeys
.
value
=
[]
apiKeys
.
value
=
[]
loadApiKeys
()
applyFilters
()
applyFilters
()
}
}
const
loadApiKeys
ForUser
=
async
(
userId
:
number
)
=>
{
const
loadApiKeys
=
async
(
userId
?
:
number
)
=>
{
try
{
try
{
apiKeys
.
value
=
await
adminAPI
.
usage
.
searchApiKeys
(
userId
)
apiKeys
.
value
=
await
adminAPI
.
usage
.
searchApiKeys
(
userId
)
}
catch
(
error
)
{
}
catch
(
error
)
{
...
@@ -863,7 +867,24 @@ const formatCacheTokens = (value: number): string => {
...
@@ -863,7 +867,24 @@ const formatCacheTokens = (value: number): string => {
return
value
.
toLocaleString
()
return
value
.
toLocaleString
()
}
}
const
isAbortError
=
(
error
:
unknown
):
boolean
=>
{
if
(
error
instanceof
DOMException
&&
error
.
name
===
'
AbortError
'
)
{
return
true
}
if
(
typeof
error
===
'
object
'
&&
error
!==
null
)
{
const
maybeError
=
error
as
{
code
?:
string
;
name
?:
string
}
return
maybeError
.
code
===
'
ERR_CANCELED
'
||
maybeError
.
name
===
'
CanceledError
'
}
return
false
}
const
loadUsageLogs
=
async
()
=>
{
const
loadUsageLogs
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
controller
=
new
AbortController
()
abortController
=
controller
const
{
signal
}
=
controller
loading
.
value
=
true
loading
.
value
=
true
try
{
try
{
const
params
:
AdminUsageQueryParams
=
{
const
params
:
AdminUsageQueryParams
=
{
...
@@ -872,17 +893,23 @@ const loadUsageLogs = async () => {
...
@@ -872,17 +893,23 @@ const loadUsageLogs = async () => {
...
filters
.
value
...
filters
.
value
}
}
const
response
=
await
adminAPI
.
usage
.
list
(
params
)
const
response
=
await
adminAPI
.
usage
.
list
(
params
,
{
signal
})
if
(
signal
.
aborted
)
{
return
}
usageLogs
.
value
=
response
.
items
usageLogs
.
value
=
response
.
items
pagination
.
value
.
total
=
response
.
total
pagination
.
value
.
total
=
response
.
total
pagination
.
value
.
pages
=
response
.
pages
pagination
.
value
.
pages
=
response
.
pages
// Extract models from loaded logs for filter options
extractModelsFromLogs
()
}
catch
(
error
)
{
}
catch
(
error
)
{
if
(
signal
.
aborted
||
isAbortError
(
error
))
{
return
}
appStore
.
showError
(
t
(
'
usage.failedToLoad
'
))
appStore
.
showError
(
t
(
'
usage.failedToLoad
'
))
}
finally
{
}
finally
{
loading
.
value
=
false
if
(
!
signal
.
aborted
&&
abortController
===
controller
)
{
loading
.
value
=
false
}
}
}
}
}
...
@@ -944,27 +971,37 @@ const applyFilters = () => {
...
@@ -944,27 +971,37 @@ const applyFilters = () => {
// Load filter options
// Load filter options
const
loadFilterOptions
=
async
()
=>
{
const
loadFilterOptions
=
async
()
=>
{
try
{
try
{
// Load accounts
const
[
accountsResponse
,
groupsResponse
]
=
await
Promise
.
all
([
const
accountsResponse
=
await
adminAPI
.
accounts
.
list
(
1
,
1000
)
adminAPI
.
accounts
.
list
(
1
,
1000
),
adminAPI
.
groups
.
list
(
1
,
1000
)
])
accounts
.
value
=
accountsResponse
.
items
||
[]
accounts
.
value
=
accountsResponse
.
items
||
[]
// Load groups
const
groupsResponse
=
await
adminAPI
.
groups
.
list
(
1
,
1000
)
groups
.
value
=
groupsResponse
.
items
||
[]
groups
.
value
=
groupsResponse
.
items
||
[]
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
'
Failed to load filter options:
'
,
error
)
console
.
error
(
'
Failed to load filter options:
'
,
error
)
}
}
await
loadModelOptions
()
}
}
// Extract unique models from usage logs
const
loadModelOptions
=
async
()
=>
{
const
extractModelsFromLogs
=
()
=>
{
try
{
const
uniqueModels
=
new
Set
<
string
>
()
const
endDate
=
new
Date
()
usageLogs
.
value
.
forEach
(
log
=>
{
const
startDateRange
=
new
Date
(
endDate
)
if
(
log
.
model
)
{
startDateRange
.
setDate
(
startDateRange
.
getDate
()
-
29
)
uniqueModels
.
add
(
log
.
model
)
const
response
=
await
adminAPI
.
dashboard
.
getModelStats
({
}
start_date
:
startDateRange
.
toISOString
().
split
(
'
T
'
)[
0
],
})
end_date
:
endDate
.
toISOString
().
split
(
'
T
'
)[
0
]
models
.
value
=
Array
.
from
(
uniqueModels
).
sort
()
})
const
uniqueModels
=
new
Set
<
string
>
()
response
.
models
?.
forEach
((
stat
)
=>
{
if
(
stat
.
model
)
{
uniqueModels
.
add
(
stat
.
model
)
}
})
models
.
value
=
Array
.
from
(
uniqueModels
).
sort
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to load model options:
'
,
error
)
}
}
}
const
resetFilters
=
()
=>
{
const
resetFilters
=
()
=>
{
...
@@ -987,6 +1024,7 @@ const resetFilters = () => {
...
@@ -987,6 +1024,7 @@ const resetFilters = () => {
// Reset date range to default (last 7 days)
// Reset date range to default (last 7 days)
initializeDateRange
()
initializeDateRange
()
pagination
.
value
.
page
=
1
pagination
.
value
.
page
=
1
loadApiKeys
()
loadUsageLogs
()
loadUsageLogs
()
loadUsageStats
()
loadUsageStats
()
loadChartData
()
loadChartData
()
...
@@ -997,6 +1035,12 @@ const handlePageChange = (page: number) => {
...
@@ -997,6 +1035,12 @@ const handlePageChange = (page: number) => {
loadUsageLogs
()
loadUsageLogs
()
}
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
value
.
page_size
=
pageSize
pagination
.
value
.
page
=
1
loadUsageLogs
()
}
const
exportToCSV
=
()
=>
{
const
exportToCSV
=
()
=>
{
if
(
usageLogs
.
value
.
length
===
0
)
{
if
(
usageLogs
.
value
.
length
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
...
@@ -1072,6 +1116,7 @@ const hideTooltip = () => {
...
@@ -1072,6 +1116,7 @@ const hideTooltip = () => {
onMounted
(()
=>
{
onMounted
(()
=>
{
initializeDateRange
()
initializeDateRange
()
loadFilterOptions
()
loadFilterOptions
()
loadApiKeys
()
loadUsageLogs
()
loadUsageLogs
()
loadUsageStats
()
loadUsageStats
()
loadChartData
()
loadChartData
()
...
@@ -1083,5 +1128,8 @@ onUnmounted(() => {
...
@@ -1083,5 +1128,8 @@ onUnmounted(() => {
if
(
searchTimeout
)
{
if
(
searchTimeout
)
{
clearTimeout
(
searchTimeout
)
clearTimeout
(
searchTimeout
)
}
}
if
(
abortController
)
{
abortController
.
abort
()
}
})
})
</
script
>
</
script
>
Prev
1
2
3
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