feat: 支持自定义端点配置与展示
This commit is contained in:
@@ -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
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
@@ -4162,6 +4170,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: '创建密钥',
|
||||
@@ -4324,6 +4332,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
|
||||
|
||||
@@ -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">
|
||||
@@ -1945,6 +2020,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,6 +2190,15 @@ 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
|
||||
try {
|
||||
@@ -2253,6 +2338,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,
|
||||
|
||||
@@ -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