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
ac114738
Unverified
Commit
ac114738
authored
Apr 24, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 24, 2026
Browse files
Merge pull request #1850 from touwaeriol/feat/channel-insights
feat(monitor): channel monitor with available channels & feature flags
parents
0a80ec80
09fd83ab
Changes
151
Hide whitespace changes
Inline
Side-by-side
frontend/src/stores/app.ts
View file @
ac114738
...
...
@@ -352,6 +352,9 @@ export const useAppStore = defineStore('app', () => {
balance_low_notify_enabled
:
false
,
account_quota_notify_enabled
:
false
,
balance_low_notify_threshold
:
0
,
channel_monitor_enabled
:
true
,
channel_monitor_default_interval_seconds
:
60
,
available_channels_enabled
:
false
,
}
}
...
...
frontend/src/types/index.ts
View file @
ac114738
...
...
@@ -186,6 +186,9 @@ export interface PublicSettings {
balance_low_notify_enabled
:
boolean
account_quota_notify_enabled
:
boolean
balance_low_notify_threshold
:
number
channel_monitor_enabled
:
boolean
channel_monitor_default_interval_seconds
:
number
available_channels_enabled
:
boolean
}
export
interface
AuthResponse
{
...
...
frontend/src/utils/__tests__/usageLoadQueue.spec.ts
deleted
100644 → 0
View file @
0a80ec80
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
enqueueUsageRequest
}
from
'
../usageLoadQueue
'
import
type
{
Account
}
from
'
@/types
'
/** Helper to create a minimal Account with proxy info */
function
makeAccount
(
platform
:
string
,
type
:
string
=
'
oauth
'
,
proxy
?:
{
host
:
string
;
port
:
number
;
username
?:
string
|
null
}
|
null
):
Account
{
return
{
id
:
Math
.
floor
(
Math
.
random
()
*
10000
),
platform
,
type
,
name
:
'
test
'
,
status
:
'
active
'
,
proxy_id
:
proxy
?
1
:
null
,
proxy
:
proxy
?
{
id
:
1
,
name
:
'
p
'
,
protocol
:
'
http
'
,
host
:
proxy
.
host
,
port
:
proxy
.
port
,
username
:
proxy
.
username
??
null
,
status
:
'
active
'
,
created_at
:
''
,
updated_at
:
''
}
:
undefined
,
credentials
:
{},
created_at
:
''
,
updated_at
:
''
}
as
unknown
as
Account
}
describe
(
'
usageLoadQueue
'
,
()
=>
{
// ─── Anthropic 账号:按代理出口排队 ───
it
(
'
Anthropic 同代理出口串行执行,间隔 >= 1s
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
const
acc
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
,
username
:
'
u1
'
})
const
p1
=
enqueueUsageRequest
(
acc
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc
,
makeFn
())
const
p3
=
enqueueUsageRequest
(
acc
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
,
p3
])
expect
(
timestamps
).
toHaveLength
(
3
)
expect
(
timestamps
[
1
]
-
timestamps
[
0
]).
toBeGreaterThanOrEqual
(
950
)
expect
(
timestamps
[
1
]
-
timestamps
[
0
]).
toBeLessThan
(
2100
)
expect
(
timestamps
[
2
]
-
timestamps
[
1
]).
toBeGreaterThanOrEqual
(
950
)
expect
(
timestamps
[
2
]
-
timestamps
[
1
]).
toBeLessThan
(
2100
)
})
it
(
'
Anthropic 不同代理出口并行执行
'
,
async
()
=>
{
const
timestamps
:
Record
<
string
,
number
>
=
{}
const
makeTracked
=
(
key
:
string
)
=>
async
()
=>
{
timestamps
[
key
]
=
Date
.
now
()
return
key
}
const
acc1
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
,
username
:
'
u1
'
})
const
acc2
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
5.6.7.8
'
,
port
:
3128
,
username
:
'
u2
'
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeTracked
(
'
proxy1
'
))
const
p2
=
enqueueUsageRequest
(
acc2
,
makeTracked
(
'
proxy2
'
))
await
Promise
.
all
([
p1
,
p2
])
const
spread
=
Math
.
abs
(
timestamps
[
'
proxy1
'
]
-
timestamps
[
'
proxy2
'
])
expect
(
spread
).
toBeLessThan
(
50
)
})
it
(
'
Anthropic 相同代理连接信息的不同账号归为同一队列
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
const
acc1
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
10.0.0.1
'
,
port
:
3128
,
username
:
'
admin
'
})
const
acc2
=
makeAccount
(
'
anthropic
'
,
'
setup-token
'
,
{
host
:
'
10.0.0.1
'
,
port
:
3128
,
username
:
'
admin
'
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
])
expect
(
timestamps
).
toHaveLength
(
2
)
expect
(
timestamps
[
1
]
-
timestamps
[
0
]).
toBeGreaterThanOrEqual
(
950
)
})
it
(
'
Anthropic 直连(无代理)的账号归为同一队列
'
,
async
()
=>
{
const
order
:
number
[]
=
[]
const
makeFn
=
(
n
:
number
)
=>
async
()
=>
{
order
.
push
(
n
)
return
n
}
const
acc1
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
)
const
acc2
=
makeAccount
(
'
anthropic
'
,
'
setup-token
'
)
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
(
1
))
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
(
2
))
await
Promise
.
all
([
p1
,
p2
])
expect
(
order
).
toEqual
([
1
,
2
])
})
it
(
'
Anthropic 请求失败时 reject,后续任务继续执行
'
,
async
()
=>
{
const
results
:
string
[]
=
[]
const
acc
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
,
{
host
:
'
99.99.99.99
'
,
port
:
1234
})
const
p1
=
enqueueUsageRequest
(
acc
,
async
()
=>
{
throw
new
Error
(
'
fail
'
)
})
const
p2
=
enqueueUsageRequest
(
acc
,
async
()
=>
{
results
.
push
(
'
second
'
)
return
'
ok
'
})
await
expect
(
p1
).
rejects
.
toThrow
(
'
fail
'
)
await
p2
expect
(
results
).
toEqual
([
'
second
'
])
})
// ─── 非 Anthropic 平台:直接执行,不排队 ───
it
(
'
非 Anthropic 平台直接执行,不排队
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
// 同一代理的 Gemini 账号 — 应当并行,不排队
const
acc1
=
makeAccount
(
'
gemini
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
acc2
=
makeAccount
(
'
gemini
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
])
expect
(
timestamps
).
toHaveLength
(
2
)
// 并行执行,几乎同时完成
expect
(
Math
.
abs
(
timestamps
[
1
]
-
timestamps
[
0
])).
toBeLessThan
(
50
)
})
it
(
'
OpenAI 平台直接执行,不排队
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
const
acc1
=
makeAccount
(
'
openai
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
acc2
=
makeAccount
(
'
openai
'
,
'
oauth
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
])
expect
(
timestamps
).
toHaveLength
(
2
)
expect
(
Math
.
abs
(
timestamps
[
1
]
-
timestamps
[
0
])).
toBeLessThan
(
50
)
})
// ─── Anthropic apikey 类型不排队 ───
it
(
'
Anthropic apikey 类型直接执行,不排队
'
,
async
()
=>
{
const
timestamps
:
number
[]
=
[]
const
makeFn
=
()
=>
async
()
=>
{
timestamps
.
push
(
Date
.
now
())
return
'
ok
'
}
const
acc1
=
makeAccount
(
'
anthropic
'
,
'
apikey
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
acc2
=
makeAccount
(
'
anthropic
'
,
'
apikey
'
,
{
host
:
'
1.2.3.4
'
,
port
:
8080
})
const
p1
=
enqueueUsageRequest
(
acc1
,
makeFn
())
const
p2
=
enqueueUsageRequest
(
acc2
,
makeFn
())
await
Promise
.
all
([
p1
,
p2
])
expect
(
timestamps
).
toHaveLength
(
2
)
expect
(
Math
.
abs
(
timestamps
[
1
]
-
timestamps
[
0
])).
toBeLessThan
(
50
)
})
// ─── 返回值透传 ───
it
(
'
返回值正确透传
'
,
async
()
=>
{
const
acc
=
makeAccount
(
'
anthropic
'
,
'
oauth
'
)
const
result
=
await
enqueueUsageRequest
(
acc
,
async
()
=>
{
return
{
usage
:
42
}
})
expect
(
result
).
toEqual
({
usage
:
42
})
})
it
(
'
非 Anthropic 返回值正确透传
'
,
async
()
=>
{
const
acc
=
makeAccount
(
'
gemini
'
,
'
oauth
'
)
const
result
=
await
enqueueUsageRequest
(
acc
,
async
()
=>
{
return
{
quota
:
100
}
})
expect
(
result
).
toEqual
({
quota
:
100
})
})
})
frontend/src/utils/featureFlags.ts
0 → 100644
View file @
ac114738
/**
* Feature flag registry — single source of truth for public-settings-driven
* feature switches used by the sidebar, routes, and views.
*
* ## Why this module exists
*
* `public settings` reach the frontend through two channels:
*
* 1. **SSR injection** — the backend embeds `window.__APP_CONFIG__` into the
* HTML. `main.ts` calls `appStore.initFromInjectedConfig()` synchronously
* before Vue mounts, so `cachedPublicSettings` is populated on first
* render.
* 2. **Async API** — `App.vue` awaits `appStore.fetchPublicSettings()` on
* mount as a fallback (used when injection is missing or stale).
*
* If the SSR injection struct forgets to include a feature flag field — the
* exact bug that hid the "可用渠道" menu after every refresh — the frontend
* reads `undefined` until the async call resolves. An opt-in flag written as
* `settings?.xxx_enabled === true` then evaluates to `false` and the menu
* disappears. An opt-out flag written as `settings?.xxx_enabled !== false`
* evaluates to `true` (menu stays) but will flicker off if the backend sends
* `false`.
*
* This module hides that `undefined` handling behind two explicit modes.
*
* ## Modes
*
* - **`opt-out`** (default enabled) — menu visible when settings unloaded,
* hidden only when the backend explicitly sends `false`. Use for features
* that ship enabled by default (Channel Monitor, Payment).
* - **`opt-in`** (default disabled) — menu hidden when settings unloaded,
* visible only when the backend explicitly sends `true`. Use for features
* that ship disabled (Available Channels).
*
* For `opt-in` flags to render immediately on refresh, the backend **must**
* inject the field through `PublicSettingsInjectionPayload`. A drift test in
* `backend/internal/handler/dto/public_settings_injection_schema_test.go`
* catches omissions.
*
* ## Adding a new flag
*
* 1. Backend `service/domain_constants.go` → `SettingKey<Name>Enabled`
* 2. Backend `service/settings_view.go` → `PublicSettings` + `SystemSettings`
* 3. Backend `service/setting_service.go` → `GetPublicSettings` / `UpdateSettings` /
* `GetAllSettings` / `InitDefaultSettings` /
* **`PublicSettingsInjectionPayload`**
* (the drift test enforces this)
* 4. Backend `handler/dto/settings.go` → `PublicSettings` + `SystemSettings`
* 5. Backend `handler/setting_handler.go` → handler response
* 6. Backend `handler/admin/setting_handler.go` → update request + audit diff
* 7. Frontend `types/index.ts` → `PublicSettings` typings
* 8. Frontend `api/admin/settings.ts` → admin DTO typings
* 9. **Frontend `utils/featureFlags.ts` (this file)** → register via `defineFlag`
* 10. Frontend `views/admin/SettingsView.vue` → Toggle UI + form defaults + save payload
* 11. Frontend `components/layout/AppSidebar.vue` → attach via `makeSidebarFlag`
*
* ## Usage
*
* ```ts
* import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
*
* const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
* // ...
* { path: '/available-channels', label: ..., featureFlag: flagAvailableChannels }
* ```
*
* `isFeatureFlagEnabled(flag)` returns the resolved boolean (`true` = show).
* `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with
* `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry.
*/
import
{
useAppStore
}
from
'
@/stores/app
'
import
type
{
PublicSettings
}
from
'
@/types
'
export
type
FeatureFlagMode
=
'
opt-in
'
|
'
opt-out
'
export
interface
FeatureFlagDefinition
{
/** Public-settings key used for lookup. */
readonly
key
:
keyof
PublicSettings
/** Resolution mode when the key is missing/undefined. */
readonly
mode
:
FeatureFlagMode
/** Short human label for logs and debug tooling. */
readonly
label
:
string
}
function
defineFlag
<
K
extends
keyof
PublicSettings
>
(
def
:
{
key
:
K
;
mode
:
FeatureFlagMode
;
label
:
string
},
):
FeatureFlagDefinition
{
return
def
}
/**
* Registered feature flags. Add a new entry here when introducing a new
* public-settings-driven switch; see the "Adding a new flag" checklist above.
*/
export
const
FeatureFlags
=
{
channelMonitor
:
defineFlag
({
key
:
'
channel_monitor_enabled
'
,
mode
:
'
opt-out
'
,
label
:
'
Channel Monitor
'
,
}),
availableChannels
:
defineFlag
({
key
:
'
available_channels_enabled
'
,
mode
:
'
opt-in
'
,
label
:
'
Available Channels
'
,
}),
payment
:
defineFlag
({
key
:
'
payment_enabled
'
,
mode
:
'
opt-out
'
,
label
:
'
Payment
'
,
}),
}
as
const
export
type
RegisteredFeatureFlag
=
keyof
typeof
FeatureFlags
/**
* Read the current value of a flag, honoring the mode's fallback.
* `true` → the feature is enabled (menu/route should render).
* `false` → the feature is disabled (menu/route should hide).
*/
export
function
isFeatureFlagEnabled
(
flag
:
FeatureFlagDefinition
):
boolean
{
const
appStore
=
useAppStore
()
const
raw
=
appStore
.
cachedPublicSettings
?.[
flag
.
key
]
as
|
boolean
|
undefined
if
(
typeof
raw
===
'
boolean
'
)
return
raw
// Settings not yet loaded → fall back to the flag's declared mode:
// opt-out → visible by default, opt-in → hidden by default.
return
flag
.
mode
===
'
opt-out
'
}
/**
* Sidebar NavItem.featureFlag accepts a getter that returns
* `false` to hide. Keeping the same contract lets callers swap in
* registry-backed flags without changing AppSidebar's filter logic.
*/
export
function
makeSidebarFlag
(
flag
:
FeatureFlagDefinition
):
()
=>
boolean
{
return
()
=>
isFeatureFlagEnabled
(
flag
)
}
frontend/src/utils/maskApiKey.ts
0 → 100644
View file @
ac114738
// Mask an API key for display: reveals first 6 + last 4; short keys (≤12) show `first 4 + ***`.
export
function
maskApiKey
(
key
:
string
):
string
{
if
(
!
key
)
return
''
if
(
key
.
length
<=
12
)
return
`
${
key
.
slice
(
0
,
4
)}
***`
return
`
${
key
.
slice
(
0
,
6
)}
...
${
key
.
slice
(
-
4
)}
`
}
frontend/src/utils/pricing.ts
0 → 100644
View file @
ac114738
/**
* formatScaled formats a per-token (or per-request) USD price scaled by `scale`.
*
* formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens
* formatScaled(0.5, 1) → "$0.5" // per request
* formatScaled(null, 1_000_000) → "-"
*
* Uses toPrecision(10) then strips trailing zeros to avoid IEEE 754 display noise.
*/
export
function
formatScaled
(
value
:
number
|
null
,
scale
:
number
):
string
{
if
(
value
==
null
)
return
'
-
'
return
`$
${(
value
*
scale
).
toPrecision
(
10
).
replace
(
/
\.?
0+$/
,
''
)}
`
}
frontend/src/views/admin/ChannelMonitorView.vue
0 → 100644
View file @
ac114738
<
template
>
<AppLayout>
<TablePageLayout>
<template
#filters
>
<MonitorFiltersBar
v-model:search=
"searchQuery"
v-model:provider=
"providerFilter"
v-model:enabled=
"enabledFilter"
:loading=
"loading"
@
reload=
"reload"
@
create=
"openCreateDialog"
@
manage-templates=
"showTemplateManager = true"
@
search-input=
"handleSearch"
/>
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"monitors"
:loading=
"loading"
>
<template
#cell-name
="
{ row, value }">
<div
class=
"flex items-center gap-1.5"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
<HelpTooltip
v-if=
"row.api_key_decrypt_failed"
:content=
"t('admin.channelMonitor.apiKeyDecryptFailed')"
>
<Icon
name=
"exclamationTriangle"
size=
"sm"
class=
"text-red-500"
/>
</HelpTooltip>
</div>
</
template
>
<
template
#cell-provider=
"{ row }"
>
<span
class=
"inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium"
:class=
"providerBadgeClass(row.provider)"
>
{{
providerLabel
(
row
.
provider
)
}}
</span>
</
template
>
<
template
#cell-primary_model=
"{ row }"
>
<MonitorPrimaryModelCell
:row=
"row"
/>
</
template
>
<
template
#cell-availability_7d=
"{ row }"
>
<span
class=
"text-sm text-gray-900 dark:text-gray-100"
>
{{
formatAvailability
(
row
)
}}
</span>
</
template
>
<
template
#cell-latency=
"{ row }"
>
<span
class=
"text-sm text-gray-900 dark:text-gray-100"
>
{{
formatLatency
(
row
.
primary_latency_ms
)
}}
</span>
</
template
>
<
template
#cell-enabled=
"{ row }"
>
<Toggle
:modelValue=
"row.enabled"
@
update:modelValue=
"toggleEnabled(row)"
/>
</
template
>
<
template
#cell-actions=
"{ row }"
>
<MonitorActionsCell
:row=
"row"
:running=
"runningId === row.id"
@
run=
"handleRunNow"
@
edit=
"openEditDialog"
@
delete=
"handleDelete"
/>
</
template
>
<
template
#empty
>
<EmptyState
:title=
"t('admin.channelMonitor.noMonitorsYet')"
:description=
"t('admin.channelMonitor.createFirstMonitor')"
:action-text=
"t('admin.channelMonitor.createButton')"
@
action=
"openCreateDialog"
/>
</
template
>
</DataTable>
</template>
<
template
#pagination
>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"onPageChange"
@
update:pageSize=
"onPageSizeChange"
/>
</
template
>
</TablePageLayout>
<MonitorFormDialog
:show=
"showDialog"
:monitor=
"editing"
@
close=
"closeDialog"
@
saved=
"reload"
/>
<MonitorTemplateManagerDialog
:show=
"showTemplateManager"
@
close=
"showTemplateManager = false"
@
updated=
"reload"
/>
<MonitorRunResultDialog
:show=
"showRunResult"
:results=
"runResults"
@
close=
"showRunResult = false"
/>
<ConfirmDialog
:show=
"showDeleteDialog"
:title=
"t('common.delete')"
:message=
"deleteConfirmMessage"
:confirm-text=
"t('common.delete')"
:cancel-text=
"t('common.cancel')"
:danger=
"true"
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
/>
</AppLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
ChannelMonitor
,
CheckResult
,
ListParams
,
Provider
,
}
from
'
@/api/admin/channelMonitor
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
HelpTooltip
from
'
@/components/common/HelpTooltip.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
MonitorFiltersBar
from
'
@/components/admin/monitor/MonitorFiltersBar.vue
'
import
MonitorFormDialog
from
'
@/components/admin/monitor/MonitorFormDialog.vue
'
import
MonitorTemplateManagerDialog
from
'
@/components/admin/monitor/MonitorTemplateManagerDialog.vue
'
import
MonitorRunResultDialog
from
'
@/components/admin/monitor/MonitorRunResultDialog.vue
'
import
MonitorPrimaryModelCell
from
'
@/components/admin/monitor/MonitorPrimaryModelCell.vue
'
import
MonitorActionsCell
from
'
@/components/admin/monitor/MonitorActionsCell.vue
'
import
{
getPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
import
{
useChannelMonitorFormat
}
from
'
@/composables/useChannelMonitorFormat
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
{
providerLabel
,
providerBadgeClass
,
formatLatency
,
formatAvailability
,
}
=
useChannelMonitorFormat
()
const
monitors
=
ref
<
ChannelMonitor
[]
>
([])
const
loading
=
ref
(
false
)
const
runningId
=
ref
<
number
|
null
>
(
null
)
const
searchQuery
=
ref
(
''
)
const
providerFilter
=
ref
<
Provider
|
''
>
(
''
)
const
enabledFilter
=
ref
<
''
|
'
true
'
|
'
false
'
>
(
''
)
const
pagination
=
reactive
({
page
:
1
,
page_size
:
getPersistedPageSize
(),
total
:
0
})
const
showDialog
=
ref
(
false
)
const
showTemplateManager
=
ref
(
false
)
const
editing
=
ref
<
ChannelMonitor
|
null
>
(
null
)
const
showDeleteDialog
=
ref
(
false
)
const
deleting
=
ref
<
ChannelMonitor
|
null
>
(
null
)
const
showRunResult
=
ref
(
false
)
const
runResults
=
ref
<
CheckResult
[]
>
([])
let
abortController
:
AbortController
|
null
=
null
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
name
'
,
label
:
t
(
'
admin.channelMonitor.columns.name
'
),
sortable
:
false
},
{
key
:
'
provider
'
,
label
:
t
(
'
admin.channelMonitor.columns.provider
'
),
sortable
:
false
},
{
key
:
'
primary_model
'
,
label
:
t
(
'
admin.channelMonitor.columns.primaryModel
'
),
sortable
:
false
},
{
key
:
'
availability_7d
'
,
label
:
t
(
'
admin.channelMonitor.columns.availability7d
'
),
sortable
:
false
},
{
key
:
'
latency
'
,
label
:
t
(
'
admin.channelMonitor.columns.latency
'
),
sortable
:
false
},
{
key
:
'
enabled
'
,
label
:
t
(
'
admin.channelMonitor.columns.enabled
'
),
sortable
:
false
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.channelMonitor.columns.actions
'
),
sortable
:
false
},
])
const
deleteConfirmMessage
=
computed
(()
=>
{
const
name
=
deleting
.
value
?.
name
||
''
return
t
(
'
admin.channelMonitor.deleteConfirm
'
,
{
name
})
})
async
function
reload
()
{
if
(
abortController
)
abortController
.
abort
()
const
ctrl
=
new
AbortController
()
abortController
=
ctrl
loading
.
value
=
true
try
{
const
params
:
ListParams
=
{
page
:
pagination
.
page
,
page_size
:
pagination
.
page_size
,
}
if
(
providerFilter
.
value
)
params
.
provider
=
providerFilter
.
value
if
(
enabledFilter
.
value
===
'
true
'
)
params
.
enabled
=
true
if
(
enabledFilter
.
value
===
'
false
'
)
params
.
enabled
=
false
if
(
searchQuery
.
value
.
trim
())
params
.
search
=
searchQuery
.
value
.
trim
()
const
res
=
await
adminAPI
.
channelMonitor
.
list
(
params
,
{
signal
:
ctrl
.
signal
})
if
(
ctrl
.
signal
.
aborted
||
abortController
!==
ctrl
)
return
monitors
.
value
=
res
.
items
||
[]
pagination
.
total
=
res
.
total
}
catch
(
err
:
unknown
)
{
const
e
=
err
as
{
name
?:
string
;
code
?:
string
}
if
(
e
?.
name
===
'
AbortError
'
||
e
?.
code
===
'
ERR_CANCELED
'
)
return
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
admin.channelMonitor.loadError
'
)))
}
finally
{
if
(
abortController
===
ctrl
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
function
handleSearch
()
{
if
(
searchTimeout
)
clearTimeout
(
searchTimeout
)
searchTimeout
=
setTimeout
(()
=>
{
pagination
.
page
=
1
reload
()
},
300
)
}
function
onPageChange
(
page
:
number
)
{
pagination
.
page
=
page
reload
()
}
function
onPageSizeChange
(
size
:
number
)
{
pagination
.
page_size
=
size
pagination
.
page
=
1
reload
()
}
function
openCreateDialog
()
{
editing
.
value
=
null
showDialog
.
value
=
true
}
function
openEditDialog
(
row
:
ChannelMonitor
)
{
editing
.
value
=
row
showDialog
.
value
=
true
}
function
closeDialog
()
{
showDialog
.
value
=
false
editing
.
value
=
null
}
async
function
toggleEnabled
(
row
:
ChannelMonitor
)
{
const
next
=
!
row
.
enabled
try
{
await
adminAPI
.
channelMonitor
.
update
(
row
.
id
,
{
enabled
:
next
})
row
.
enabled
=
next
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
}
async
function
handleRunNow
(
row
:
ChannelMonitor
)
{
if
(
runningId
.
value
!=
null
)
return
runningId
.
value
=
row
.
id
try
{
const
res
=
await
adminAPI
.
channelMonitor
.
runNow
(
row
.
id
)
runResults
.
value
=
res
.
results
||
[]
showRunResult
.
value
=
true
appStore
.
showSuccess
(
t
(
'
admin.channelMonitor.runSuccess
'
))
// Refresh row to get latest status from backend
void
reload
()
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
admin.channelMonitor.runFailed
'
)))
}
finally
{
runningId
.
value
=
null
}
}
function
handleDelete
(
row
:
ChannelMonitor
)
{
deleting
.
value
=
row
showDeleteDialog
.
value
=
true
}
async
function
confirmDelete
()
{
if
(
!
deleting
.
value
)
return
try
{
await
adminAPI
.
channelMonitor
.
del
(
deleting
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.channelMonitor.deleteSuccess
'
))
showDeleteDialog
.
value
=
false
deleting
.
value
=
null
reload
()
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
}
onMounted
(
reload
)
onUnmounted
(()
=>
{
if
(
searchTimeout
)
clearTimeout
(
searchTimeout
)
abortController
?.
abort
()
})
</
script
>
frontend/src/views/admin/SettingsView.vue
View file @
ac114738
...
...
@@ -3767,6 +3767,94 @@
</div>
<!-- /Tab: General -->
<!-- Tab: Features (功能开关) -->
<div v-show="activeTab === 'features'" class="space-y-6">
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.channelMonitor.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/monitor"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.channelMonitor.configureLink') }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.channelMonitor.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.enabledHint') }}
</p>
</div>
<Toggle v-model="form.channel_monitor_enabled" />
</div>
<div v-if="form.channel_monitor_enabled">
<label class="input-label">
{{ t('admin.settings.features.channelMonitor.defaultInterval') }}
<span class="text-red-500">*</span>
</label>
<input
v-model.number="form.channel_monitor_default_interval_seconds"
type="number"
min="15"
max="3600"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.channelMonitor.defaultIntervalHint') }}
</p>
</div>
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.availableChannels.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.availableChannels.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/pricing"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.availableChannels.configureLink') }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.availableChannels.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.availableChannels.enabledHint') }}
</p>
</div>
<Toggle v-model="form.available_channels_enabled" />
</div>
</div>
</div>
</div><!-- /Tab: Features -->
<!-- Tab: Email -->
<!-- Tab: Payment -->
<div v-show="activeTab === 'payment'" class="space-y-6">
...
...
@@ -4755,6 +4843,7 @@ const paymentMethodsHref = computed(() =>
type SettingsTab =
| "general"
| "features"
| "security"
| "users"
| "gateway"
...
...
@@ -4764,6 +4853,7 @@ type SettingsTab =
const activeTab = ref<SettingsTab>("general");
const settingsTabs = [
{ key: "general" as SettingsTab, icon: "home" as const },
{ key: "features" as SettingsTab, icon: "bolt" as const },
{ key: "security" as SettingsTab, icon: "shield" as const },
{ key: "users" as SettingsTab, icon: "user" as const },
{ key: "gateway" as SettingsTab, icon: "server" as const },
...
...
@@ -5024,6 +5114,11 @@ const form = reactive<SettingsForm>({
balance_low_notify_recharge_url: "",
account_quota_notify_enabled: false,
account_quota_notify_emails: [] as NotifyEmailEntry[],
// Channel Monitor feature switch
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
// Available Channels feature switch
available_channels_enabled: false,
});
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
...
...
@@ -5932,6 +6027,12 @@ async function saveSettings() {
account_quota_notify_emails: (
form.account_quota_notify_emails || []
).filter((e) => e.email.trim() !== ""),
// Channel Monitor feature switch
channel_monitor_enabled: form.channel_monitor_enabled,
channel_monitor_default_interval_seconds:
Number(form.channel_monitor_default_interval_seconds) || 60,
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
};
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
...
...
frontend/src/views/user/AvailableChannelsView.vue
0 → 100644
View file @
ac114738
<
template
>
<AppLayout>
<TablePageLayout>
<template
#filters
>
<div
class=
"flex flex-col justify-between gap-4 lg:flex-row lg:items-start"
>
<div
class=
"flex flex-1 flex-wrap items-center gap-3"
>
<div
class=
"relative w-full sm:w-80"
>
<Icon
name=
"search"
size=
"md"
class=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('availableChannels.searchPlaceholder')"
class=
"input pl-10"
/>
</div>
</div>
<div
class=
"flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto"
>
<button
@
click=
"loadChannels"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh', 'Refresh')"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
</div>
</div>
</
template
>
<
template
#table
>
<AvailableChannelsTable
:columns=
"columnLabels"
:rows=
"filteredChannels"
:loading=
"loading"
:user-group-rates=
"userGroupRates"
pricing-key-prefix=
"availableChannels.pricing"
:no-pricing-label=
"t('availableChannels.noPricing')"
:no-models-label=
"t('availableChannels.noModels')"
:empty-label=
"t('availableChannels.empty')"
/>
</
template
>
</TablePageLayout>
</AppLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
AvailableChannelsTable
from
'
@/components/channels/AvailableChannelsTable.vue
'
import
userChannelsAPI
,
{
type
UserAvailableChannel
}
from
'
@/api/channels
'
import
userGroupsAPI
from
'
@/api/groups
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
channels
=
ref
<
UserAvailableChannel
[]
>
([])
const
userGroupRates
=
ref
<
Record
<
number
,
number
>>
({})
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
columnLabels
=
computed
(()
=>
({
name
:
t
(
'
availableChannels.columns.name
'
),
description
:
t
(
'
availableChannels.columns.description
'
),
platform
:
t
(
'
availableChannels.columns.platform
'
),
groups
:
t
(
'
availableChannels.columns.groups
'
),
supportedModels
:
t
(
'
availableChannels.columns.supportedModels
'
),
}))
/**
* 搜索过滤:
* - 命中渠道名/描述 → 整个渠道(所有 platforms)都保留
* - 否则按 platform/group/model 维度在 sections 里过滤,保留有匹配的 section
* - 所有 sections 都不匹配时,渠道本身被过滤掉
*/
const
filteredChannels
=
computed
(()
=>
{
const
q
=
searchQuery
.
value
.
trim
().
toLowerCase
()
if
(
!
q
)
return
channels
.
value
return
channels
.
value
.
map
((
ch
)
=>
{
const
nameHit
=
ch
.
name
.
toLowerCase
().
includes
(
q
)
const
descHit
=
(
ch
.
description
||
''
).
toLowerCase
().
includes
(
q
)
if
(
nameHit
||
descHit
)
return
ch
const
matchingSections
=
ch
.
platforms
.
filter
(
(
p
)
=>
p
.
platform
.
toLowerCase
().
includes
(
q
)
||
p
.
groups
.
some
((
g
)
=>
g
.
name
.
toLowerCase
().
includes
(
q
))
||
p
.
supported_models
.
some
((
m
)
=>
m
.
name
.
toLowerCase
().
includes
(
q
)),
)
if
(
matchingSections
.
length
===
0
)
return
null
return
{
...
ch
,
platforms
:
matchingSections
}
})
.
filter
((
ch
):
ch
is
UserAvailableChannel
=>
ch
!==
null
)
})
async
function
loadChannels
()
{
loading
.
value
=
true
try
{
// 渠道列表和用户专属倍率并发拉取。专属倍率失败不阻塞渠道展示——
// 失败时只是无法渲染专属倍率角标,降级为仅显示默认倍率。
const
[
list
,
rates
]
=
await
Promise
.
all
([
userChannelsAPI
.
getAvailable
(),
userGroupsAPI
.
getUserGroupRates
().
catch
((
err
:
unknown
)
=>
{
console
.
error
(
'
Failed to load user group rates:
'
,
err
)
return
{}
as
Record
<
number
,
number
>
}),
])
channels
.
value
=
list
userGroupRates
.
value
=
rates
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
loading
.
value
=
false
}
}
onMounted
(
loadChannels
)
</
script
>
frontend/src/views/user/ChannelStatusView.vue
0 → 100644
View file @
ac114738
<
template
>
<AppLayout>
<MonitorHero
:overall-status=
"overallStatus"
:interval-seconds=
"DEFAULT_INTERVAL_SECONDS"
:window=
"currentWindow"
:loading=
"loading"
:auto-refresh=
"autoRefresh"
@
update:window=
"handleWindowChange"
@
refresh=
"manualReload"
/>
<MonitorCardGrid
:items=
"items"
:window=
"currentWindow"
:countdown-seconds=
"countdown"
:loading=
"loading"
:detail-cache=
"detailCache"
@
card-click=
"openDetail"
/>
<MonitorDetailDialog
:show=
"showDetail"
:monitor-id=
"detailTarget?.id ?? null"
:title=
"detailTitle"
@
close=
"closeDetail"
/>
</AppLayout>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onBeforeUnmount
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
list
as
listChannelMonitorViews
,
status
as
fetchChannelMonitorDetail
,
type
UserMonitorView
,
type
UserMonitorDetail
,
}
from
'
@/api/channelMonitor
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
MonitorHero
,
{
type
MonitorWindow
,
type
OverallStatus
,
}
from
'
@/components/user/monitor/MonitorHero.vue
'
import
MonitorCardGrid
from
'
@/components/user/monitor/MonitorCardGrid.vue
'
import
MonitorDetailDialog
from
'
@/components/user/MonitorDetailDialog.vue
'
import
{
DEFAULT_INTERVAL_SECONDS
,
STATUS_OPERATIONAL
}
from
'
@/constants/channelMonitor
'
import
{
useAutoRefresh
}
from
'
@/composables/useAutoRefresh
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
// ── State ──
const
items
=
ref
<
UserMonitorView
[]
>
([])
const
loading
=
ref
(
false
)
const
currentWindow
=
ref
<
MonitorWindow
>
(
'
7d
'
)
const
detailCache
=
reactive
<
Record
<
number
,
UserMonitorDetail
>>
({})
const
showDetail
=
ref
(
false
)
const
detailTarget
=
ref
<
UserMonitorView
|
null
>
(
null
)
let
abortController
:
AbortController
|
null
=
null
const
autoRefresh
=
useAutoRefresh
({
storageKey
:
'
channel-status-auto-refresh
'
,
intervals
:
[
30
,
60
,
120
]
as
const
,
defaultInterval
:
DEFAULT_INTERVAL_SECONDS
,
onRefresh
:
()
=>
reload
(
true
),
shouldPause
:
()
=>
document
.
hidden
||
loading
.
value
,
})
const
countdown
=
autoRefresh
.
countdown
// ── Computed ──
const
overallStatus
=
computed
<
OverallStatus
>
(()
=>
{
if
(
items
.
value
.
length
===
0
)
return
'
operational
'
for
(
const
it
of
items
.
value
)
{
if
(
it
.
primary_status
===
'
failed
'
||
it
.
primary_status
===
'
error
'
)
return
'
degraded
'
if
(
it
.
primary_status
!==
STATUS_OPERATIONAL
)
return
'
degraded
'
}
return
'
operational
'
})
const
detailTitle
=
computed
(()
=>
{
return
detailTarget
.
value
?.
name
||
t
(
'
channelStatus.detailTitle
'
)
})
// ── Loaders ──
async
function
reload
(
silent
=
false
)
{
if
(
abortController
)
abortController
.
abort
()
const
ctrl
=
new
AbortController
()
abortController
=
ctrl
if
(
!
silent
)
loading
.
value
=
true
try
{
const
res
=
await
listChannelMonitorViews
({
signal
:
ctrl
.
signal
})
if
(
ctrl
.
signal
.
aborted
||
abortController
!==
ctrl
)
return
items
.
value
=
res
.
items
||
[]
}
catch
(
err
:
unknown
)
{
const
e
=
err
as
{
name
?:
string
;
code
?:
string
}
if
(
e
?.
name
===
'
AbortError
'
||
e
?.
code
===
'
ERR_CANCELED
'
)
return
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
channelStatus.loadError
'
)))
}
finally
{
if
(
abortController
===
ctrl
)
{
if
(
!
silent
)
loading
.
value
=
false
countdown
.
value
=
DEFAULT_INTERVAL_SECONDS
abortController
=
null
}
}
}
async
function
manualReload
()
{
await
reload
(
false
)
// After base reload, refresh any cached detail records so non-7d availability
// values stay in sync without forcing the user to switch tabs again.
if
(
currentWindow
.
value
!==
'
7d
'
)
{
await
Promise
.
all
(
items
.
value
.
map
(
it
=>
loadDetail
(
it
.
id
,
true
)))
}
}
async
function
loadDetail
(
id
:
number
,
force
=
false
)
{
if
(
!
force
&&
detailCache
[
id
])
return
try
{
detailCache
[
id
]
=
await
fetchChannelMonitorDetail
(
id
)
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
channelStatus.detailLoadError
'
)))
}
}
async
function
ensureDetailsForWindow
()
{
if
(
currentWindow
.
value
===
'
7d
'
)
return
await
Promise
.
all
(
items
.
value
.
map
(
it
=>
loadDetail
(
it
.
id
)))
}
// ── Handlers ──
async
function
handleWindowChange
(
value
:
MonitorWindow
)
{
currentWindow
.
value
=
value
await
ensureDetailsForWindow
()
}
function
openDetail
(
row
:
UserMonitorView
)
{
detailTarget
.
value
=
row
showDetail
.
value
=
true
}
function
closeDetail
()
{
showDetail
.
value
=
false
detailTarget
.
value
=
null
}
watch
(
items
,
()
=>
{
void
ensureDetailsForWindow
()
})
watch
(
()
=>
appStore
.
cachedPublicSettings
?.
channel_monitor_enabled
,
(
enabled
)
=>
{
if
(
enabled
===
false
)
autoRefresh
.
stop
()
else
if
(
autoRefresh
.
enabled
.
value
)
autoRefresh
.
start
()
},
)
onMounted
(()
=>
{
void
reload
(
false
)
if
(
appStore
.
cachedPublicSettings
?.
channel_monitor_enabled
!==
false
)
{
autoRefresh
.
setEnabled
(
true
)
}
})
onBeforeUnmount
(()
=>
{
if
(
abortController
)
abortController
.
abort
()
})
</
script
>
frontend/src/views/user/KeysView.vue
View file @
ac114738
...
...
@@ -61,7 +61,7 @@
<template
#cell-key
="
{ value, row }">
<div
class=
"flex items-center gap-2"
>
<code
class=
"code text-xs"
>
{{
maskKey
(
value
)
}}
{{
mask
Api
Key
(
value
)
}}
</code>
<button
@
click=
"copyToClipboard(value, row.id)"
...
...
@@ -1072,6 +1072,7 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import
type
{
Column
}
from
'
@/components/common/types
'
import
type
{
BatchApiKeyUsageStats
}
from
'
@/api/usage
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
{
maskApiKey
}
from
'
@/utils/maskApiKey
'
// Helper to format date for datetime-local input
const
formatDateTimeLocal
=
(
isoDate
:
string
):
string
=>
{
...
...
@@ -1260,11 +1261,6 @@ const filteredGroupOptions = computed(() => {
})
})
const
maskKey
=
(
key
:
string
):
string
=>
{
if
(
key
.
length
<=
12
)
return
key
return
`
${
key
.
slice
(
0
,
8
)}
...
${
key
.
slice
(
-
4
)}
`
}
const
copyToClipboard
=
async
(
text
:
string
,
keyId
:
number
)
=>
{
const
success
=
await
clipboardCopy
(
text
,
t
(
'
keys.copied
'
))
if
(
success
)
{
...
...
Prev
1
…
4
5
6
7
8
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