merge: resolve upstream main conflicts for bulk OpenAI passthrough

This commit is contained in:
Wang Lvyuan
2026-03-24 19:27:51 +08:00
98 changed files with 5168 additions and 213 deletions

View File

@ -36,6 +36,7 @@ export async function list(
status?: string
group?: string
search?: string
privacy_mode?: string
lite?: string
},
options?: {
@ -68,6 +69,7 @@ export async function listWithEtag(
status?: string
group?: string
search?: string
privacy_mode?: string
lite?: string
},
options?: {
@ -550,14 +552,18 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string
export async function refreshOpenAIToken(
refreshToken: string,
proxyId?: number | null,
endpoint: string = '/admin/openai/refresh-token'
endpoint: string = '/admin/openai/refresh-token',
clientId?: string
): Promise<Record<string, unknown>> {
const payload: { refresh_token: string; proxy_id?: number } = {
const payload: { refresh_token: string; proxy_id?: number; client_id?: string } = {
refresh_token: refreshToken
}
if (proxyId) {
payload.proxy_id = proxyId
}
if (clientId) {
payload.client_id = clientId
}
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
return data
}

View File

@ -969,6 +969,13 @@ export interface OpsErrorLog {
client_ip?: string | null
request_path?: string
stream?: boolean
// Error observability context (endpoint + model mapping)
inbound_endpoint?: string
upstream_endpoint?: string
requested_model?: string
upstream_model?: string
request_type?: number | null
}
export interface OpsErrorDetail extends OpsErrorLog {

View File

@ -4,7 +4,7 @@
*/
import { apiClient } from '../client'
import type { CustomMenuItem } from '@/types'
import type { CustomMenuItem, CustomEndpoint } from '@/types'
export interface DefaultSubscriptionSetting {
group_id: number
@ -43,6 +43,7 @@ export interface SystemSettings {
sora_client_enabled: boolean
backend_mode_enabled: boolean
custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
// SMTP settings
smtp_host: string
smtp_port: number
@ -112,6 +113,7 @@ export interface UpdateSettingsRequest {
sora_client_enabled?: boolean
backend_mode_enabled?: boolean
custom_menu_items?: CustomMenuItem[]
custom_endpoints?: CustomEndpoint[]
smtp_host?: string
smtp_port?: number
smtp_username?: string

View File

@ -661,6 +661,43 @@
</div>
</div>
<!-- OpenAI OAuth WS mode -->
<div v-if="allOpenAIOAuth" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<label
id="bulk-edit-openai-ws-mode-label"
class="input-label mb-0"
for="bulk-edit-openai-ws-mode-enabled"
>
{{ t('admin.accounts.openai.wsMode') }}
</label>
<input
v-model="enableOpenAIWSMode"
id="bulk-edit-openai-ws-mode-enabled"
type="checkbox"
aria-controls="bulk-edit-openai-ws-mode"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div
id="bulk-edit-openai-ws-mode"
:class="!enableOpenAIWSMode && 'pointer-events-none opacity-50'"
>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.wsModeDesc') }}
</p>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t(openAIWSModeConcurrencyHintKey) }}
</p>
<Select
v-model="openaiOAuthResponsesWebSocketV2Mode"
data-testid="bulk-edit-openai-ws-mode-select"
:options="openAIWSModeOptions"
aria-labelledby="bulk-edit-openai-ws-mode-label"
/>
</div>
</div>
<!-- RPM Limit (仅全部为 Anthropic OAuth/SetupToken 时显示) -->
<div v-if="allAnthropicOAuthOrSetupToken" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@ -883,6 +920,13 @@ import {
buildModelMappingObject as buildModelMappingPayload,
getPresetMappingsByPlatform
} from '@/composables/useModelWhitelist'
import {
OPENAI_WS_MODE_OFF,
OPENAI_WS_MODE_PASSTHROUGH,
isOpenAIWSModeEnabled,
resolveOpenAIWSModeConcurrencyHintKey
} from '@/utils/openaiWsMode'
import type { OpenAIWSMode } from '@/utils/openaiWsMode'
interface Props {
show: boolean
accountIds: number[]
@ -913,6 +957,15 @@ const allOpenAIPassthroughCapable = computed(() => {
)
})
const allOpenAIOAuth = computed(() => {
return (
props.selectedPlatforms.length === 1 &&
props.selectedPlatforms[0] === 'openai' &&
props.selectedTypes.length > 0 &&
props.selectedTypes.every(t => t === 'oauth')
)
})
// 是否全部为 Anthropic OAuth/SetupTokenRPM 配置仅在此条件下显示)
const allAnthropicOAuthOrSetupToken = computed(() => {
return (
@ -957,6 +1010,7 @@ const enableRateMultiplier = ref(false)
const enableStatus = ref(false)
const enableGroups = ref(false)
const enableOpenAIPassthrough = ref(false)
const enableOpenAIWSMode = ref(false)
const enableRpmLimit = ref(false)
// State - field values
@ -979,6 +1033,7 @@ const rateMultiplier = ref(1)
const status = ref<'active' | 'inactive'>('active')
const groupIds = ref<number[]>([])
const openaiPassthroughEnabled = ref(false)
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const rpmLimitEnabled = ref(false)
const bulkBaseRpm = ref<number | null>(null)
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
@ -1005,10 +1060,19 @@ const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
const isOpenAIModelRestrictionDisabled = computed(() =>
allOpenAIPassthroughCapable.value &&
enableOpenAIPassthrough.value &&
openaiPassthroughEnabled.value
const isOpenAIModelRestrictionDisabled = computed(
() =>
allOpenAIPassthroughCapable.value &&
enableOpenAIPassthrough.value &&
openaiPassthroughEnabled.value
)
const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
{ value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }
])
const openAIWSModeConcurrencyHintKey = computed(() =>
resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value)
)
// Model mapping helpers
@ -1180,6 +1244,14 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
updates.credentials = credentials
}
if (enableOpenAIWSMode.value) {
const extra = ensureExtra()
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
extra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(
openaiOAuthResponsesWebSocketV2Mode.value
)
}
// RPM limit settings (写入 extra 字段)
if (enableRpmLimit.value) {
const extra = ensureExtra()
@ -1269,6 +1341,7 @@ const handleSubmit = async () => {
enableRateMultiplier.value ||
enableStatus.value ||
enableGroups.value ||
enableOpenAIWSMode.value ||
enableRpmLimit.value ||
userMsgQueueMode.value !== null
@ -1361,6 +1434,7 @@ watch(
enableStatus.value = false
enableGroups.value = false
enableOpenAIPassthrough.value = false
enableOpenAIWSMode.value = false
enableRpmLimit.value = false
// Reset all values
@ -1379,6 +1453,7 @@ watch(
rateMultiplier.value = 1
status.value = 'active'
groupIds.value = []
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
rpmLimitEnabled.value = false
bulkBaseRpm.value = null
bulkRpmStrategy.value = 'tiered'

View File

@ -2504,6 +2504,7 @@
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'sora' || form.platform === 'antigravity'"
:show-mobile-refresh-token-option="form.platform === 'openai'"
:show-session-token-option="form.platform === 'sora'"
:show-access-token-option="form.platform === 'sora'"
:platform="form.platform"
@ -2511,6 +2512,7 @@
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
@validate-refresh-token="handleValidateRefreshToken"
@validate-mobile-refresh-token="handleOpenAIValidateMobileRT"
@validate-session-token="handleValidateSessionToken"
@import-access-token="handleImportAccessToken"
/>
@ -4360,11 +4362,14 @@ const handleOpenAIExchange = async (authCode: string) => {
}
// OpenAI 手动 RT 批量验证和创建
const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
// OpenAI Mobile RT 使用的 client_id与后端 openai.SoraClientID 一致)
const OPENAI_MOBILE_RT_CLIENT_ID = 'app_LlGpXReQgckcGGUo2JrYvtJK'
// OpenAI/Sora RT 批量验证和创建(共享逻辑)
const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => {
const oauthClient = activeOpenAIOAuth.value
if (!refreshTokenInput.trim()) return
// Parse multiple refresh tokens (one per line)
const refreshTokens = refreshTokenInput
.split('\n')
.map((rt) => rt.trim())
@ -4389,7 +4394,8 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
try {
const tokenInfo = await oauthClient.validateRefreshToken(
refreshTokens[i],
form.proxy_id
form.proxy_id,
clientId
)
if (!tokenInfo) {
failedCount++
@ -4399,6 +4405,9 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
}
const credentials = oauthClient.buildCredentials(tokenInfo)
if (clientId) {
credentials.client_id = clientId
}
const oauthExtra = oauthClient.buildExtraInfo(tokenInfo) as Record<string, unknown> | undefined
const extra = buildOpenAIExtra(oauthExtra)
@ -4410,8 +4419,9 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
}
}
// Generate account name with index for batch
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
// Generate account name; fallback to email if name is empty (ent schema requires NotEmpty)
const baseName = form.name || tokenInfo.email || 'OpenAI OAuth Account'
const accountName = refreshTokens.length > 1 ? `${baseName} #${i + 1}` : baseName
let openaiAccountId: string | number | undefined
@ -4494,6 +4504,12 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
}
}
// 手动输入 RTCodex CLI client_id默认
const handleOpenAIValidateRT = (rt: string) => handleOpenAIBatchRT(rt)
// 手动输入 Mobile RTSoraClientID
const handleOpenAIValidateMobileRT = (rt: string) => handleOpenAIBatchRT(rt, OPENAI_MOBILE_RT_CLIENT_ID)
// Sora 手动 ST 批量验证和创建
const handleSoraValidateST = async (sessionTokenInput: string) => {
const oauthClient = activeOpenAIOAuth.value

View File

@ -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,7 +959,11 @@ const handleCookieAuth = () => {
const handleValidateRefreshToken = () => {
if (refreshTokenInput.value.trim()) {
emit('validate-refresh-token', 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())
}
}
}

View File

@ -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'],

View File

@ -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') },

View File

@ -0,0 +1,56 @@
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' }
])
})
})

View File

@ -0,0 +1,141 @@
<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>

View File

@ -0,0 +1,69 @@
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)
})
})

