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
fb313356
Commit
fb313356
authored
Jan 05, 2026
by
yangjianbo
Browse files
Merge branch 'main' into test-dev
parents
048ed061
91f9d4c7
Changes
84
Expand all
Hide whitespace changes
Inline
Side-by-side
deploy/docker-compose.yml
View file @
fb313356
...
@@ -28,6 +28,8 @@ services:
...
@@ -28,6 +28,8 @@ services:
volumes
:
volumes
:
# Data persistence (config.yaml will be auto-generated here)
# Data persistence (config.yaml will be auto-generated here)
-
sub2api_data:/app/data
-
sub2api_data:/app/data
# Mount custom config.yaml (optional, overrides auto-generated config)
-
./config.yaml:/app/data/config.yaml:ro
environment
:
environment
:
# =======================================================================
# =======================================================================
# Auto Setup (REQUIRED for Docker deployment)
# Auto Setup (REQUIRED for Docker deployment)
...
@@ -91,6 +93,13 @@ services:
...
@@ -91,6 +93,13 @@ services:
-
GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
-
GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
-
GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
-
GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
-
GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
-
GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
# =======================================================================
# Security Configuration (URL Allowlist)
# =======================================================================
-
SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
# Allow private IP addresses for CRS sync (for internal deployments)
-
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
depends_on
:
depends_on
:
postgres
:
postgres
:
condition
:
service_healthy
condition
:
service_healthy
...
...
frontend/.npmrc
0 → 100644
View file @
fb313356
legacy-peer-deps=true
# 允许运行所有包的构建脚本
# esbuild 和 vue-demi 是已知安全的包,需要 postinstall 脚本才能正常工作
ignore-scripts=false
frontend/package-lock.json
deleted
100644 → 0
View file @
048ed061
This diff is collapsed.
Click to expand it.
frontend/pnpm-lock.yaml
View file @
fb313356
This diff is collapsed.
Click to expand it.
frontend/src/api/admin/settings.ts
View file @
fb313356
...
@@ -34,6 +34,9 @@ export interface SystemSettings {
...
@@ -34,6 +34,9 @@ export interface SystemSettings {
turnstile_enabled
:
boolean
turnstile_enabled
:
boolean
turnstile_site_key
:
string
turnstile_site_key
:
string
turnstile_secret_key_configured
:
boolean
turnstile_secret_key_configured
:
boolean
// Identity patch configuration (Claude -> Gemini)
enable_identity_patch
:
boolean
identity_patch_prompt
:
string
}
}
export
interface
UpdateSettingsRequest
{
export
interface
UpdateSettingsRequest
{
...
@@ -57,6 +60,8 @@ export interface UpdateSettingsRequest {
...
@@ -57,6 +60,8 @@ export interface UpdateSettingsRequest {
turnstile_enabled
?:
boolean
turnstile_enabled
?:
boolean
turnstile_site_key
?:
string
turnstile_site_key
?:
string
turnstile_secret_key
?:
string
turnstile_secret_key
?:
string
enable_identity_patch
?:
boolean
identity_patch_prompt
?:
string
}
}
/**
/**
...
...
frontend/src/api/client.ts
View file @
fb313356
...
@@ -5,6 +5,7 @@
...
@@ -5,6 +5,7 @@
import
axios
,
{
AxiosInstance
,
AxiosError
,
InternalAxiosRequestConfig
}
from
'
axios
'
import
axios
,
{
AxiosInstance
,
AxiosError
,
InternalAxiosRequestConfig
}
from
'
axios
'
import
type
{
ApiResponse
}
from
'
@/types
'
import
type
{
ApiResponse
}
from
'
@/types
'
import
{
getLocale
}
from
'
@/i18n
'
// ==================== Axios Instance Configuration ====================
// ==================== Axios Instance Configuration ====================
...
@@ -27,6 +28,12 @@ apiClient.interceptors.request.use(
...
@@ -27,6 +28,12 @@ apiClient.interceptors.request.use(
if
(
token
&&
config
.
headers
)
{
if
(
token
&&
config
.
headers
)
{
config
.
headers
.
Authorization
=
`Bearer
${
token
}
`
config
.
headers
.
Authorization
=
`Bearer
${
token
}
`
}
}
// Attach locale for backend translations
if
(
config
.
headers
)
{
config
.
headers
[
'
Accept-Language
'
]
=
getLocale
()
}
return
config
return
config
},
},
(
error
)
=>
{
(
error
)
=>
{
...
...
frontend/src/components/account/AccountStatusIndicator.vue
View file @
fb313356
...
@@ -5,7 +5,7 @@
...
@@ -5,7 +5,7 @@
v-if=
"isTempUnschedulable"
v-if=
"isTempUnschedulable"
type=
"button"
type=
"button"
:class=
"['badge text-xs', statusClass, 'cursor-pointer']"
:class=
"['badge text-xs', statusClass, 'cursor-pointer']"
:title=
"t('admin.accounts.
tempUnschedulable.view
Details')"
:title=
"t('admin.accounts.
status.viewTempUnsched
Details')"
@
click=
"handleTempUnschedClick"
@
click=
"handleTempUnschedClick"
>
>
{{
statusText
}}
{{
statusText
}}
...
@@ -61,7 +61,7 @@
...
@@ -61,7 +61,7 @@
<div
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
>
R
ate
l
imited
u
ntil
{{
formatTime
(
account
.
rate_limit_reset_at
)
}}
{{
t
(
'
admin.accounts.status.r
ate
L
imited
U
ntil
'
,
{
time
:
formatTime
(
account
.
rate_limit_reset_at
)
}
)
}}
<
div
<
div
class
=
"
absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700
"
class
=
"
absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700
"
><
/div
>
><
/div
>
...
@@ -86,7 +86,7 @@
...
@@ -86,7 +86,7 @@
<
div
<
div
class
=
"
pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700
"
class
=
"
pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700
"
>
>
O
verloaded
u
ntil
{{
formatTime
(
account
.
overload_until
)
}}
{{
t
(
'
admin.accounts.status.o
verloaded
U
ntil
'
,
{
time
:
formatTime
(
account
.
overload_until
)
}
)
}}
<
div
<
div
class
=
"
absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700
"
class
=
"
absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700
"
><
/div
>
><
/div
>
...
@@ -160,7 +160,7 @@ const statusClass = computed(() => {
...
@@ -160,7 +160,7 @@ const statusClass = computed(() => {
// Computed: status text
// Computed: status text
const
statusText
=
computed
(()
=>
{
const
statusText
=
computed
(()
=>
{
if
(
hasError
.
value
)
{
if
(
hasError
.
value
)
{
return
t
(
'
common
.error
'
)
return
t
(
'
admin.accounts.status
.error
'
)
}
}
if
(
isTempUnschedulable
.
value
)
{
if
(
isTempUnschedulable
.
value
)
{
return
t
(
'
admin.accounts.status.tempUnschedulable
'
)
return
t
(
'
admin.accounts.status.tempUnschedulable
'
)
...
@@ -171,7 +171,7 @@ const statusText = computed(() => {
...
@@ -171,7 +171,7 @@ const statusText = computed(() => {
if
(
isRateLimited
.
value
||
isOverloaded
.
value
)
{
if
(
isRateLimited
.
value
||
isOverloaded
.
value
)
{
return
t
(
'
admin.accounts.status.limited
'
)
return
t
(
'
admin.accounts.status.limited
'
)
}
}
return
t
(
`
common
.
${
props
.
account
.
status
}
`
)
return
t
(
`
admin.accounts.status
.${props.account.status
}
`
)
}
)
}
)
const
handleTempUnschedClick
=
()
=>
{
const
handleTempUnschedClick
=
()
=>
{
...
@@ -179,4 +179,4 @@ const handleTempUnschedClick = () => {
...
@@ -179,4 +179,4 @@ const handleTempUnschedClick = () => {
emit
(
'
show-temp-unsched
'
,
props
.
account
)
emit
(
'
show-temp-unsched
'
,
props
.
account
)
}
}
</
script
>
<
/script>
\ No newline at end of file
frontend/src/components/account/AccountTestModal.vue
View file @
fb313356
...
@@ -48,21 +48,18 @@
...
@@ -48,21 +48,18 @@
</span>
</span>
</div>
</div>
<!-- Model Selection -->
<div
class=
"space-y-1.5"
>
<div
class=
"space-y-1.5"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.selectTestModel
'
)
}}
{{
t
(
'
admin.accounts.selectTestModel
'
)
}}
</label>
</label>
<
s
elect
<
S
elect
v-model=
"selectedModelId"
v-model=
"selectedModelId"
:options=
"availableModels"
:disabled=
"loadingModels || status === 'connecting'"
:disabled=
"loadingModels || status === 'connecting'"
class=
"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100"
value-key=
"id"
>
label-key=
"display_name"
<option
v-if=
"loadingModels"
value=
""
>
{{
t
(
'
common.loading
'
)
}}
...
</option>
:placeholder=
"loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
<option
v-for=
"model in availableModels"
:key=
"model.id"
:value=
"model.id"
>
/>
{{
model
.
display_name
}}
(
{{
model
.
id
}}
)
</option>
</select>
</div>
</div>
<!-- Terminal Output -->
<!-- Terminal Output -->
...
@@ -280,6 +277,7 @@
...
@@ -280,6 +277,7 @@
import
{
ref
,
watch
,
nextTick
}
from
'
vue
'
import
{
ref
,
watch
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
ClaudeModel
}
from
'
@/types
'
import
type
{
Account
,
ClaudeModel
}
from
'
@/types
'
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
fb313356
...
@@ -1522,7 +1522,7 @@
...
@@ -1522,7 +1522,7 @@
<
/ul
>
<
/ul
>
<
div
class
=
"
mt-2 flex flex-wrap gap-2
"
>
<
div
class
=
"
mt-2 flex flex-wrap gap-2
"
>
<
a
<
a
href
=
"
https://
gemini
.google.com/
faq#location
"
href
=
"
https://
policies
.google.com/
terms
"
target
=
"
_blank
"
target
=
"
_blank
"
rel
=
"
noreferrer
"
rel
=
"
noreferrer
"
class
=
"
text-sm text-blue-600 hover:underline dark:text-blue-400
"
class
=
"
text-sm text-blue-600 hover:underline dark:text-blue-400
"
...
@@ -1531,7 +1531,16 @@
...
@@ -1531,7 +1531,16 @@
<
/a
>
<
/a
>
<
span
class
=
"
text-gray-400
"
>
·
<
/span
>
<
span
class
=
"
text-gray-400
"
>
·
<
/span
>
<
a
<
a
href
=
"
https://gemini.google.com
"
href
=
"
https://policies.google.com/country-association-form
"
target
=
"
_blank
"
rel
=
"
noreferrer
"
class
=
"
text-sm text-blue-600 hover:underline dark:text-blue-400
"
>
修改归属地
<
/a
>
<
span
class
=
"
text-gray-400
"
>
·
<
/span
>
<
a
href
=
"
https://gemini.google.com/gems/create?hl=en-US&pli=1
"
target
=
"
_blank
"
target
=
"
_blank
"
rel
=
"
noreferrer
"
rel
=
"
noreferrer
"
class
=
"
text-sm text-blue-600 hover:underline dark:text-blue-400
"
class
=
"
text-sm text-blue-600 hover:underline dark:text-blue-400
"
...
@@ -1869,8 +1878,9 @@ const geminiHelpLinks = {
...
@@ -1869,8 +1878,9 @@ const geminiHelpLinks = {
apiKey
:
'
https://aistudio.google.com/app/apikey
'
,
apiKey
:
'
https://aistudio.google.com/app/apikey
'
,
aiStudioPricing
:
'
https://ai.google.dev/pricing
'
,
aiStudioPricing
:
'
https://ai.google.dev/pricing
'
,
gcpProject
:
'
https://console.cloud.google.com/welcome/new
'
,
gcpProject
:
'
https://console.cloud.google.com/welcome/new
'
,
geminiWebActivation
:
'
https://gemini.google.com/gems/create?hl=en-US
'
,
geminiWebActivation
:
'
https://gemini.google.com/gems/create?hl=en-US&pli=1
'
,
countryCheck
:
'
https://policies.google.com/country-association-form
'
countryCheck
:
'
https://policies.google.com/terms
'
,
countryChange
:
'
https://policies.google.com/country-association-form
'
}
}
// Computed: current preset mappings based on platform
// Computed: current preset mappings based on platform
...
...
frontend/src/components/admin/account/AccountActionMenu.vue
0 → 100644
View file @
fb313356
<
template
>
<Teleport
to=
"body"
>
<div
v-if=
"show && position"
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"
:style=
"
{ top: position.top + 'px', left: position.left + 'px' }">
<div
class=
"py-1"
>
<template
v-if=
"account"
>
<button
@
click=
"$emit('test', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"
><span
class=
"text-green-500"
>
▶
</span>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
</button>
<button
@
click=
"$emit('stats', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"
><span
class=
"text-indigo-500"
>
📊
</span>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
</button>
<template
v-if=
"account.type === 'oauth' || account.type === 'setup-token'"
>
<button
@
click=
"$emit('reauth', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 text-blue-600"
>
🔗
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
</button>
<button
@
click=
"$emit('refresh-token', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 text-purple-600"
>
🔄
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
</button>
</
template
>
</template>
</div>
</div>
</Teleport>
</template>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
([
'
show
'
,
'
account
'
,
'
position
'
]);
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
]);
const
{
t
}
=
useI18n
()
</
script
>
\ No newline at end of file
frontend/src/components/admin/account/AccountBulkActionsBar.vue
0 → 100644
View file @
fb313356
<
template
>
<div
v-if=
"selectedIds.length > 0"
class=
"mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg"
>
<span
class=
"text-sm font-medium"
>
{{
t
(
'
admin.accounts.bulkActions.selected
'
,
{
count
:
selectedIds
.
length
}
)
}}
<
/span
>
<
div
class
=
"
flex gap-2
"
>
<
button
@
click
=
"
$emit('delete')
"
class
=
"
btn btn-danger btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.delete
'
)
}}
<
/button
>
<
button
@
click
=
"
$emit('edit')
"
class
=
"
btn btn-primary btn-sm
"
>
{{
t
(
'
admin.accounts.bulkActions.edit
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
([
'
selectedIds
'
]);
defineEmits
([
'
delete
'
,
'
edit
'
]);
const
{
t
}
=
useI18n
()
<
/script>
\ No newline at end of file
frontend/src/components/admin/account/AccountStatsModal.vue
0 → 100644
View file @
fb313356
This diff is collapsed.
Click to expand it.
frontend/src/components/admin/account/AccountTableActions.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"flex max-w-full flex-wrap justify-end gap-3"
>
<button
@
click=
"$emit('refresh')"
:disabled=
"loading"
class=
"btn btn-secondary flex-shrink-0"
><svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
><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></button>
<button
@
click=
"$emit('sync')"
class=
"btn btn-secondary flex-shrink-0"
>
{{
t
(
'
admin.accounts.syncFromCrs
'
)
}}
</button>
<button
@
click=
"$emit('create')"
class=
"btn btn-primary flex-shrink-0"
>
{{
t
(
'
admin.accounts.createAccount
'
)
}}
</button>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
;
defineProps
([
'
loading
'
]);
defineEmits
([
'
refresh
'
,
'
sync
'
,
'
create
'
]);
const
{
t
}
=
useI18n
()
</
script
>
frontend/src/components/admin/account/AccountTableFilters.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"flex flex-wrap items-start gap-3"
>
<div
class=
"min-w-0 flex-1"
>
<SearchInput
:model-value=
"searchQuery"
:placeholder=
"t('admin.accounts.searchAccounts')"
@
update:model-value=
"$emit('update:searchQuery', $event)"
@
search=
"$emit('change')"
/>
</div>
<div
class=
"flex flex-wrap items-center gap-3"
>
<Select
v-model=
"filters.platform"
class=
"w-40 flex-shrink-0"
:options=
"pOpts"
@
change=
"$emit('change')"
/>
<Select
v-model=
"filters.status"
class=
"w-40 flex-shrink-0"
:options=
"sOpts"
@
change=
"$emit('change')"
/>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
Select
from
'
@/components/common/Select.vue
'
;
import
SearchInput
from
'
@/components/common/SearchInput.vue
'
defineProps
([
'
searchQuery
'
,
'
filters
'
]);
defineEmits
([
'
update:searchQuery
'
,
'
change
'
]);
const
{
t
}
=
useI18n
()
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
}])
</
script
>
frontend/src/components/admin/account/AccountTestModal.vue
0 → 100644
View file @
fb313356
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.accounts.testAccountConnection')"
width=
"normal"
@
close=
"handleClose"
>
<div
class=
"space-y-4"
>
<!-- Account Info Card -->
<div
v-if=
"account"
class=
"flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg
class=
"h-5 w-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<div
class=
"font-semibold text-gray-900 dark:text-gray-100"
>
{{
account
.
name
}}
</div>
<div
class=
"flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span
class=
"rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{
account
.
type
}}
</span>
<span>
{{
t
(
'
admin.accounts.account
'
)
}}
</span>
</div>
</div>
</div>
<span
:class=
"[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{
account
.
status
}}
</span>
</div>
<div
class=
"space-y-1.5"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.selectTestModel
'
)
}}
</label>
<Select
v-model=
"selectedModelId"
:options=
"availableModels"
:disabled=
"loadingModels || status === 'connecting'"
value-key=
"id"
label-key=
"display_name"
:placeholder=
"loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<!-- Terminal Output -->
<div
class=
"group relative"
>
<div
ref=
"terminalRef"
class=
"max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
>
<!-- Status Line -->
<div
v-if=
"status === 'idle'"
class=
"flex items-center gap-2 text-gray-500"
>
<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=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span>
{{
t
(
'
admin.accounts.readyToTest
'
)
}}
</span>
</div>
<div
v-else-if=
"status === 'connecting'"
class=
"flex items-center gap-2 text-yellow-400"
>
<svg
class=
"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>
<span>
{{
t
(
'
admin.accounts.connectingToApi
'
)
}}
</span>
</div>
<!-- Output Lines -->
<div
v-for=
"(line, index) in outputLines"
:key=
"index"
:class=
"line.class"
>
{{
line
.
text
}}
</div>
<!-- Streaming Content -->
<div
v-if=
"streamingContent"
class=
"text-green-400"
>
{{
streamingContent
}}
<span
class=
"animate-pulse"
>
_
</span>
</div>
<!-- Result Status -->
<div
v-if=
"status === 'success'"
class=
"mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<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=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{{
t
(
'
admin.accounts.testCompleted
'
)
}}
</span>
</div>
<div
v-else-if=
"status === 'error'"
class=
"mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<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=
"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{{
errorMessage
}}
</span>
</div>
</div>
<!-- Copy Button -->
<button
v-if=
"outputLines.length > 0"
@
click=
"copyOutput"
class=
"absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title=
"t('admin.accounts.copyOutput')"
>
<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=
"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
<!-- Test Info -->
<div
class=
"flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400"
>
<div
class=
"flex items-center gap-3"
>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{{
t
(
'
admin.accounts.testModel
'
)
}}
</span>
</div>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
{{
t
(
'
admin.accounts.testPrompt
'
)
}}
</span>
</div>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"handleClose"
class=
"rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled=
"status === 'connecting'"
>
{{
t
(
'
common.close
'
)
}}
</button>
<button
@
click=
"startTest"
:disabled=
"status === 'connecting' || !selectedModelId"
:class=
"[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
: status === 'error'
? 'bg-orange-500 text-white hover:bg-orange-600'
: 'bg-primary-500 text-white hover:bg-primary-600'
]"
>
<svg
v-if=
"status === 'connecting'"
class=
"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>
<svg
v-else-if=
"status === 'idle'"
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>
<svg
v-else
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=
"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>
{{
status
===
'
connecting
'
?
t
(
'
admin.accounts.testing
'
)
:
status
===
'
idle
'
?
t
(
'
admin.accounts.startTest
'
)
:
t
(
'
admin.accounts.retry
'
)
}}
</span>
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
ClaudeModel
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
const
{
copyToClipboard
}
=
useClipboard
()
interface
OutputLine
{
text
:
string
class
:
string
}
const
props
=
defineProps
<
{
show
:
boolean
account
:
Account
|
null
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
close
'
):
void
}
>
()
const
terminalRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
status
=
ref
<
'
idle
'
|
'
connecting
'
|
'
success
'
|
'
error
'
>
(
'
idle
'
)
const
outputLines
=
ref
<
OutputLine
[]
>
([])
const
streamingContent
=
ref
(
''
)
const
errorMessage
=
ref
(
''
)
const
availableModels
=
ref
<
ClaudeModel
[]
>
([])
const
selectedModelId
=
ref
(
''
)
const
loadingModels
=
ref
(
false
)
let
eventSource
:
EventSource
|
null
=
null
// Load available models when modal opens
watch
(
()
=>
props
.
show
,
async
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
resetState
()
await
loadAvailableModels
()
}
else
{
closeEventSource
()
}
}
)
const
loadAvailableModels
=
async
()
=>
{
if
(
!
props
.
account
)
return
loadingModels
.
value
=
true
selectedModelId
.
value
=
''
// Reset selection before loading
try
{
availableModels
.
value
=
await
adminAPI
.
accounts
.
getAvailableModels
(
props
.
account
.
id
)
// Default selection by platform
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
// Try to select Sonnet as default, otherwise use first model
const
sonnetModel
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
.
includes
(
'
sonnet
'
))
selectedModelId
.
value
=
sonnetModel
?.
id
||
availableModels
.
value
[
0
].
id
}
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load available models:
'
,
error
)
// Fallback to empty list
availableModels
.
value
=
[]
selectedModelId
.
value
=
''
}
finally
{
loadingModels
.
value
=
false
}
}
const
resetState
=
()
=>
{
status
.
value
=
'
idle
'
outputLines
.
value
=
[]
streamingContent
.
value
=
''
errorMessage
.
value
=
''
}
const
handleClose
=
()
=>
{
// 防止在连接测试进行中关闭对话框
if
(
status
.
value
===
'
connecting
'
)
{
return
}
closeEventSource
()
emit
(
'
close
'
)
}
const
closeEventSource
=
()
=>
{
if
(
eventSource
)
{
eventSource
.
close
()
eventSource
=
null
}
}
const
addLine
=
(
text
:
string
,
className
:
string
=
'
text-gray-300
'
)
=>
{
outputLines
.
value
.
push
({
text
,
class
:
className
})
scrollToBottom
()
}
const
scrollToBottom
=
async
()
=>
{
await
nextTick
()
if
(
terminalRef
.
value
)
{
terminalRef
.
value
.
scrollTop
=
terminalRef
.
value
.
scrollHeight
}
}
const
startTest
=
async
()
=>
{
if
(
!
props
.
account
||
!
selectedModelId
.
value
)
return
resetState
()
status
.
value
=
'
connecting
'
addLine
(
t
(
'
admin.accounts.startingTestForAccount
'
,
{
name
:
props
.
account
.
name
}),
'
text-blue-400
'
)
addLine
(
t
(
'
admin.accounts.testAccountTypeLabel
'
,
{
type
:
props
.
account
.
type
}),
'
text-gray-400
'
)
addLine
(
''
,
'
text-gray-300
'
)
closeEventSource
()
try
{
// Create EventSource for SSE
const
url
=
`/api/v1/admin/accounts/
${
props
.
account
.
id
}
/test`
// Use fetch with streaming for SSE since EventSource doesn't support POST
const
response
=
await
fetch
(
url
,
{
method
:
'
POST
'
,
headers
:
{
Authorization
:
`Bearer
${
localStorage
.
getItem
(
'
auth_token
'
)}
`
,
'
Content-Type
'
:
'
application/json
'
},
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
})
})
if
(
!
response
.
ok
)
{
throw
new
Error
(
`HTTP error! status:
${
response
.
status
}
`
)
}
const
reader
=
response
.
body
?.
getReader
()
if
(
!
reader
)
{
throw
new
Error
(
'
No response body
'
)
}
const
decoder
=
new
TextDecoder
()
let
buffer
=
''
while
(
true
)
{
const
{
done
,
value
}
=
await
reader
.
read
()
if
(
done
)
break
buffer
+=
decoder
.
decode
(
value
,
{
stream
:
true
})
const
lines
=
buffer
.
split
(
'
\n
'
)
buffer
=
lines
.
pop
()
||
''
for
(
const
line
of
lines
)
{
if
(
line
.
startsWith
(
'
data:
'
))
{
const
jsonStr
=
line
.
slice
(
6
).
trim
()
if
(
jsonStr
)
{
try
{
const
event
=
JSON
.
parse
(
jsonStr
)
handleEvent
(
event
)
}
catch
(
e
)
{
console
.
error
(
'
Failed to parse SSE event:
'
,
e
)
}
}
}
}
}
}
catch
(
error
:
any
)
{
status
.
value
=
'
error
'
errorMessage
.
value
=
error
.
message
||
'
Unknown error
'
addLine
(
`Error:
${
errorMessage
.
value
}
`
,
'
text-red-400
'
)
}
}
const
handleEvent
=
(
event
:
{
type
:
string
text
?:
string
model
?:
string
success
?:
boolean
error
?:
string
})
=>
{
switch
(
event
.
type
)
{
case
'
test_start
'
:
addLine
(
t
(
'
admin.accounts.connectedToApi
'
),
'
text-green-400
'
)
if
(
event
.
model
)
{
addLine
(
t
(
'
admin.accounts.usingModel
'
,
{
model
:
event
.
model
}),
'
text-cyan-400
'
)
}
addLine
(
t
(
'
admin.accounts.sendingTestMessage
'
),
'
text-gray-400
'
)
addLine
(
''
,
'
text-gray-300
'
)
addLine
(
t
(
'
admin.accounts.response
'
),
'
text-yellow-400
'
)
break
case
'
content
'
:
if
(
event
.
text
)
{
streamingContent
.
value
+=
event
.
text
scrollToBottom
()
}
break
case
'
test_complete
'
:
// Move streaming content to output lines
if
(
streamingContent
.
value
)
{
addLine
(
streamingContent
.
value
,
'
text-green-300
'
)
streamingContent
.
value
=
''
}
if
(
event
.
success
)
{
status
.
value
=
'
success
'
}
else
{
status
.
value
=
'
error
'
errorMessage
.
value
=
event
.
error
||
'
Test failed
'
}
break
case
'
error
'
:
status
.
value
=
'
error
'
errorMessage
.
value
=
event
.
error
||
'
Unknown error
'
if
(
streamingContent
.
value
)
{
addLine
(
streamingContent
.
value
,
'
text-green-300
'
)
streamingContent
.
value
=
''
}
break
}
}
const
copyOutput
=
()
=>
{
const
text
=
outputLines
.
value
.
map
((
l
)
=>
l
.
text
).
join
(
'
\n
'
)
copyToClipboard
(
text
,
t
(
'
admin.accounts.outputCopied
'
))
}
</
script
>
frontend/src/components/admin/account/ReAuthAccountModal.vue
0 → 100644
View file @
fb313356
This diff is collapsed.
Click to expand it.
frontend/src/components/admin/usage/UsageExportProgress.vue
0 → 100644
View file @
fb313356
<
template
>
<ExportProgressDialog
:show=
"show"
:progress=
"progress"
:current=
"current"
:total=
"total"
:estimated-time=
"estimatedTime"
@
cancel=
"$emit('cancel')"
/>
</
template
>
<
script
setup
lang=
"ts"
>
import
ExportProgressDialog
from
'
@/components/common/ExportProgressDialog.vue
'
defineProps
<
{
show
:
boolean
,
progress
:
number
,
current
:
number
,
total
:
number
,
estimatedTime
:
string
}
>
()
defineEmits
([
'
cancel
'
])
</
script
>
\ No newline at end of file
frontend/src/components/admin/usage/UsageFilters.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"card p-6"
>
<!-- Toolbar: left filters (multi-line) + right actions -->
<div
class=
"flex flex-wrap items-end justify-between gap-4"
>
<!-- Left: filters (allowed to wrap to multiple rows) -->
<div
class=
"flex flex-1 flex-wrap items-end gap-4"
>
<!-- User Search -->
<div
ref=
"userSearchRef"
class=
"usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.usage.userFilter
'
)
}}
</label>
<input
v-model=
"userKeyword"
type=
"text"
class=
"input pr-8"
:placeholder=
"t('admin.usage.searchUserPlaceholder')"
@
input=
"debounceUserSearch"
@
focus=
"showUserDropdown = true"
/>
<button
v-if=
"filters.user_id"
type=
"button"
@
click=
"clearUser"
class=
"absolute right-2 top-9 text-gray-400"
aria-label=
"Clear user filter"
>
✕
</button>
<div
v-if=
"showUserDropdown && (userResults.length > 0 || userKeyword)"
class=
"absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for=
"u in userResults"
:key=
"u.id"
type=
"button"
@
click=
"selectUser(u)"
class=
"w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span>
{{
u
.
email
}}
</span>
<span
class=
"ml-2 text-xs text-gray-400"
>
#
{{
u
.
id
}}
</span>
</button>
</div>
</div>
<!-- API Key Search -->
<div
ref=
"apiKeySearchRef"
class=
"usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.apiKeyFilter
'
)
}}
</label>
<input
v-model=
"apiKeyKeyword"
type=
"text"
class=
"input pr-8"
:placeholder=
"t('admin.usage.searchApiKeyPlaceholder')"
@
input=
"debounceApiKeySearch"
@
focus=
"showApiKeyDropdown = true"
/>
<button
v-if=
"filters.api_key_id"
type=
"button"
@
click=
"onClearApiKey"
class=
"absolute right-2 top-9 text-gray-400"
aria-label=
"Clear API key filter"
>
✕
</button>
<div
v-if=
"showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
class=
"absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for=
"k in apiKeyResults"
:key=
"k.id"
type=
"button"
@
click=
"selectApiKey(k)"
class=
"w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span
class=
"truncate"
>
{{
k
.
name
||
`#${k.id
}
`
}}
<
/span
>
<
span
class
=
"
ml-2 text-xs text-gray-400
"
>
#
{{
k
.
id
}}
<
/span
>
<
/button
>
<
/div
>
<
/div
>
<!--
Model
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[220px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.model
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.model
"
:
options
=
"
modelOptions
"
searchable
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Account
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[220px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.account
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.account_id
"
:
options
=
"
accountOptions
"
searchable
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Stream
Type
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[180px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.type
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.stream
"
:
options
=
"
streamTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Billing
Type
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[180px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.billingType
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.billing_type
"
:
options
=
"
billingTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Group
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.group
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.group_id
"
:
options
=
"
groupOptions
"
searchable
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Date
Range
Filter
-->
<
div
class
=
"
w-full sm:w-auto [&_.date-picker-trigger]:w-full
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.timeRange
'
)
}}
<
/label
>
<
DateRangePicker
:
start
-
date
=
"
startDate
"
:
end
-
date
=
"
endDate
"
@
update
:
startDate
=
"
updateStartDate
"
@
update
:
endDate
=
"
updateEndDate
"
@
change
=
"
emitChange
"
/>
<
/div
>
<
/div
>
<!--
Right
:
actions
-->
<
div
class
=
"
flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto
"
>
<
button
type
=
"
button
"
@
click
=
"
$emit('reset')
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.reset
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
$emit('export')
"
:
disabled
=
"
exporting
"
class
=
"
btn btn-primary
"
>
{{
t
(
'
usage.exportExcel
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
onMounted
,
onUnmounted
,
toRef
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
Select
,
{
type
SelectOption
}
from
'
@/components/common/Select.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
type
{
SimpleApiKey
,
SimpleUser
}
from
'
@/api/admin/usage
'
type
ModelValue
=
Record
<
string
,
any
>
interface
Props
{
modelValue
:
ModelValue
exporting
:
boolean
startDate
:
string
endDate
:
string
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
([
'
update:modelValue
'
,
'
update:startDate
'
,
'
update:endDate
'
,
'
change
'
,
'
reset
'
,
'
export
'
])
const
{
t
}
=
useI18n
()
const
filters
=
toRef
(
props
,
'
modelValue
'
)
const
userSearchRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
apiKeySearchRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
userKeyword
=
ref
(
''
)
const
userResults
=
ref
<
SimpleUser
[]
>
([])
const
showUserDropdown
=
ref
(
false
)
let
userSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
apiKeyKeyword
=
ref
(
''
)
const
apiKeyResults
=
ref
<
SimpleApiKey
[]
>
([])
const
showApiKeyDropdown
=
ref
(
false
)
let
apiKeySearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
modelOptions
=
ref
<
SelectOption
[]
>
([{
value
:
null
,
label
:
t
(
'
admin.usage.allModels
'
)
}
])
const
groupOptions
=
ref
<
SelectOption
[]
>
([{
value
:
null
,
label
:
t
(
'
admin.usage.allGroups
'
)
}
])
const
accountOptions
=
ref
<
SelectOption
[]
>
([{
value
:
null
,
label
:
t
(
'
admin.usage.allAccounts
'
)
}
])
const
streamTypeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allTypes
'
)
}
,
{
value
:
true
,
label
:
t
(
'
usage.stream
'
)
}
,
{
value
:
false
,
label
:
t
(
'
usage.sync
'
)
}
])
const
billingTypeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allBillingTypes
'
)
}
,
{
value
:
1
,
label
:
t
(
'
usage.subscription
'
)
}
,
{
value
:
0
,
label
:
t
(
'
usage.balance
'
)
}
])
const
emitChange
=
()
=>
emit
(
'
change
'
)
const
updateStartDate
=
(
value
:
string
)
=>
{
emit
(
'
update:startDate
'
,
value
)
filters
.
value
.
start_date
=
value
}
const
updateEndDate
=
(
value
:
string
)
=>
{
emit
(
'
update:endDate
'
,
value
)
filters
.
value
.
end_date
=
value
}
const
debounceUserSearch
=
()
=>
{
if
(
userSearchTimeout
)
clearTimeout
(
userSearchTimeout
)
userSearchTimeout
=
setTimeout
(
async
()
=>
{
if
(
!
userKeyword
.
value
)
{
userResults
.
value
=
[]
return
}
try
{
userResults
.
value
=
await
adminAPI
.
usage
.
searchUsers
(
userKeyword
.
value
)
}
catch
{
userResults
.
value
=
[]
}
}
,
300
)
}
const
debounceApiKeySearch
=
()
=>
{
if
(
apiKeySearchTimeout
)
clearTimeout
(
apiKeySearchTimeout
)
apiKeySearchTimeout
=
setTimeout
(
async
()
=>
{
if
(
!
apiKeyKeyword
.
value
)
{
apiKeyResults
.
value
=
[]
return
}
try
{
apiKeyResults
.
value
=
await
adminAPI
.
usage
.
searchApiKeys
(
filters
.
value
.
user_id
,
apiKeyKeyword
.
value
)
}
catch
{
apiKeyResults
.
value
=
[]
}
}
,
300
)
}
const
selectUser
=
(
u
:
SimpleUser
)
=>
{
userKeyword
.
value
=
u
.
email
showUserDropdown
.
value
=
false
filters
.
value
.
user_id
=
u
.
id
clearApiKey
()
emitChange
()
}
const
clearUser
=
()
=>
{
userKeyword
.
value
=
''
userResults
.
value
=
[]
showUserDropdown
.
value
=
false
filters
.
value
.
user_id
=
undefined
clearApiKey
()
emitChange
()
}
const
selectApiKey
=
(
k
:
SimpleApiKey
)
=>
{
apiKeyKeyword
.
value
=
k
.
name
||
String
(
k
.
id
)
showApiKeyDropdown
.
value
=
false
filters
.
value
.
api_key_id
=
k
.
id
emitChange
()
}
const
clearApiKey
=
()
=>
{
apiKeyKeyword
.
value
=
''
apiKeyResults
.
value
=
[]
showApiKeyDropdown
.
value
=
false
filters
.
value
.
api_key_id
=
undefined
}
const
onClearApiKey
=
()
=>
{
clearApiKey
()
emitChange
()
}
const
onDocumentClick
=
(
e
:
MouseEvent
)
=>
{
const
target
=
e
.
target
as
Node
|
null
if
(
!
target
)
return
const
clickedInsideUser
=
userSearchRef
.
value
?.
contains
(
target
)
??
false
const
clickedInsideApiKey
=
apiKeySearchRef
.
value
?.
contains
(
target
)
??
false
if
(
!
clickedInsideUser
)
showUserDropdown
.
value
=
false
if
(
!
clickedInsideApiKey
)
showApiKeyDropdown
.
value
=
false
}
watch
(
()
=>
props
.
startDate
,
(
value
)
=>
{
filters
.
value
.
start_date
=
value
}
,
{
immediate
:
true
}
)
watch
(
()
=>
props
.
endDate
,
(
value
)
=>
{
filters
.
value
.
end_date
=
value
}
,
{
immediate
:
true
}
)
watch
(
()
=>
filters
.
value
.
user_id
,
(
userId
)
=>
{
if
(
!
userId
)
{
userKeyword
.
value
=
''
userResults
.
value
=
[]
}
}
)
watch
(
()
=>
filters
.
value
.
api_key_id
,
(
apiKeyId
)
=>
{
if
(
!
apiKeyId
)
{
apiKeyKeyword
.
value
=
''
apiKeyResults
.
value
=
[]
}
}
)
onMounted
(
async
()
=>
{
document
.
addEventListener
(
'
click
'
,
onDocumentClick
)
try
{
const
[
gs
,
ms
,
as
]
=
await
Promise
.
all
([
adminAPI
.
groups
.
list
(
1
,
1000
),
adminAPI
.
dashboard
.
getModelStats
({
start_date
:
props
.
startDate
,
end_date
:
props
.
endDate
}
),
adminAPI
.
accounts
.
list
(
1
,
1000
)
])
groupOptions
.
value
.
push
(...
gs
.
items
.
map
((
g
:
any
)
=>
({
value
:
g
.
id
,
label
:
g
.
name
}
)))
accountOptions
.
value
.
push
(...
as
.
items
.
map
((
a
:
any
)
=>
({
value
:
a
.
id
,
label
:
a
.
name
}
)))
const
uniqueModels
=
new
Set
<
string
>
()
ms
.
models
?.
forEach
((
s
:
any
)
=>
s
.
model
&&
uniqueModels
.
add
(
s
.
model
))
modelOptions
.
value
.
push
(
...
Array
.
from
(
uniqueModels
)
.
sort
()
.
map
((
m
)
=>
({
value
:
m
,
label
:
m
}
))
)
}
catch
{
// Ignore filter option loading errors (page still usable)
}
}
)
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
onDocumentClick
)
}
)
<
/script
>
frontend/src/components/admin/usage/UsageStatsCards.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600"
><svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/></svg></div>
<div><p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalRequests
'
)
}}
</p><p
class=
"text-xl font-bold"
>
{{
stats
?.
total_requests
?.
toLocaleString
()
||
'
0
'
}}
</p></div>
</div>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"
><svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/></svg></div>
<div><p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalTokens
'
)
}}
</p><p
class=
"text-xl font-bold"
>
{{
formatTokens
(
stats
?.
total_tokens
||
0
)
}}
</p></div>
</div>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600"
><svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg></div>
<div><p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p><p
class=
"text-xl font-bold text-green-600"
>
$
{{
(
stats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</p></div>
</div>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600"
><svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg></div>
<div><p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.avgDuration
'
)
}}
</p><p
class=
"text-xl font-bold"
>
{{
formatDuration
(
stats
?.
average_duration_ms
||
0
)
}}
</p></div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
;
import
type
{
AdminUsageStatsResponse
}
from
'
@/api/admin/usage
'
defineProps
<
{
stats
:
AdminUsageStatsResponse
|
null
}
>
();
const
{
t
}
=
useI18n
()
const
formatDuration
=
(
ms
:
number
)
=>
ms
<
1000
?
`
${
ms
.
toFixed
(
0
)}
ms`
:
`
${(
ms
/
1000
).
toFixed
(
2
)}
s`
const
formatTokens
=
(
v
:
number
)
=>
{
if
(
v
>=
1
e9
)
return
(
v
/
1
e9
).
toFixed
(
2
)
+
'
B
'
;
if
(
v
>=
1
e6
)
return
(
v
/
1
e6
).
toFixed
(
2
)
+
'
M
'
;
if
(
v
>=
1
e3
)
return
(
v
/
1
e3
).
toFixed
(
2
)
+
'
K
'
;
return
v
.
toLocaleString
()
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/usage/UsageTable.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"card overflow-hidden"
><div
class=
"overflow-auto"
>
<DataTable
:columns=
"cols"
:data=
"data"
:loading=
"loading"
>
<template
#cell-user
="
{ row }">
<div
class=
"text-sm"
><span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
user
?.
email
||
'
-
'
}}
</span><span
class=
"ml-1 text-xs text-gray-400"
>
#
{{
row
.
user_id
}}
</span></div></
template
>
<
template
#cell-model=
"{ value }"
><span
class=
"font-medium"
>
{{
value
}}
</span></
template
>
<
template
#cell-tokens=
"{ row }"
><div
class=
"text-sm"
>
In:
{{
row
.
input_tokens
.
toLocaleString
()
}}
/ Out:
{{
row
.
output_tokens
.
toLocaleString
()
}}
</div></
template
>
<
template
#cell-cost=
"{ row }"
><span
class=
"font-medium text-green-600"
>
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
</span></
template
>
<
template
#cell-created_at=
"{ value }"
><span
class=
"text-sm text-gray-500"
>
{{
formatDateTime
(
value
)
}}
</span></
template
>
<
template
#empty
><EmptyState
:message=
"t('usage.noRecords')"
/></
template
>
</DataTable>
</div></div>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
formatDateTime
}
from
'
@/utils/format
'
;
import
DataTable
from
'
@/components/common/DataTable.vue
'
;
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
defineProps
([
'
data
'
,
'
loading
'
]);
const
{
t
}
=
useI18n
()
const
cols
=
computed
(()
=>
[
{
key
:
'
user
'
,
label
:
t
(
'
admin.usage.user
'
)
},
{
key
:
'
model
'
,
label
:
t
(
'
usage.model
'
),
sortable
:
true
},
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
)
},
{
key
:
'
cost
'
,
label
:
t
(
'
usage.cost
'
)
},
{
key
:
'
created_at
'
,
label
:
t
(
'
usage.time
'
),
sortable
:
true
}
])
</
script
>
\ No newline at end of file
Prev
1
2
3
4
5
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