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
bdc426a7
Commit
bdc426a7
authored
Jan 18, 2026
by
yangjianbo
Browse files
Merge branch 'main' into dev
parents
771baa66
32fff379
Changes
44
Show whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/zh.ts
View file @
bdc426a7
...
...
@@ -160,6 +160,7 @@ export default {
notAvailable
:
'
不可用
'
,
now
:
'
现在
'
,
unknown
:
'
未知
'
,
minutes
:
'
分钟
'
,
time
:
{
never
:
'
从未
'
,
justNow
:
'
刚刚
'
,
...
...
@@ -1134,7 +1135,7 @@ export default {
platformType
:
'
平台/类型
'
,
platform
:
'
平台
'
,
type
:
'
类型
'
,
c
oncurrencyStatus
:
'
并发
'
,
c
apacity
:
'
容量
'
,
notes
:
'
备注
'
,
priority
:
'
优先级
'
,
billingRateMultiplier
:
'
账号倍率
'
,
...
...
@@ -1144,10 +1145,23 @@ export default {
todayStats
:
'
今日统计
'
,
groups
:
'
分组
'
,
usageWindows
:
'
用量窗口
'
,
proxy
:
'
代理
'
,
lastUsed
:
'
最近使用
'
,
expiresAt
:
'
过期时间
'
,
actions
:
'
操作
'
},
// 容量状态提示
capacity
:
{
windowCost
:
{
blocked
:
'
5h窗口费用超限,账号暂停调度
'
,
stickyOnly
:
'
5h窗口费用达阈值,仅允许粘性会话
'
,
normal
:
'
5h窗口费用正常
'
},
sessions
:
{
full
:
'
活跃会话已满,新会话需等待(空闲超时:{idle}分钟)
'
,
normal
:
'
活跃会话正常(空闲超时:{idle}分钟)
'
}
},
clearRateLimit
:
'
清除速率限制
'
,
testConnection
:
'
测试连接
'
,
reAuthorize
:
'
重新授权
'
,
...
...
@@ -1383,6 +1397,31 @@ export default {
interceptWarmupRequestsDesc
:
'
启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token
'
,
autoPauseOnExpired
:
'
过期自动暂停调度
'
,
autoPauseOnExpiredDesc
:
'
启用后,账号过期将自动暂停调度
'
,
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl
:
{
title
:
'
配额控制
'
,
hint
:
'
仅适用于 Anthropic OAuth/Setup Token 账号
'
,
windowCost
:
{
label
:
'
5h窗口费用控制
'
,
hint
:
'
限制账号在5小时窗口内的费用使用
'
,
limit
:
'
费用阈值
'
,
limitPlaceholder
:
'
50
'
,
limitHint
:
'
达到阈值后不参与新请求调度
'
,
stickyReserve
:
'
粘性预留额度
'
,
stickyReservePlaceholder
:
'
10
'
,
stickyReserveHint
:
'
为粘性会话预留的额外额度
'
},
sessionLimit
:
{
label
:
'
会话数量控制
'
,
hint
:
'
限制同时活跃的会话数量
'
,
maxSessions
:
'
最大会话数
'
,
maxSessionsPlaceholder
:
'
3
'
,
maxSessionsHint
:
'
同时活跃的最大会话数量
'
,
idleTimeout
:
'
空闲超时
'
,
idleTimeoutPlaceholder
:
'
5
'
,
idleTimeoutHint
:
'
会话空闲超时后自动释放
'
}
},
expired
:
'
已过期
'
,
proxy
:
'
代理
'
,
noProxy
:
'
无代理
'
,
...
...
frontend/src/types/index.ts
View file @
bdc426a7
...
...
@@ -471,6 +471,18 @@ export interface Account {
session_window_start
:
string
|
null
session_window_end
:
string
|
null
session_window_status
:
'
allowed
'
|
'
allowed_warning
'
|
'
rejected
'
|
null
// 5h窗口费用控制(仅 Anthropic OAuth/SetupToken 账号有效)
window_cost_limit
?:
number
|
null
window_cost_sticky_reserve
?:
number
|
null
// 会话数量控制(仅 Anthropic OAuth/SetupToken 账号有效)
max_sessions
?:
number
|
null
session_idle_timeout_minutes
?:
number
|
null
// 运行时状态(仅当启用对应限制时返回)
current_window_cost
?:
number
|
null
// 当前窗口费用
active_sessions
?:
number
|
null
// 当前活跃会话数
}
// Account Usage types
...
...
frontend/src/views/admin/AccountsView.vue
View file @
bdc426a7
...
...
@@ -15,7 +15,40 @@
@
refresh=
"load"
@
sync=
"showSync = true"
@
create=
"showCreate = true"
/>
>
<template
#before
>
<!-- 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=
"max-h-80 overflow-y-auto p-2"
>
<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>
</
template
>
</AccountTableActions>
</div>
</template>
<
template
#table
>
...
...
@@ -34,15 +67,8 @@
<
template
#cell-platform_type=
"{ row }"
>
<PlatformTypeBadge
:platform=
"row.platform"
:type=
"row.type"
/>
</
template
>
<
template
#cell-concurrency=
"{ row }"
>
<div
class=
"flex items-center gap-1.5"
>
<span
:class=
"['inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium', (row.current_concurrency || 0) >= row.concurrency ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' : (row.current_concurrency || 0) > 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400']"
>
<svg
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/></svg>
<span
class=
"font-mono"
>
{{
row
.
current_concurrency
||
0
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
{{
row
.
concurrency
}}
</span>
</span>
</div>
<
template
#cell-capacity=
"{ row }"
>
<AccountCapacityCell
:account=
"row"
/>
</
template
>
<
template
#cell-status=
"{ row }"
>
<AccountStatusIndicator
:account=
"row"
@
show-temp-unsched=
"handleShowTempUnsched"
/>
...
...
@@ -61,6 +87,15 @@
<
template
#cell-usage=
"{ row }"
>
<AccountUsageCell
:account=
"row"
/>
</
template
>
<
template
#cell-proxy=
"{ row }"
>
<div
v-if=
"row.proxy"
class=
"flex items-center gap-2"
>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
row
.
proxy
.
name
}}
</span>
<span
v-if=
"row.proxy.country_code"
class=
"text-xs text-gray-500 dark:text-gray-400"
>
(
{{
row
.
proxy
.
country_code
}}
)
</span>
</div>
<span
v-else
class=
"text-sm text-gray-400 dark:text-dark-500"
>
-
</span>
</
template
>
<
template
#cell-rate_multiplier=
"{ row }"
>
<span
class=
"text-sm font-mono text-gray-700 dark:text-gray-300"
>
{{
(
row
.
rate_multiplier
??
1
).
toFixed
(
2
)
}}
x
...
...
@@ -148,7 +183,9 @@ import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.
import
AccountUsageCell
from
'
@/components/account/AccountUsageCell.vue
'
import
AccountTodayStatsCell
from
'
@/components/account/AccountTodayStatsCell.vue
'
import
AccountGroupsCell
from
'
@/components/account/AccountGroupsCell.vue
'
import
AccountCapacityCell
from
'
@/components/account/AccountCapacityCell.vue
'
import
PlatformTypeBadge
from
'
@/components/common/PlatformTypeBadge.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
formatDateTime
,
formatRelativeTime
}
from
'
@/utils/format
'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
...
...
@@ -177,17 +214,59 @@ const statsAcc = ref<Account | null>(null)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
menu
=
reactive
<
{
show
:
boolean
,
acc
:
Account
|
null
,
pos
:{
top
:
number
,
left
:
number
}
|
null
}
>
({
show
:
false
,
acc
:
null
,
pos
:
null
})
// Column settings
const
showColumnDropdown
=
ref
(
false
)
const
columnDropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
hiddenColumns
=
reactive
<
Set
<
string
>>
(
new
Set
())
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
proxy
'
,
'
notes
'
,
'
priority
'
,
'
rate_multiplier
'
]
const
HIDDEN_COLUMNS_KEY
=
'
account-hidden-columns
'
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
))
}
}
const
saveColumnsToStorage
=
()
=>
{
try
{
localStorage
.
setItem
(
HIDDEN_COLUMNS_KEY
,
JSON
.
stringify
([...
hiddenColumns
]))
}
catch
(
e
)
{
console
.
error
(
'
Failed to save columns:
'
,
e
)
}
}
const
toggleColumn
=
(
key
:
string
)
=>
{
if
(
hiddenColumns
.
has
(
key
))
{
hiddenColumns
.
delete
(
key
)
}
else
{
hiddenColumns
.
add
(
key
)
}
saveColumnsToStorage
()
}
const
isColumnVisible
=
(
key
:
string
)
=>
!
hiddenColumns
.
has
(
key
)
const
{
items
:
accounts
,
loading
,
params
,
pagination
,
load
,
reload
,
debouncedReload
,
handlePageChange
,
handlePageSizeChange
}
=
useTableLoader
<
Account
,
any
>
({
fetchFn
:
adminAPI
.
accounts
.
list
,
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
search
:
''
}
})
const
cols
=
computed
(()
=>
{
// All available columns
const
allColumns
=
computed
(()
=>
{
const
c
=
[
{
key
:
'
select
'
,
label
:
''
,
sortable
:
false
},
{
key
:
'
name
'
,
label
:
t
(
'
admin.accounts.columns.name
'
),
sortable
:
true
},
{
key
:
'
platform_type
'
,
label
:
t
(
'
admin.accounts.columns.platformType
'
),
sortable
:
false
},
{
key
:
'
c
oncurrenc
y
'
,
label
:
t
(
'
admin.accounts.columns.c
oncurrencyStatus
'
),
sortable
:
false
},
{
key
:
'
c
apacit
y
'
,
label
:
t
(
'
admin.accounts.columns.c
apacity
'
),
sortable
:
false
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.accounts.columns.status
'
),
sortable
:
true
},
{
key
:
'
schedulable
'
,
label
:
t
(
'
admin.accounts.columns.schedulable
'
),
sortable
:
true
},
{
key
:
'
today_stats
'
,
label
:
t
(
'
admin.accounts.columns.todayStats
'
),
sortable
:
false
}
...
...
@@ -197,6 +276,7 @@ const cols = computed(() => {
}
c
.
push
(
{
key
:
'
usage
'
,
label
:
t
(
'
admin.accounts.columns.usageWindows
'
),
sortable
:
false
},
{
key
:
'
proxy
'
,
label
:
t
(
'
admin.accounts.columns.proxy
'
),
sortable
:
false
},
{
key
:
'
priority
'
,
label
:
t
(
'
admin.accounts.columns.priority
'
),
sortable
:
true
},
{
key
:
'
rate_multiplier
'
,
label
:
t
(
'
admin.accounts.columns.billingRateMultiplier
'
),
sortable
:
true
},
{
key
:
'
last_used_at
'
,
label
:
t
(
'
admin.accounts.columns.lastUsed
'
),
sortable
:
true
},
...
...
@@ -207,6 +287,18 @@ const cols = computed(() => {
return
c
})
// Columns that can be toggled (exclude select, name, and actions)
const
toggleableColumns
=
computed
(()
=>
allColumns
.
value
.
filter
(
col
=>
col
.
key
!==
'
select
'
&&
col
.
key
!==
'
name
'
&&
col
.
key
!==
'
actions
'
)
)
// Filtered columns based on visibility
const
cols
=
computed
(()
=>
allColumns
.
value
.
filter
(
col
=>
col
.
key
===
'
select
'
||
col
.
key
===
'
name
'
||
col
.
key
===
'
actions
'
||
!
hiddenColumns
.
has
(
col
.
key
)
)
)
const
handleEdit
=
(
a
:
Account
)
=>
{
edAcc
.
value
=
a
;
showEdit
.
value
=
true
}
const
openMenu
=
(
a
:
Account
,
e
:
MouseEvent
)
=>
{
menu
.
acc
=
a
...
...
@@ -409,12 +501,21 @@ const isExpired = (value: number | null) => {
return
value
*
1000
<=
Date
.
now
()
}
// 滚动时关闭
菜单
// 滚动时关闭
操作菜单(不关闭列设置下拉菜单)
const
handleScroll
=
()
=>
{
menu
.
show
=
false
}
// 点击外部关闭列设置下拉菜单
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
showColumnDropdown
.
value
=
false
}
}
onMounted
(
async
()
=>
{
loadSavedColumns
()
load
()
try
{
const
[
p
,
g
]
=
await
Promise
.
all
([
adminAPI
.
proxies
.
getAll
(),
adminAPI
.
groups
.
getAll
()])
...
...
@@ -424,9 +525,11 @@ onMounted(async () => {
console
.
error
(
'
Failed to load proxies/groups:
'
,
error
)
}
window
.
addEventListener
(
'
scroll
'
,
handleScroll
,
true
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
})
onUnmounted
(()
=>
{
window
.
removeEventListener
(
'
scroll
'
,
handleScroll
,
true
)
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
})
</
script
>
frontend/src/views/admin/SubscriptionsView.vue
View file @
bdc426a7
...
...
@@ -85,6 +85,57 @@
<!-- Right: Actions -->
<div
class=
"ml-auto flex flex-wrap items-center justify-end gap-3"
>
<!-- 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=
"loadSubscriptions"
:disabled=
"loading"
...
...
@@ -110,12 +161,18 @@
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
>
...
...
@@ -545,8 +602,43 @@ 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
}
,
// 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
:
true
}
,
{
key
:
'
group
'
,
label
:
t
(
'
admin.subscriptions.columns.group
'
),
sortable
:
true
}
,
{
key
:
'
usage
'
,
label
:
t
(
'
admin.subscriptions.columns.usage
'
),
sortable
:
false
}
,
{
key
:
'
expires_at
'
,
label
:
t
(
'
admin.subscriptions.columns.expires
'
),
sortable
:
true
}
,
...
...
@@ -554,6 +646,69 @@ const columns = computed<Column[]>(() => [
{
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
'
)
}
,
...
...
@@ -949,14 +1104,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
)
...
...
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