View File

@ -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

View File

@ -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
}

View File

@ -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.',

View File

@ -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: '填写客服联系方式,将展示在兑换页面、个人资料等位置',

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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_password,
smtp_password: smtpPasswordForTest,
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_password,
smtp_password: smtpPasswordForSend,
smtp_from_email: form.smtp_from_email,
smtp_from_name: form.smtp_from_name,
smtp_use_tls: form.smtp_use_tls

View File

@ -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)

View File

@ -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 }}
</span>
<span v-else class="text-xs text-gray-400">-</span>
<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()
@ -263,4 +333,4 @@ function formatSmartMessage(msg: string): string {
return msg.length > 200 ? msg.substring(0, 200) + '...' : msg
}
</script>
</script>

View File

@ -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,21 +374,27 @@ 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">
<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
</label>
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input v-model="runtimeConfig.enable_sampling" type="checkbox" />
sampling
</label>
<button type="button" class="btn btn-primary btn-sm" :disabled="runtimeSaving" @click="saveRuntimeConfig">
{{ runtimeSaving ? '保存中...' : '保存并生效' }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="runtimeSaving" @click="resetRuntimeConfig">
回滚默认值
</button>
<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
</label>
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<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>
<button type="button" class="btn btn-secondary btn-sm" :disabled="runtimeSaving" @click="resetRuntimeConfig">
回滚默认值
</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>

View File

@ -2,24 +2,31 @@
<AppLayout>
<TablePageLayout>
<template #filters>
<div class="flex flex-wrap items-center gap-3">
<SearchInput
v-model="filterSearch"
:placeholder="t('keys.searchPlaceholder')"
class="w-full sm:w-64"
@search="onFilterChange"
/>
<Select
:model-value="filterGroupId"
class="w-40"
:options="groupFilterOptions"
@update:model-value="onGroupFilterChange"
/>
<Select
:model-value="filterStatus"
class="w-40"
:options="statusFilterOptions"
@update:model-value="onStatusFilterChange"
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-3">
<SearchInput
v-model="filterSearch"
:placeholder="t('keys.searchPlaceholder')"
class="w-full sm:w-64"
@search="onFilterChange"
/>
<Select
:model-value="filterGroupId"
class="w-40"
:options="groupFilterOptions"
@update:model-value="onGroupFilterChange"
/>
<Select
:model-value="filterStatus"
class="w-40"
:options="statusFilterOptions"
@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>
@ -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'