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
bb399e56
Commit
bb399e56
authored
Mar 24, 2026
by
Wang Lvyuan
Browse files
merge: resolve upstream main conflicts for bulk OpenAI passthrough
parents
73d72651
0f033930
Changes
98
Show whitespace changes
Inline
Side-by-side
frontend/src/components/account/OAuthAuthorizationFlow.vue
View file @
bb399e56
...
...
@@ -48,6 +48,17 @@
t
(
getOAuthKey
(
'
refreshTokenAuth
'
))
}}
</span>
</label>
<label
v-if=
"showMobileRefreshTokenOption"
class=
"flex cursor-pointer items-center gap-2"
>
<input
v-model=
"inputMethod"
type=
"radio"
value=
"mobile_refresh_token"
class=
"text-blue-600 focus:ring-blue-500"
/>
<span
class=
"text-sm text-blue-900 dark:text-blue-200"
>
{{
t
(
'
admin.accounts.oauth.openai.mobileRefreshTokenAuth
'
,
'
手动输入 Mobile RT
'
)
}}
</span>
</label>
<label
v-if=
"showSessionTokenOption"
class=
"flex cursor-pointer items-center gap-2"
>
<input
v-model=
"inputMethod"
...
...
@@ -73,8 +84,8 @@
</div>
</div>
<!-- Refresh Token Input (OpenAI / Antigravity) -->
<div
v-if=
"inputMethod === 'refresh_token'"
class=
"space-y-4"
>
<!-- Refresh Token Input (OpenAI / Antigravity
/ Mobile RT
) -->
<div
v-if=
"inputMethod === 'refresh_token'
|| inputMethod === 'mobile_refresh_token'
"
class=
"space-y-4"
>
<div
class=
"rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
...
...
@@ -759,6 +770,7 @@ interface Props {
methodLabel
?:
string
showCookieOption
?:
boolean
// Whether to show cookie auto-auth option
showRefreshTokenOption
?:
boolean
// Whether to show refresh token input option (OpenAI only)
showMobileRefreshTokenOption
?:
boolean
// Whether to show mobile refresh token option (OpenAI only)
showSessionTokenOption
?:
boolean
// Whether to show session token input option (Sora only)
showAccessTokenOption
?:
boolean
// Whether to show access token input option (Sora only)
platform
?:
AccountPlatform
// Platform type for different UI/text
...
...
@@ -776,6 +788,7 @@ const props = withDefaults(defineProps<Props>(), {
methodLabel
:
'
Authorization Method
'
,
showCookieOption
:
true
,
showRefreshTokenOption
:
false
,
showMobileRefreshTokenOption
:
false
,
showSessionTokenOption
:
false
,
showAccessTokenOption
:
false
,
platform
:
'
anthropic
'
,
...
...
@@ -787,6 +800,7 @@ const emit = defineEmits<{
'
exchange-code
'
:
[
code
:
string
]
'
cookie-auth
'
:
[
sessionKey
:
string
]
'
validate-refresh-token
'
:
[
refreshToken
:
string
]
'
validate-mobile-refresh-token
'
:
[
refreshToken
:
string
]
'
validate-session-token
'
:
[
sessionToken
:
string
]
'
import-access-token
'
:
[
accessToken
:
string
]
'
update:inputMethod
'
:
[
method
:
AuthInputMethod
]
...
...
@@ -834,7 +848,7 @@ const oauthState = ref('')
const
projectId
=
ref
(
''
)
// Computed: show method selection when either cookie or refresh token option is enabled
const
showMethodSelection
=
computed
(()
=>
props
.
showCookieOption
||
props
.
showRefreshTokenOption
||
props
.
showSessionTokenOption
||
props
.
showAccessTokenOption
)
const
showMethodSelection
=
computed
(()
=>
props
.
showCookieOption
||
props
.
showRefreshTokenOption
||
props
.
showMobileRefreshTokenOption
||
props
.
showSessionTokenOption
||
props
.
showAccessTokenOption
)
// Clipboard
const
{
copied
,
copyToClipboard
}
=
useClipboard
()
...
...
@@ -945,8 +959,12 @@ const handleCookieAuth = () => {
const
handleValidateRefreshToken
=
()
=>
{
if
(
refreshTokenInput
.
value
.
trim
())
{
if
(
inputMethod
.
value
===
'
mobile_refresh_token
'
)
{
emit
(
'
validate-mobile-refresh-token
'
,
refreshTokenInput
.
value
.
trim
())
}
else
{
emit
(
'
validate-refresh-token
'
,
refreshTokenInput
.
value
.
trim
())
}
}
}
const
handleValidateSessionToken
=
()
=>
{
...
...
frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
View file @
bb399e56
...
...
@@ -149,6 +149,35 @@ describe('BulkEditAccountModal', () => {
})
})
it
(
'
OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段
'
,
async
()
=>
{
const
wrapper
=
mountModal
({
selectedPlatforms
:
[
'
openai
'
],
selectedTypes
:
[
'
oauth
'
]
})
await
wrapper
.
get
(
'
#bulk-edit-openai-ws-mode-enabled
'
).
setValue
(
true
)
await
wrapper
.
get
(
'
[data-testid="bulk-edit-openai-ws-mode-select"]
'
).
setValue
(
'
passthrough
'
)
await
wrapper
.
get
(
'
#bulk-edit-account-form
'
).
trigger
(
'
submit.prevent
'
)
await
flushPromises
()
expect
(
adminAPI
.
accounts
.
bulkUpdate
).
toHaveBeenCalledTimes
(
1
)
expect
(
adminAPI
.
accounts
.
bulkUpdate
).
toHaveBeenCalledWith
([
1
,
2
],
{
extra
:
{
openai_oauth_responses_websockets_v2_mode
:
'
passthrough
'
,
openai_oauth_responses_websockets_v2_enabled
:
true
}
})
})
it
(
'
OpenAI API Key 批量编辑不显示 WS mode 入口
'
,
()
=>
{
const
wrapper
=
mountModal
({
selectedPlatforms
:
[
'
openai
'
],
selectedTypes
:
[
'
apikey
'
]
})
expect
(
wrapper
.
find
(
'
#bulk-edit-openai-ws-mode-enabled
'
).
exists
()).
toBe
(
false
)
})
it
(
'
OpenAI 账号批量编辑可关闭自动透传
'
,
async
()
=>
{
const
wrapper
=
mountModal
({
selectedPlatforms
:
[
'
openai
'
],
...
...
frontend/src/components/admin/account/AccountTableFilters.vue
View file @
bb399e56
...
...
@@ -10,6 +10,7 @@
<Select
:model-value=
"filters.platform"
class=
"w-40"
:options=
"pOpts"
@
update:model-value=
"updatePlatform"
@
change=
"$emit('change')"
/>
<Select
:model-value=
"filters.type"
class=
"w-40"
:options=
"tOpts"
@
update:model-value=
"updateType"
@
change=
"$emit('change')"
/>
<Select
:model-value=
"filters.status"
class=
"w-40"
:options=
"sOpts"
@
update:model-value=
"updateStatus"
@
change=
"$emit('change')"
/>
<Select
:model-value=
"filters.privacy_mode"
class=
"w-40"
:options=
"privacyOpts"
@
update:model-value=
"updatePrivacyMode"
@
change=
"$emit('change')"
/>
<Select
:model-value=
"filters.group"
class=
"w-40"
:options=
"gOpts"
@
update:model-value=
"updateGroup"
@
change=
"$emit('change')"
/>
</div>
</
template
>
...
...
@@ -22,10 +23,18 @@ const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); co
const
updatePlatform
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
platform
:
value
})
}
const
updateType
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
type
:
value
})
}
const
updateStatus
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
status
:
value
})
}
const
updatePrivacyMode
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
privacy_mode
:
value
})
}
const
updateGroup
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
group
:
value
})
}
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
},
{
value
:
'
sora
'
,
label
:
'
Sora
'
}])
const
tOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
},
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
},
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
},
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
},
{
value
:
'
bedrock
'
,
label
:
'
AWS Bedrock
'
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
},
{
value
:
'
rate_limited
'
,
label
:
t
(
'
admin.accounts.status.rateLimited
'
)
},
{
value
:
'
temp_unschedulable
'
,
label
:
t
(
'
admin.accounts.status.tempUnschedulable
'
)
}])
const
privacyOpts
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allPrivacyModes
'
)
},
{
value
:
'
__unset__
'
,
label
:
t
(
'
admin.accounts.privacyUnset
'
)
},
{
value
:
'
training_off
'
,
label
:
'
Privacy
'
},
{
value
:
'
training_set_cf_blocked
'
,
label
:
'
CF
'
},
{
value
:
'
training_set_failed
'
,
label
:
'
Fail
'
}
])
const
gOpts
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allGroups
'
)
},
{
value
:
'
ungrouped
'
,
label
:
t
(
'
admin.accounts.ungroupedGroup
'
)
},
...
...
frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts
0 → 100644
View file @
bb399e56
import
{
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
AccountTableFilters
from
'
../AccountTableFilters.vue
'
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
})
}
})
describe
(
'
AccountTableFilters
'
,
()
=>
{
it
(
'
renders privacy mode options and emits privacy_mode updates
'
,
async
()
=>
{
const
wrapper
=
mount
(
AccountTableFilters
,
{
props
:
{
searchQuery
:
''
,
filters
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
group
:
''
,
privacy_mode
:
''
},
groups
:
[]
},
global
:
{
stubs
:
{
SearchInput
:
{
template
:
'
<div />
'
},
Select
:
{
props
:
[
'
modelValue
'
,
'
options
'
],
emits
:
[
'
update:modelValue
'
,
'
change
'
],
template
:
'
<div class="select-stub" :data-options="JSON.stringify(options)" />
'
}
}
}
})
const
selects
=
wrapper
.
findAll
(
'
.select-stub
'
)
expect
(
selects
).
toHaveLength
(
5
)
const
privacyOptions
=
JSON
.
parse
(
selects
[
3
].
attributes
(
'
data-options
'
))
expect
(
privacyOptions
).
toEqual
([
{
value
:
''
,
label
:
'
admin.accounts.allPrivacyModes
'
},
{
value
:
'
__unset__
'
,
label
:
'
admin.accounts.privacyUnset
'
},
{
value
:
'
training_off
'
,
label
:
'
Privacy
'
},
{
value
:
'
training_set_cf_blocked
'
,
label
:
'
CF
'
},
{
value
:
'
training_set_failed
'
,
label
:
'
Fail
'
}
])
})
})
frontend/src/components/keys/EndpointPopover.vue
0 → 100644
View file @
bb399e56
<
script
setup
lang=
"ts"
>
import
{
computed
,
onBeforeUnmount
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
type
{
CustomEndpoint
}
from
'
@/types
'
const
props
=
defineProps
<
{
apiBaseUrl
:
string
customEndpoints
:
CustomEndpoint
[]
}
>
()
const
{
t
}
=
useI18n
()
const
{
copyToClipboard
}
=
useClipboard
()
const
copiedEndpoint
=
ref
<
string
|
null
>
(
null
)
let
copiedResetTimer
:
number
|
undefined
const
allEndpoints
=
computed
(()
=>
{
const
items
:
Array
<
{
name
:
string
;
endpoint
:
string
;
description
:
string
;
isDefault
:
boolean
}
>
=
[]
if
(
props
.
apiBaseUrl
)
{
items
.
push
({
name
:
t
(
'
keys.endpoints.title
'
),
endpoint
:
props
.
apiBaseUrl
,
description
:
''
,
isDefault
:
true
,
})
}
for
(
const
ep
of
props
.
customEndpoints
)
{
items
.
push
({
...
ep
,
isDefault
:
false
})
}
return
items
})
async
function
copy
(
url
:
string
)
{
const
success
=
await
copyToClipboard
(
url
,
t
(
'
keys.endpoints.copied
'
))
if
(
!
success
)
return
copiedEndpoint
.
value
=
url
if
(
copiedResetTimer
!==
undefined
)
{
window
.
clearTimeout
(
copiedResetTimer
)
}
copiedResetTimer
=
window
.
setTimeout
(()
=>
{
if
(
copiedEndpoint
.
value
===
url
)
{
copiedEndpoint
.
value
=
null
}
},
1800
)
}
function
tooltipHint
(
endpoint
:
string
):
string
{
return
copiedEndpoint
.
value
===
endpoint
?
t
(
'
keys.endpoints.copiedHint
'
)
:
t
(
'
keys.endpoints.clickToCopy
'
)
}
function
speedTestUrl
(
endpoint
:
string
):
string
{
return
`https://www.tcptest.cn/http/
${
encodeURIComponent
(
endpoint
)}
`
}
onBeforeUnmount
(()
=>
{
if
(
copiedResetTimer
!==
undefined
)
{
window
.
clearTimeout
(
copiedResetTimer
)
}
})
</
script
>
<
template
>
<div
v-if=
"allEndpoints.length > 0"
class=
"flex flex-wrap gap-2"
>
<div
v-for=
"(item, index) in allEndpoints"
:key=
"index"
class=
"flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs transition-colors hover:border-primary-200 dark:border-dark-600 dark:bg-dark-800 dark:hover:border-primary-700"
>
<span
class=
"font-medium text-gray-600 dark:text-gray-300"
>
{{
item
.
name
}}
</span>
<span
v-if=
"item.isDefault"
class=
"rounded bg-primary-50 px-1 py-px text-[10px] font-medium leading-tight text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>
{{
t
(
'
keys.endpoints.default
'
)
}}
</span>
<span
class=
"text-gray-300 dark:text-dark-500"
>
|
</span>
<div
class=
"group/endpoint relative flex items-center gap-1.5"
>
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-max max-w-[24rem] -translate-x-1/2 translate-y-1 rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-left opacity-0 shadow-[0_14px_36px_-20px_rgba(15,23,42,0.35)] ring-1 ring-slate-200/80 transition-all duration-150 group-hover/endpoint:translate-y-0 group-hover/endpoint:opacity-100 group-focus-within/endpoint:translate-y-0 group-focus-within/endpoint:opacity-100 dark:border-slate-700 dark:bg-slate-900 dark:ring-slate-700/70"
>
<p
v-if=
"item.description"
class=
"max-w-[24rem] break-words text-xs leading-5 text-slate-600 dark:text-slate-200"
>
{{
item
.
description
}}
</p>
<p
class=
"flex items-center gap-1.5 text-[11px] leading-4 text-primary-600 dark:text-primary-300"
:class=
"item.description ? 'mt-1.5' : ''"
>
<span
class=
"h-1.5 w-1.5 rounded-full bg-primary-500 dark:bg-primary-300"
></span>
{{
tooltipHint
(
item
.
endpoint
)
}}
</p>
<div
class=
"absolute left-1/2 top-full h-3 w-3 -translate-x-1/2 -translate-y-1/2 rotate-45 border-b border-r border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
></div>
</div>
<code
class=
"cursor-pointer font-mono text-gray-500 decoration-gray-400 decoration-dashed underline-offset-2 hover:text-primary-600 hover:underline focus:text-primary-600 focus:underline focus:outline-none dark:text-gray-400 dark:decoration-gray-500 dark:hover:text-primary-400 dark:focus:text-primary-400"
role=
"button"
tabindex=
"0"
@
click=
"copy(item.endpoint)"
@
keydown.enter.prevent=
"copy(item.endpoint)"
@
keydown.space.prevent=
"copy(item.endpoint)"
>
{{
item
.
endpoint
}}
</code>
<button
type=
"button"
class=
"rounded p-0.5 transition-colors"
:class=
"copiedEndpoint === item.endpoint
? 'text-emerald-500 dark:text-emerald-400'
: 'text-gray-400 hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400'"
:aria-label=
"tooltipHint(item.endpoint)"
@
click=
"copy(item.endpoint)"
>
<svg
v-if=
"copiedEndpoint === item.endpoint"
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2.2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</svg>
<svg
v-else
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=
"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>
<a
:href=
"speedTestUrl(item.endpoint)"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"rounded p-0.5 text-gray-400 transition-colors hover:text-amber-500 dark:text-gray-500 dark:hover:text-amber-400"
:title=
"t('keys.endpoints.speedTest')"
>
<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=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</a>
</div>
</div>
</div>
</
template
>
frontend/src/components/keys/__tests__/EndpointPopover.spec.ts
0 → 100644
View file @
bb399e56
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
const
copyToClipboard
=
vi
.
fn
().
mockResolvedValue
(
true
)
const
messages
:
Record
<
string
,
string
>
=
{
'
keys.endpoints.title
'
:
'
API 端点
'
,
'
keys.endpoints.default
'
:
'
默认
'
,
'
keys.endpoints.copied
'
:
'
已复制
'
,
'
keys.endpoints.copiedHint
'
:
'
已复制到剪贴板
'
,
'
keys.endpoints.clickToCopy
'
:
'
点击可复制此端点
'
,
'
keys.endpoints.speedTest
'
:
'
测速
'
,
}
vi
.
mock
(
'
vue-i18n
'
,
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
messages
[
key
]
??
key
,
}),
}))
vi
.
mock
(
'
@/composables/useClipboard
'
,
()
=>
({
useClipboard
:
()
=>
({
copyToClipboard
,
}),
}))
import
EndpointPopover
from
'
../EndpointPopover.vue
'
describe
(
'
EndpointPopover
'
,
()
=>
{
beforeEach
(()
=>
{
vi
.
clearAllMocks
()
})
it
(
'
将说明提示渲染到 URL 上方而不是旧的 title 图标上
'
,
()
=>
{
const
wrapper
=
mount
(
EndpointPopover
,
{
props
:
{
apiBaseUrl
:
'
https://default.example.com/v1
'
,
customEndpoints
:
[
{
name
:
'
备用线路
'
,
endpoint
:
'
https://backup.example.com/v1
'
,
description
:
'
自定义说明
'
,
},
],
},
})
expect
(
wrapper
.
text
()).
toContain
(
'
自定义说明
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
点击可复制此端点
'
)
expect
(
wrapper
.
find
(
'
[role="button"]
'
).
attributes
(
'
title
'
)).
toBeUndefined
()
expect
(
wrapper
.
find
(
'
[title="自定义说明"]
'
).
exists
()).
toBe
(
false
)
})
it
(
'
点击 URL 后会复制并切换为已复制提示
'
,
async
()
=>
{
const
wrapper
=
mount
(
EndpointPopover
,
{
props
:
{
apiBaseUrl
:
'
https://default.example.com/v1
'
,
customEndpoints
:
[],
},
})
await
wrapper
.
find
(
'
[role="button"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
copyToClipboard
).
toHaveBeenCalledWith
(
'
https://default.example.com/v1
'
,
'
已复制
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
已复制到剪贴板
'
)
expect
(
wrapper
.
find
(
'
button[aria-label="已复制到剪贴板"]
'
).
exists
()).
toBe
(
true
)
})
})
frontend/src/composables/useAccountOAuth.ts
View file @
bb399e56
...
...
@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
import
{
adminAPI
}
from
'
@/api/admin
'
export
type
AddMethod
=
'
oauth
'
|
'
setup-token
'
export
type
AuthInputMethod
=
'
manual
'
|
'
cookie
'
|
'
refresh_token
'
|
'
session_token
'
|
'
access_token
'
export
type
AuthInputMethod
=
'
manual
'
|
'
cookie
'
|
'
refresh_token
'
|
'
mobile_refresh_token
'
|
'
session_token
'
|
'
access_token
'
export
interface
OAuthState
{
authUrl
:
string
...
...
frontend/src/composables/useOpenAIOAuth.ts
View file @
bb399e56
...
...
@@ -13,6 +13,8 @@ export interface OpenAITokenInfo {
scope
?:
string
email
?:
string
name
?:
string
plan_type
?:
string
privacy_mode
?:
string
// OpenAI specific IDs (extracted from ID Token)
chatgpt_account_id
?:
string
chatgpt_user_id
?:
string
...
...
@@ -126,9 +128,11 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
}
// Validate refresh token and get full token info
// clientId: 指定 OAuth client_id(用于第三方渠道获取的 RT,如 app_LlGpXReQgckcGGUo2JrYvtJK)
const
validateRefreshToken
=
async
(
refreshToken
:
string
,
proxyId
?:
number
|
null
proxyId
?:
number
|
null
,
clientId
?:
string
):
Promise
<
OpenAITokenInfo
|
null
>
=>
{
if
(
!
refreshToken
.
trim
())
{
error
.
value
=
'
Missing refresh token
'
...
...
@@ -143,11 +147,12 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
const
tokenInfo
=
await
adminAPI
.
accounts
.
refreshOpenAIToken
(
refreshToken
.
trim
(),
proxyId
,
`
${
endpointPrefix
}
/refresh-token`
`
${
endpointPrefix
}
/refresh-token`
,
clientId
)
return
tokenInfo
as
OpenAITokenInfo
}
catch
(
err
:
any
)
{
error
.
value
=
err
.
response
?.
data
?.
detail
||
'
Failed to validate refresh token
'
error
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
message
||
'
Failed to validate refresh token
'
appStore
.
showError
(
error
.
value
)
return
null
}
finally
{
...
...
@@ -182,22 +187,23 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
}
}
// Build credentials for OpenAI OAuth account
// Build credentials for OpenAI OAuth account
(aligned with backend BuildAccountCredentials)
const
buildCredentials
=
(
tokenInfo
:
OpenAITokenInfo
):
Record
<
string
,
unknown
>
=>
{
const
creds
:
Record
<
string
,
unknown
>
=
{
access_token
:
tokenInfo
.
access_token
,
refresh_token
:
tokenInfo
.
refresh_token
,
token_type
:
tokenInfo
.
token_type
,
expires_in
:
tokenInfo
.
expires_in
,
expires_at
:
tokenInfo
.
expires_at
,
scope
:
tokenInfo
.
scope
expires_at
:
tokenInfo
.
expires_at
}
if
(
tokenInfo
.
client_id
)
{
creds
.
client_id
=
tokenInfo
.
client_id
// 仅在返回了新的 refresh_token 时才写入,防止用空值覆盖已有令牌
if
(
tokenInfo
.
refresh_token
)
{
creds
.
refresh_token
=
tokenInfo
.
refresh_token
}
if
(
tokenInfo
.
id_token
)
{
creds
.
id_token
=
tokenInfo
.
id_token
}
if
(
tokenInfo
.
email
)
{
creds
.
email
=
tokenInfo
.
email
}
// Include OpenAI specific IDs (required for forwarding)
if
(
tokenInfo
.
chatgpt_account_id
)
{
creds
.
chatgpt_account_id
=
tokenInfo
.
chatgpt_account_id
}
...
...
@@ -207,6 +213,12 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
if
(
tokenInfo
.
organization_id
)
{
creds
.
organization_id
=
tokenInfo
.
organization_id
}
if
(
tokenInfo
.
plan_type
)
{
creds
.
plan_type
=
tokenInfo
.
plan_type
}
if
(
tokenInfo
.
client_id
)
{
creds
.
client_id
=
tokenInfo
.
client_id
}
return
creds
}
...
...
@@ -220,6 +232,9 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
if
(
tokenInfo
.
name
)
{
extra
.
name
=
tokenInfo
.
name
}
if
(
tokenInfo
.
privacy_mode
)
{
extra
.
privacy_mode
=
tokenInfo
.
privacy_mode
}
return
Object
.
keys
(
extra
).
length
>
0
?
extra
:
undefined
}
...
...
frontend/src/i18n/locales/en.ts
View file @
bb399e56
...
...
@@ -533,6 +533,14 @@ export default {
title
:
'
API Keys
'
,
description
:
'
Manage your API keys and access tokens
'
,
searchPlaceholder
:
'
Search name or key...
'
,
endpoints
:
{
title
:
'
API Endpoints
'
,
default
:
'
Default
'
,
copied
:
'
Copied
'
,
copiedHint
:
'
Copied to clipboard
'
,
clickToCopy
:
'
Click to copy this endpoint
'
,
speedTest
:
'
Speed Test
'
,
},
allGroups
:
'
All Groups
'
,
allStatus
:
'
All Status
'
,
createKey
:
'
Create API Key
'
,
...
...
@@ -1971,6 +1979,8 @@ export default {
expiresAt
:
'
Expires At
'
,
actions
:
'
Actions
'
},
allPrivacyModes
:
'
All Privacy States
'
,
privacyUnset
:
'
Unset
'
,
privacyTrainingOff
:
'
Training data sharing disabled
'
,
privacyCfBlocked
:
'
Blocked by Cloudflare, training may still be on
'
,
privacyFailed
:
'
Failed to disable training
'
,
...
...
@@ -3486,7 +3496,12 @@ export default {
typeRequest
:
'
Request
'
,
typeAuth
:
'
Auth
'
,
typeRouting
:
'
Routing
'
,
typeInternal
:
'
Internal
'
typeInternal
:
'
Internal
'
,
endpoint
:
'
Endpoint
'
,
requestType
:
'
Type
'
,
requestTypeSync
:
'
Sync
'
,
requestTypeStream
:
'
Stream
'
,
requestTypeWs
:
'
WS
'
},
// Error Details Modal
errorDetails
:
{
...
...
@@ -3572,6 +3587,16 @@ export default {
latency
:
'
Request Duration
'
,
businessLimited
:
'
Business Limited
'
,
requestPath
:
'
Request Path
'
,
inboundEndpoint
:
'
Inbound Endpoint
'
,
upstreamEndpoint
:
'
Upstream Endpoint
'
,
requestedModel
:
'
Requested Model
'
,
upstreamModel
:
'
Upstream Model
'
,
requestType
:
'
Request Type
'
,
requestTypeUnknown
:
'
Unknown
'
,
requestTypeSync
:
'
Sync
'
,
requestTypeStream
:
'
Stream
'
,
requestTypeWs
:
'
WebSocket
'
,
modelMapping
:
'
Model Mapping
'
,
timings
:
'
Timings
'
,
auth
:
'
Auth
'
,
routing
:
'
Routing
'
,
...
...
@@ -4162,6 +4187,18 @@ export default {
apiBaseUrlPlaceholder
:
'
https://api.example.com
'
,
apiBaseUrlHint
:
'
Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.
'
,
customEndpoints
:
{
title
:
'
Custom Endpoints
'
,
description
:
'
Add additional API endpoint URLs for users to quickly copy on the API Keys page
'
,
itemLabel
:
'
Endpoint #{n}
'
,
name
:
'
Name
'
,
namePlaceholder
:
'
e.g., OpenAI Compatible
'
,
endpointUrl
:
'
Endpoint URL
'
,
endpointUrlPlaceholder
:
'
https://api2.example.com
'
,
descriptionLabel
:
'
Description
'
,
descriptionPlaceholder
:
'
e.g., Supports OpenAI format requests
'
,
add
:
'
Add Endpoint
'
,
},
contactInfo
:
'
Contact Info
'
,
contactInfoPlaceholder
:
'
e.g., QQ: 123456789
'
,
contactInfoHint
:
'
Customer support contact info, displayed on redeem page, profile, etc.
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
bb399e56
...
...
@@ -533,6 +533,14 @@ export default {
title
:
'
API 密钥
'
,
description
:
'
管理您的 API 密钥和访问令牌
'
,
searchPlaceholder
:
'
搜索名称或Key...
'
,
endpoints
:
{
title
:
'
API 端点
'
,
default
:
'
默认
'
,
copied
:
'
已复制
'
,
copiedHint
:
'
已复制到剪贴板
'
,
clickToCopy
:
'
点击可复制此端点
'
,
speedTest
:
'
测速
'
,
},
allGroups
:
'
全部分组
'
,
allStatus
:
'
全部状态
'
,
createKey
:
'
创建密钥
'
,
...
...
@@ -2009,6 +2017,8 @@ export default {
expiresAt
:
'
过期时间
'
,
actions
:
'
操作
'
},
allPrivacyModes
:
'
全部Privacy状态
'
,
privacyUnset
:
'
未设置
'
,
privacyTrainingOff
:
'
已关闭训练数据共享
'
,
privacyCfBlocked
:
'
被 Cloudflare 拦截,训练可能仍开启
'
,
privacyFailed
:
'
关闭训练数据共享失败
'
,
...
...
@@ -3651,7 +3661,12 @@ export default {
typeRequest
:
'
请求
'
,
typeAuth
:
'
认证
'
,
typeRouting
:
'
路由
'
,
typeInternal
:
'
内部
'
typeInternal
:
'
内部
'
,
endpoint
:
'
端点
'
,
requestType
:
'
类型
'
,
requestTypeSync
:
'
同步
'
,
requestTypeStream
:
'
流式
'
,
requestTypeWs
:
'
WS
'
},
// Error Details Modal
errorDetails
:
{
...
...
@@ -3737,6 +3752,16 @@ export default {
latency
:
'
请求时长
'
,
businessLimited
:
'
业务限制
'
,
requestPath
:
'
请求路径
'
,
inboundEndpoint
:
'
入站端点
'
,
upstreamEndpoint
:
'
上游端点
'
,
requestedModel
:
'
请求模型
'
,
upstreamModel
:
'
上游模型
'
,
requestType
:
'
请求类型
'
,
requestTypeUnknown
:
'
未知
'
,
requestTypeSync
:
'
同步
'
,
requestTypeStream
:
'
流式
'
,
requestTypeWs
:
'
WebSocket
'
,
modelMapping
:
'
模型映射
'
,
timings
:
'
时序信息
'
,
auth
:
'
认证
'
,
routing
:
'
路由
'
,
...
...
@@ -4324,6 +4349,18 @@ export default {
apiBaseUrl
:
'
API 端点地址
'
,
apiBaseUrlHint
:
'
用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址
'
,
apiBaseUrlPlaceholder
:
'
https://api.example.com
'
,
customEndpoints
:
{
title
:
'
自定义端点
'
,
description
:
'
添加额外的 API 端点地址,用户可在「API Keys」页面快速复制
'
,
itemLabel
:
'
端点 #{n}
'
,
name
:
'
名称
'
,
namePlaceholder
:
'
如:OpenAI Compatible
'
,
endpointUrl
:
'
端点地址
'
,
endpointUrlPlaceholder
:
'
https://api2.example.com
'
,
descriptionLabel
:
'
介绍
'
,
descriptionPlaceholder
:
'
如:支持 OpenAI 格式请求
'
,
add
:
'
添加端点
'
,
},
contactInfo
:
'
客服联系方式
'
,
contactInfoPlaceholder
:
'
例如:QQ: 123456789
'
,
contactInfoHint
:
'
填写客服联系方式,将展示在兑换页面、个人资料等位置
'
,
...
...
frontend/src/stores/app.ts
View file @
bb399e56
...
...
@@ -330,6 +330,7 @@ export const useAppStore = defineStore('app', () => {
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
custom_menu_items
:
[],
custom_endpoints
:
[],
linuxdo_oauth_enabled
:
false
,
sora_client_enabled
:
false
,
backend_mode_enabled
:
false
,
...
...
frontend/src/types/index.ts
View file @
bb399e56
...
...
@@ -84,6 +84,12 @@ export interface CustomMenuItem {
sort_order
:
number
}
export
interface
CustomEndpoint
{
name
:
string
endpoint
:
string
description
:
string
}
export
interface
PublicSettings
{
registration_enabled
:
boolean
email_verify_enabled
:
boolean
...
...
@@ -104,6 +110,7 @@ export interface PublicSettings {
purchase_subscription_enabled
:
boolean
purchase_subscription_url
:
string
custom_menu_items
:
CustomMenuItem
[]
custom_endpoints
:
CustomEndpoint
[]
linuxdo_oauth_enabled
:
boolean
sora_client_enabled
:
boolean
backend_mode_enabled
:
boolean
...
...
frontend/src/views/admin/AccountsView.vue
View file @
bb399e56
...
...
@@ -581,7 +581,7 @@ const {
handlePageSizeChange
:
baseHandlePageSizeChange
}
=
useTableLoader
<
Account
,
any
>
({
fetchFn
:
adminAPI
.
accounts
.
list
,
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
group
:
''
,
search
:
''
}
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
privacy_mode
:
''
,
group
:
''
,
search
:
''
}
}
)
const
{
...
...
@@ -758,6 +758,7 @@ const refreshAccountsIncrementally = async () => {
platform
?:
string
type
?:
string
status
?:
string
privacy_mode
?:
string
group
?:
string
search
?:
string
...
...
frontend/src/views/admin/SettingsView.vue
View file @
bb399e56
...
...
@@ -1248,6 +1248,81 @@
<
/p
>
<
/div
>
<!--
Custom
Endpoints
-->
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.site.customEndpoints.title
'
)
}}
<
/label
>
<
p
class
=
"
mb-3 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.site.customEndpoints.description
'
)
}}
<
/p
>
<
div
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(ep, index) in form.custom_endpoints
"
:
key
=
"
index
"
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
span
class
=
"
text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.site.customEndpoints.itemLabel
'
,
{
n
:
index
+
1
}
)
}}
<
/span
>
<
button
type
=
"
button
"
class
=
"
rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20
"
@
click
=
"
removeEndpoint(index)
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16
"
/><
/svg
>
<
/button
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-3 sm:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.site.customEndpoints.name
'
)
}}
<
/label
>
<
input
v
-
model
=
"
ep.name
"
type
=
"
text
"
class
=
"
input text-sm
"
:
placeholder
=
"
t('admin.settings.site.customEndpoints.namePlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.site.customEndpoints.endpointUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
ep.endpoint
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.site.customEndpoints.endpointUrlPlaceholder')
"
/>
<
/div
>
<
div
class
=
"
sm:col-span-2
"
>
<
label
class
=
"
mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.site.customEndpoints.descriptionLabel
'
)
}}
<
/label
>
<
input
v
-
model
=
"
ep.description
"
type
=
"
text
"
class
=
"
input text-sm
"
:
placeholder
=
"
t('admin.settings.site.customEndpoints.descriptionPlaceholder')
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
button
type
=
"
button
"
class
=
"
mt-3 flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 px-4 py-2.5 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400
"
@
click
=
"
addEndpoint
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M12 4v16m8-8H4
"
/><
/svg
>
{{
t
(
'
admin.settings.site.customEndpoints.add
'
)
}}
<
/button
>
<
/div
>
<!--
Contact
Info
-->
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
...
...
@@ -1580,7 +1655,7 @@
<
button
type
=
"
button
"
@
click
=
"
testSmtpConnection
"
:
disabled
=
"
testingSmtp
"
:
disabled
=
"
testingSmtp
|| loadFailed
"
class
=
"
btn btn-secondary btn-sm
"
>
<
svg
v
-
if
=
"
testingSmtp
"
class
=
"
h-4 w-4 animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
...
...
@@ -1650,6 +1725,11 @@
v
-
model
=
"
form.smtp_password
"
type
=
"
password
"
class
=
"
input
"
autocomplete
=
"
new-password
"
autocapitalize
=
"
off
"
spellcheck
=
"
false
"
@
keydown
=
"
smtpPasswordManuallyEdited = true
"
@
paste
=
"
smtpPasswordManuallyEdited = true
"
:
placeholder
=
"
form.smtp_password_configured
? t('admin.settings.smtp.passwordConfiguredPlaceholder')
...
...
@@ -1732,7 +1812,7 @@
<
button
type
=
"
button
"
@
click
=
"
sendTestEmail
"
:
disabled
=
"
sendingTestEmail || !testEmailAddress
"
:
disabled
=
"
sendingTestEmail || !testEmailAddress
|| loadFailed
"
class
=
"
btn btn-secondary
"
>
<
svg
...
...
@@ -1778,7 +1858,7 @@
<!--
Save
Button
-->
<
div
v
-
show
=
"
activeTab !== 'backup' && activeTab !== 'data'
"
class
=
"
flex justify-end
"
>
<
button
type
=
"
submit
"
:
disabled
=
"
saving
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
:
disabled
=
"
saving
|| loadFailed
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
saving
"
class
=
"
h-4 w-4 animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
...
...
@@ -1849,9 +1929,11 @@ const settingsTabs = [
const
{
copyToClipboard
}
=
useClipboard
()
const
loading
=
ref
(
true
)
const
loadFailed
=
ref
(
false
)
const
saving
=
ref
(
false
)
const
testingSmtp
=
ref
(
false
)
const
sendingTestEmail
=
ref
(
false
)
const
smtpPasswordManuallyEdited
=
ref
(
false
)
const
testEmailAddress
=
ref
(
''
)
const
registrationEmailSuffixWhitelistTags
=
ref
<
string
[]
>
([])
const
registrationEmailSuffixWhitelistDraft
=
ref
(
''
)
...
...
@@ -1945,6 +2027,7 @@ const form = reactive<SettingsForm>({
purchase_subscription_url
:
''
,
sora_client_enabled
:
false
,
custom_menu_items
:
[]
as
Array
<
{
id
:
string
;
label
:
string
;
icon_svg
:
string
;
url
:
string
;
visibility
:
'
user
'
|
'
admin
'
;
sort_order
:
number
}
>
,
custom_endpoints
:
[]
as
Array
<
{
name
:
string
;
endpoint
:
string
;
description
:
string
}
>
,
frontend_url
:
''
,
smtp_host
:
''
,
smtp_port
:
587
,
...
...
@@ -2114,8 +2197,18 @@ function moveMenuItem(index: number, direction: -1 | 1) {
}
)
}
// Custom endpoint management
function
addEndpoint
()
{
form
.
custom_endpoints
.
push
({
name
:
''
,
endpoint
:
''
,
description
:
''
}
)
}
function
removeEndpoint
(
index
:
number
)
{
form
.
custom_endpoints
.
splice
(
index
,
1
)
}
async
function
loadSettings
()
{
loading
.
value
=
true
loadFailed
.
value
=
false
try
{
const
settings
=
await
adminAPI
.
settings
.
getSettings
()
Object
.
assign
(
form
,
settings
)
...
...
@@ -2133,9 +2226,11 @@ async function loadSettings() {
)
registrationEmailSuffixWhitelistDraft
.
value
=
''
form
.
smtp_password
=
''
smtpPasswordManuallyEdited
.
value
=
false
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
}
catch
(
error
:
any
)
{
loadFailed
.
value
=
true
appStore
.
showError
(
t
(
'
admin.settings.failedToLoad
'
)
+
'
:
'
+
(
error
.
message
||
t
(
'
common.unknownError
'
))
)
...
...
@@ -2253,6 +2348,7 @@ async function saveSettings() {
purchase_subscription_url
:
form
.
purchase_subscription_url
,
sora_client_enabled
:
form
.
sora_client_enabled
,
custom_menu_items
:
form
.
custom_menu_items
,
custom_endpoints
:
form
.
custom_endpoints
,
frontend_url
:
form
.
frontend_url
,
smtp_host
:
form
.
smtp_host
,
smtp_port
:
form
.
smtp_port
,
...
...
@@ -2286,6 +2382,7 @@ async function saveSettings() {
)
registrationEmailSuffixWhitelistDraft
.
value
=
''
form
.
smtp_password
=
''
smtpPasswordManuallyEdited
.
value
=
false
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
// Refresh cached settings so sidebar/header update immediately
...
...
@@ -2304,11 +2401,12 @@ async function saveSettings() {
async
function
testSmtpConnection
()
{
testingSmtp
.
value
=
true
try
{
const
smtpPasswordForTest
=
smtpPasswordManuallyEdited
.
value
?
form
.
smtp_password
:
''
const
result
=
await
adminAPI
.
settings
.
testSmtpConnection
({
smtp_host
:
form
.
smtp_host
,
smtp_port
:
form
.
smtp_port
,
smtp_username
:
form
.
smtp_username
,
smtp_password
:
form
.
smtp
_p
assword
,
smtp_password
:
smtp
P
assword
ForTest
,
smtp_use_tls
:
form
.
smtp_use_tls
}
)
// API returns
{
message
:
"
...
"
}
on
success
,
errors
are
thrown
as
exceptions
...
...
@@ -2330,12 +2428,13 @@ async function sendTestEmail() {
sendingTestEmail
.
value
=
true
try
{
const
smtpPasswordForSend
=
smtpPasswordManuallyEdited
.
value
?
form
.
smtp_password
:
''
const
result
=
await
adminAPI
.
settings
.
sendTestEmail
({
email
:
testEmailAddress
.
value
,
smtp_host
:
form
.
smtp_host
,
smtp_port
:
form
.
smtp_port
,
smtp_username
:
form
.
smtp_username
,
smtp_password
:
form
.
smtp
_p
assword
,
smtp_password
:
smtp
P
assword
ForSend
,
smtp_from_email
:
form
.
smtp_from_email
,
smtp_from_name
:
form
.
smtp_from_name
,
smtp_use_tls
:
form
.
smtp_use_tls
...
...
frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
View file @
bb399e56
...
...
@@ -59,7 +59,28 @@
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-900"
>
<div
class=
"text-xs font-bold uppercase tracking-wider text-gray-400"
>
{{ t('admin.ops.errorDetail.model') }}
</div>
<div
class=
"mt-1 text-sm font-medium text-gray-900 dark:text-white"
>
{{ detail.model || '—' }}
<
template
v-if=
"hasModelMapping(detail)"
>
<span
class=
"font-mono"
>
{{
detail
.
requested_model
}}
</span>
<span
class=
"mx-1 text-gray-400"
>
→
</span>
<span
class=
"font-mono text-primary-600 dark:text-primary-400"
>
{{
detail
.
upstream_model
}}
</span>
</
template
>
<
template
v-else
>
{{
displayModel
(
detail
)
||
'
—
'
}}
</
template
>
</div>
</div>
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-900"
>
<div
class=
"text-xs font-bold uppercase tracking-wider text-gray-400"
>
{{ t('admin.ops.errorDetail.inboundEndpoint') }}
</div>
<div
class=
"mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white"
>
{{ detail.inbound_endpoint || '—' }}
</div>
</div>
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-900"
>
<div
class=
"text-xs font-bold uppercase tracking-wider text-gray-400"
>
{{ t('admin.ops.errorDetail.upstreamEndpoint') }}
</div>
<div
class=
"mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white"
>
{{ detail.upstream_endpoint || '—' }}
</div>
</div>
...
...
@@ -72,6 +93,13 @@
</div>
</div>
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-900"
>
<div
class=
"text-xs font-bold uppercase tracking-wider text-gray-400"
>
{{ t('admin.ops.errorDetail.requestType') }}
</div>
<div
class=
"mt-1 text-sm font-medium text-gray-900 dark:text-white"
>
{{ formatRequestTypeLabel(detail.request_type) }}
</div>
</div>
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-900"
>
<div
class=
"text-xs font-bold uppercase tracking-wider text-gray-400"
>
{{ t('admin.ops.errorDetail.message') }}
</div>
<div
class=
"mt-1 truncate text-sm font-medium text-gray-900 dark:text-white"
:title=
"detail.message"
>
...
...
@@ -213,6 +241,31 @@ function isUpstreamError(d: OpsErrorDetail | null): boolean {
return
phase
===
'
upstream
'
&&
owner
===
'
provider
'
}
function
formatRequestTypeLabel
(
type
:
number
|
null
|
undefined
):
string
{
switch
(
type
)
{
case
1
:
return
t
(
'
admin.ops.errorDetail.requestTypeSync
'
)
case
2
:
return
t
(
'
admin.ops.errorDetail.requestTypeStream
'
)
case
3
:
return
t
(
'
admin.ops.errorDetail.requestTypeWs
'
)
default
:
return
t
(
'
admin.ops.errorDetail.requestTypeUnknown
'
)
}
}
function
hasModelMapping
(
d
:
OpsErrorDetail
|
null
):
boolean
{
if
(
!
d
)
return
false
const
requested
=
String
(
d
.
requested_model
||
''
).
trim
()
const
upstream
=
String
(
d
.
upstream_model
||
''
).
trim
()
return
!!
requested
&&
!!
upstream
&&
requested
!==
upstream
}
function
displayModel
(
d
:
OpsErrorDetail
|
null
):
string
{
if
(
!
d
)
return
''
const
upstream
=
String
(
d
.
upstream_model
||
''
).
trim
()
if
(
upstream
)
return
upstream
const
requested
=
String
(
d
.
requested_model
||
''
).
trim
()
if
(
requested
)
return
requested
return
String
(
d
.
model
||
''
).
trim
()
}
const
correlatedUpstream
=
ref
<
OpsErrorDetail
[]
>
([])
const
correlatedUpstreamLoading
=
ref
(
false
)
...
...
frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
View file @
bb399e56
...
...
@@ -17,6 +17,9 @@
<th
class=
"border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400"
>
{{
t
(
'
admin.ops.errorLog.type
'
)
}}
</th>
<th
class=
"border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400"
>
{{
t
(
'
admin.ops.errorLog.endpoint
'
)
}}
</th>
<th
class=
"border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400"
>
{{
t
(
'
admin.ops.errorLog.platform
'
)
}}
</th>
...
...
@@ -42,7 +45,7 @@
</thead>
<tbody
class=
"divide-y divide-gray-100 dark:divide-dark-700"
>
<tr
v-if=
"rows.length === 0"
>
<td
colspan=
"
9
"
class=
"py-12 text-center text-sm text-gray-400 dark:text-dark-500"
>
<td
colspan=
"
10
"
class=
"py-12 text-center text-sm text-gray-400 dark:text-dark-500"
>
{{
t
(
'
admin.ops.errorLog.noErrors
'
)
}}
</td>
</tr>
...
...
@@ -74,6 +77,18 @@
</span>
</td>
<!-- Endpoint -->
<td
class=
"px-4 py-2"
>
<div
class=
"max-w-[160px]"
>
<el-tooltip
v-if=
"log.inbound_endpoint"
:content=
"formatEndpointTooltip(log)"
placement=
"top"
:show-after=
"500"
>
<span
class=
"truncate font-mono text-[11px] text-gray-700 dark:text-gray-300"
>
{{
log
.
inbound_endpoint
}}
</span>
</el-tooltip>
<span
v-else
class=
"text-xs text-gray-400"
>
-
</span>
</div>
</td>
<!-- Platform -->
<td
class=
"whitespace-nowrap px-4 py-2"
>
<span
class=
"inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-bold uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
...
...
@@ -83,11 +98,22 @@
<!-- Model -->
<td
class=
"px-4 py-2"
>
<div
class=
"max-w-[120px] truncate"
:title=
"log.model"
>
<span
v-if=
"log.model"
class=
"font-mono text-[11px] text-gray-700 dark:text-gray-300"
>
{{
log
.
model
}}
<div
class=
"max-w-[160px]"
>
<template
v-if=
"hasModelMapping(log)"
>
<el-tooltip
:content=
"modelMappingTooltip(log)"
placement=
"top"
:show-after=
"500"
>
<span
class=
"flex items-center gap-1 truncate font-mono text-[11px] text-gray-700 dark:text-gray-300"
>
<span
class=
"truncate"
>
{{
log
.
requested_model
}}
</span>
<span
class=
"flex-shrink-0 text-gray-400"
>
→
</span>
<span
class=
"truncate text-primary-600 dark:text-primary-400"
>
{{
log
.
upstream_model
}}
</span>
</span>
</el-tooltip>
</
template
>
<
template
v-else
>
<span
v-if=
"displayModel(log)"
class=
"truncate font-mono text-[11px] text-gray-700 dark:text-gray-300"
:title=
"displayModel(log)"
>
{{
displayModel
(
log
)
}}
</span>
<span
v-else
class=
"text-xs text-gray-400"
>
-
</span>
</
template
>
</div>
</td>
...
...
@@ -138,6 +164,12 @@
>
{{ log.severity }}
</span>
<span
v-if=
"log.request_type != null && log.request_type > 0"
class=
"rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-bold text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{ formatRequestType(log.request_type) }}
</span>
</div>
</td>
...
...
@@ -193,6 +225,44 @@ function isUpstreamRow(log: OpsErrorLog): boolean {
return
phase
===
'
upstream
'
&&
owner
===
'
provider
'
}
function
formatEndpointTooltip
(
log
:
OpsErrorLog
):
string
{
const
parts
:
string
[]
=
[]
if
(
log
.
inbound_endpoint
)
parts
.
push
(
`Inbound:
${
log
.
inbound_endpoint
}
`
)
if
(
log
.
upstream_endpoint
)
parts
.
push
(
`Upstream:
${
log
.
upstream_endpoint
}
`
)
return
parts
.
join
(
'
\n
'
)
||
''
}
function
hasModelMapping
(
log
:
OpsErrorLog
):
boolean
{
const
requested
=
String
(
log
.
requested_model
||
''
).
trim
()
const
upstream
=
String
(
log
.
upstream_model
||
''
).
trim
()
return
!!
requested
&&
!!
upstream
&&
requested
!==
upstream
}
function
modelMappingTooltip
(
log
:
OpsErrorLog
):
string
{
const
requested
=
String
(
log
.
requested_model
||
''
).
trim
()
const
upstream
=
String
(
log
.
upstream_model
||
''
).
trim
()
if
(
!
requested
&&
!
upstream
)
return
''
if
(
requested
&&
upstream
)
return
`
${
requested
}
→
${
upstream
}
`
return
upstream
||
requested
}
function
displayModel
(
log
:
OpsErrorLog
):
string
{
const
upstream
=
String
(
log
.
upstream_model
||
''
).
trim
()
if
(
upstream
)
return
upstream
const
requested
=
String
(
log
.
requested_model
||
''
).
trim
()
if
(
requested
)
return
requested
return
String
(
log
.
model
||
''
).
trim
()
}
function
formatRequestType
(
type
:
number
|
null
|
undefined
):
string
{
switch
(
type
)
{
case
1
:
return
t
(
'
admin.ops.errorLog.requestTypeSync
'
)
case
2
:
return
t
(
'
admin.ops.errorLog.requestTypeStream
'
)
case
3
:
return
t
(
'
admin.ops.errorLog.requestTypeWs
'
)
default
:
return
''
}
}
function
getTypeBadge
(
log
:
OpsErrorLog
):
{
label
:
string
;
className
:
string
}
{
const
phase
=
String
(
log
.
phase
||
''
).
toLowerCase
()
const
owner
=
String
(
log
.
error_owner
||
''
).
toLowerCase
()
...
...
frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
View file @
bb399e56
...
...
@@ -344,7 +344,7 @@ onMounted(async () => {
<div
class=
"text-xs font-semibold text-gray-700 dark:text-gray-200"
>
运行时日志配置(实时生效)
</div>
<span
v-if=
"runtimeLoading"
class=
"text-xs text-gray-500"
>
加载中...
</span>
</div>
<div
class=
"grid grid-cols-1 gap-3 md:grid-cols-6"
>
<div
class=
"grid grid-cols-1 gap-3 md:grid-cols-
2 xl:grid-cols-
6"
>
<label
class=
"text-xs text-gray-600 dark:text-gray-300"
>
级别
<select
v-model=
"runtimeConfig.level"
class=
"input mt-1"
>
...
...
@@ -374,7 +374,9 @@ onMounted(async () => {
保留天数
<input
v-model.number=
"runtimeConfig.retention_days"
type=
"number"
min=
"1"
max=
"3650"
class=
"input mt-1"
/>
</label>
<div
class=
"flex items-end gap-2"
>
<div
class=
"md:col-span-2 xl:col-span-6"
>
<div
class=
"grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end"
>
<div
class=
"flex flex-wrap items-center gap-x-4 gap-y-2"
>
<label
class=
"inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300"
>
<input
v-model=
"runtimeConfig.caller"
type=
"checkbox"
/>
caller
...
...
@@ -383,6 +385,8 @@ onMounted(async () => {
<input
v-model=
"runtimeConfig.enable_sampling"
type=
"checkbox"
/>
sampling
</label>
</div>
<div
class=
"flex flex-wrap items-center gap-2 lg:justify-end"
>
<button
type=
"button"
class=
"btn btn-primary btn-sm"
:disabled=
"runtimeSaving"
@
click=
"saveRuntimeConfig"
>
{{
runtimeSaving
?
'
保存中...
'
:
'
保存并生效
'
}}
</button>
...
...
@@ -391,6 +395,8 @@ onMounted(async () => {
</button>
</div>
</div>
</div>
</div>
<p
v-if=
"health.last_error"
class=
"mt-2 text-xs text-red-600 dark:text-red-400"
>
最近写入错误:
{{
health
.
last_error
}}
</p>
</div>
...
...
frontend/src/views/user/KeysView.vue
View file @
bb399e56
...
...
@@ -2,6 +2,7 @@
<AppLayout>
<TablePageLayout>
<template
#filters
>
<div
class=
"flex flex-col gap-3"
>
<div
class=
"flex flex-wrap items-center gap-3"
>
<SearchInput
v-model=
"filterSearch"
...
...
@@ -22,6 +23,12 @@
@
update:model-value=
"onStatusFilterChange"
/>
</div>
<EndpointPopover
v-if=
"publicSettings?.api_base_url || (publicSettings?.custom_endpoints?.length ?? 0) > 0"
:api-base-url=
"publicSettings?.api_base_url || ''"
:custom-endpoints=
"publicSettings?.custom_endpoints || []"
/>
</div>
</
template
>
<
template
#actions
>
...
...
@@ -1050,6 +1057,7 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import
SearchInput
from
'
@/components/common/SearchInput.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
UseKeyModal
from
'
@/components/keys/UseKeyModal.vue
'
import
EndpointPopover
from
'
@/components/keys/EndpointPopover.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
import
type
{
ApiKey
,
Group
,
PublicSettings
,
SubscriptionType
,
GroupPlatform
}
from
'
@/types
'
...
...
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