merge: resolve upstream main conflicts for bulk OpenAI passthrough
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/SetupToken(RPM 配置仅在此条件下显示)
|
||||
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'
|
||||
|
||||
@ -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) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 手动输入 RT(Codex CLI client_id,默认)
|
||||
const handleOpenAIValidateRT = (rt: string) => handleOpenAIBatchRT(rt)
|
||||
|
||||
// 手动输入 Mobile RT(SoraClientID)
|
||||
const handleOpenAIValidateMobileRT = (rt: string) => handleOpenAIBatchRT(rt, OPENAI_MOBILE_RT_CLIENT_ID)
|
||||
|
||||
// Sora 手动 ST 批量验证和创建
|
||||
const handleSoraValidateST = async (sessionTokenInput: string) => {
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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') },
|
||||
|
||||
@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
141
frontend/src/components/keys/EndpointPopover.vue
Normal file
141
frontend/src/components/keys/EndpointPopover.vue
Normal 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>
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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: '填写客服联系方式,将展示在兑换页面、个人资料等位置',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
Reference in New Issue
Block a user