Commit 995bee14 authored by shaw's avatar shaw
Browse files

feat: 支持自定义端点配置与展示

parent bda7c39e
...@@ -110,6 +110,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -110,6 +110,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
SoraClientEnabled: settings.SoraClientEnabled, SoraClientEnabled: settings.SoraClientEnabled,
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems), CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DefaultConcurrency: settings.DefaultConcurrency, DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance, DefaultBalance: settings.DefaultBalance,
DefaultSubscriptions: defaultSubscriptions, DefaultSubscriptions: defaultSubscriptions,
...@@ -176,6 +177,7 @@ type UpdateSettingsRequest struct { ...@@ -176,6 +177,7 @@ type UpdateSettingsRequest struct {
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
SoraClientEnabled bool `json:"sora_client_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"`
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"` CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"`
// 默认配置 // 默认配置
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
...@@ -417,6 +419,55 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -417,6 +419,55 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
customMenuJSON = string(menuBytes) customMenuJSON = string(menuBytes)
} }
// 自定义端点验证
const (
maxCustomEndpoints = 10
maxEndpointNameLen = 50
maxEndpointURLLen = 2048
maxEndpointDescriptionLen = 200
)
customEndpointsJSON := previousSettings.CustomEndpoints
if req.CustomEndpoints != nil {
endpoints := *req.CustomEndpoints
if len(endpoints) > maxCustomEndpoints {
response.BadRequest(c, "Too many custom endpoints (max 10)")
return
}
for _, ep := range endpoints {
if strings.TrimSpace(ep.Name) == "" {
response.BadRequest(c, "Custom endpoint name is required")
return
}
if len(ep.Name) > maxEndpointNameLen {
response.BadRequest(c, "Custom endpoint name is too long (max 50 characters)")
return
}
if strings.TrimSpace(ep.Endpoint) == "" {
response.BadRequest(c, "Custom endpoint URL is required")
return
}
if len(ep.Endpoint) > maxEndpointURLLen {
response.BadRequest(c, "Custom endpoint URL is too long (max 2048 characters)")
return
}
if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(ep.Endpoint)); err != nil {
response.BadRequest(c, "Custom endpoint URL must be an absolute http(s) URL")
return
}
if len(ep.Description) > maxEndpointDescriptionLen {
response.BadRequest(c, "Custom endpoint description is too long (max 200 characters)")
return
}
}
endpointBytes, err := json.Marshal(endpoints)
if err != nil {
response.BadRequest(c, "Failed to serialize custom endpoints")
return
}
customEndpointsJSON = string(endpointBytes)
}
// Ops metrics collector interval validation (seconds). // Ops metrics collector interval validation (seconds).
if req.OpsMetricsIntervalSeconds != nil { if req.OpsMetricsIntervalSeconds != nil {
v := *req.OpsMetricsIntervalSeconds v := *req.OpsMetricsIntervalSeconds
...@@ -495,6 +546,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -495,6 +546,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionURL: purchaseURL, PurchaseSubscriptionURL: purchaseURL,
SoraClientEnabled: req.SoraClientEnabled, SoraClientEnabled: req.SoraClientEnabled,
CustomMenuItems: customMenuJSON, CustomMenuItems: customMenuJSON,
CustomEndpoints: customEndpointsJSON,
DefaultConcurrency: req.DefaultConcurrency, DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance, DefaultBalance: req.DefaultBalance,
DefaultSubscriptions: defaultSubscriptions, DefaultSubscriptions: defaultSubscriptions,
...@@ -592,6 +644,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -592,6 +644,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
SoraClientEnabled: updatedSettings.SoraClientEnabled, SoraClientEnabled: updatedSettings.SoraClientEnabled,
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems), CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance, DefaultBalance: updatedSettings.DefaultBalance,
DefaultSubscriptions: updatedDefaultSubscriptions, DefaultSubscriptions: updatedDefaultSubscriptions,
......
...@@ -15,6 +15,13 @@ type CustomMenuItem struct { ...@@ -15,6 +15,13 @@ type CustomMenuItem struct {
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }
// CustomEndpoint represents an admin-configured API endpoint for quick copy.
type CustomEndpoint struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Description string `json:"description"`
}
// SystemSettings represents the admin settings API response payload. // SystemSettings represents the admin settings API response payload.
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
...@@ -56,6 +63,7 @@ type SystemSettings struct { ...@@ -56,6 +63,7 @@ type SystemSettings struct {
PurchaseSubscriptionURL string `json:"purchase_subscription_url"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
SoraClientEnabled bool `json:"sora_client_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"` DefaultBalance float64 `json:"default_balance"`
...@@ -114,6 +122,7 @@ type PublicSettings struct { ...@@ -114,6 +122,7 @@ type PublicSettings struct {
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"`
...@@ -218,3 +227,17 @@ func ParseUserVisibleMenuItems(raw string) []CustomMenuItem { ...@@ -218,3 +227,17 @@ func ParseUserVisibleMenuItems(raw string) []CustomMenuItem {
} }
return filtered return filtered
} }
// ParseCustomEndpoints parses a JSON string into a slice of CustomEndpoint.
// Returns empty slice on empty/invalid input.
func ParseCustomEndpoints(raw string) []CustomEndpoint {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return []CustomEndpoint{}
}
var items []CustomEndpoint
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []CustomEndpoint{}
}
return items
}
...@@ -52,6 +52,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ...@@ -52,6 +52,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
SoraClientEnabled: settings.SoraClientEnabled, SoraClientEnabled: settings.SoraClientEnabled,
BackendModeEnabled: settings.BackendModeEnabled, BackendModeEnabled: settings.BackendModeEnabled,
......
...@@ -540,7 +540,8 @@ func TestAPIContracts(t *testing.T) { ...@@ -540,7 +540,8 @@ func TestAPIContracts(t *testing.T) {
"max_claude_code_version": "", "max_claude_code_version": "",
"allow_ungrouped_key_scheduling": false, "allow_ungrouped_key_scheduling": false,
"backend_mode_enabled": false, "backend_mode_enabled": false,
"custom_menu_items": [] "custom_menu_items": [],
"custom_endpoints": []
} }
}`, }`,
}, },
......
...@@ -119,6 +119,7 @@ const ( ...@@ -119,6 +119,7 @@ const (
SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口 SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口
SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src) SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src)
SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项(JSON 数组) SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项(JSON 数组)
SettingKeyCustomEndpoints = "custom_endpoints" // 自定义端点列表(JSON 数组)
// 默认配置 // 默认配置
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
......
...@@ -150,6 +150,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -150,6 +150,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyPurchaseSubscriptionURL, SettingKeyPurchaseSubscriptionURL,
SettingKeySoraClientEnabled, SettingKeySoraClientEnabled,
SettingKeyCustomMenuItems, SettingKeyCustomMenuItems,
SettingKeyCustomEndpoints,
SettingKeyLinuxDoConnectEnabled, SettingKeyLinuxDoConnectEnabled,
SettingKeyBackendModeEnabled, SettingKeyBackendModeEnabled,
} }
...@@ -195,6 +196,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -195,6 +196,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
CustomMenuItems: settings[SettingKeyCustomMenuItems], CustomMenuItems: settings[SettingKeyCustomMenuItems],
CustomEndpoints: settings[SettingKeyCustomEndpoints],
LinuxDoOAuthEnabled: linuxDoEnabled, LinuxDoOAuthEnabled: linuxDoEnabled,
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
}, nil }, nil
...@@ -247,6 +249,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -247,6 +249,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"` PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
SoraClientEnabled bool `json:"sora_client_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"`
CustomMenuItems json.RawMessage `json:"custom_menu_items"` CustomMenuItems json.RawMessage `json:"custom_menu_items"`
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
...@@ -272,6 +275,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -272,6 +275,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
SoraClientEnabled: settings.SoraClientEnabled, SoraClientEnabled: settings.SoraClientEnabled,
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled, BackendModeEnabled: settings.BackendModeEnabled,
Version: s.version, Version: s.version,
...@@ -314,6 +318,18 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage { ...@@ -314,6 +318,18 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage {
return result return result
} }
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
func safeRawJSONArray(raw string) json.RawMessage {
raw = strings.TrimSpace(raw)
if raw == "" {
return json.RawMessage("[]")
}
if json.Valid([]byte(raw)) {
return json.RawMessage(raw)
}
return json.RawMessage("[]")
}
// GetFrameSrcOrigins returns deduplicated http(s) origins from purchase_subscription_url // GetFrameSrcOrigins returns deduplicated http(s) origins from purchase_subscription_url
// and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection. // and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) { func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) {
...@@ -454,6 +470,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -454,6 +470,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled) updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled)
updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints
// 默认配置 // 默认配置
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
...@@ -740,6 +757,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { ...@@ -740,6 +757,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyPurchaseSubscriptionURL: "", SettingKeyPurchaseSubscriptionURL: "",
SettingKeySoraClientEnabled: "false", SettingKeySoraClientEnabled: "false",
SettingKeyCustomMenuItems: "[]", SettingKeyCustomMenuItems: "[]",
SettingKeyCustomEndpoints: "[]",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyDefaultSubscriptions: "[]", SettingKeyDefaultSubscriptions: "[]",
...@@ -805,6 +823,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -805,6 +823,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
CustomMenuItems: settings[SettingKeyCustomMenuItems], CustomMenuItems: settings[SettingKeyCustomMenuItems],
CustomEndpoints: settings[SettingKeyCustomEndpoints],
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
} }
......
...@@ -43,6 +43,7 @@ type SystemSettings struct { ...@@ -43,6 +43,7 @@ type SystemSettings struct {
PurchaseSubscriptionURL string PurchaseSubscriptionURL string
SoraClientEnabled bool SoraClientEnabled bool
CustomMenuItems string // JSON array of custom menu items CustomMenuItems string // JSON array of custom menu items
CustomEndpoints string // JSON array of custom endpoints
DefaultConcurrency int DefaultConcurrency int
DefaultBalance float64 DefaultBalance float64
...@@ -104,6 +105,7 @@ type PublicSettings struct { ...@@ -104,6 +105,7 @@ type PublicSettings struct {
PurchaseSubscriptionURL string PurchaseSubscriptionURL string
SoraClientEnabled bool SoraClientEnabled bool
CustomMenuItems string // JSON array of custom menu items CustomMenuItems string // JSON array of custom menu items
CustomEndpoints string // JSON array of custom endpoints
LinuxDoOAuthEnabled bool LinuxDoOAuthEnabled bool
BackendModeEnabled bool BackendModeEnabled bool
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
*/ */
import { apiClient } from '../client' import { apiClient } from '../client'
import type { CustomMenuItem } from '@/types' import type { CustomMenuItem, CustomEndpoint } from '@/types'
export interface DefaultSubscriptionSetting { export interface DefaultSubscriptionSetting {
group_id: number group_id: number
...@@ -43,6 +43,7 @@ export interface SystemSettings { ...@@ -43,6 +43,7 @@ export interface SystemSettings {
sora_client_enabled: boolean sora_client_enabled: boolean
backend_mode_enabled: boolean backend_mode_enabled: boolean
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
// SMTP settings // SMTP settings
smtp_host: string smtp_host: string
smtp_port: number smtp_port: number
...@@ -112,6 +113,7 @@ export interface UpdateSettingsRequest { ...@@ -112,6 +113,7 @@ export interface UpdateSettingsRequest {
sora_client_enabled?: boolean sora_client_enabled?: boolean
backend_mode_enabled?: boolean backend_mode_enabled?: boolean
custom_menu_items?: CustomMenuItem[] custom_menu_items?: CustomMenuItem[]
custom_endpoints?: CustomEndpoint[]
smtp_host?: string smtp_host?: string
smtp_port?: number smtp_port?: number
smtp_username?: string smtp_username?: string
......
<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>
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 { ...@@ -533,6 +533,14 @@ export default {
title: 'API Keys', title: 'API Keys',
description: 'Manage your API keys and access tokens', description: 'Manage your API keys and access tokens',
searchPlaceholder: 'Search name or key...', 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', allGroups: 'All Groups',
allStatus: 'All Status', allStatus: 'All Status',
createKey: 'Create API Key', createKey: 'Create API Key',
...@@ -4162,6 +4170,18 @@ export default { ...@@ -4162,6 +4170,18 @@ export default {
apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlPlaceholder: 'https://api.example.com',
apiBaseUrlHint: apiBaseUrlHint:
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', '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', contactInfo: 'Contact Info',
contactInfoPlaceholder: 'e.g., QQ: 123456789', contactInfoPlaceholder: 'e.g., QQ: 123456789',
contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.', contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.',
......
...@@ -533,6 +533,14 @@ export default { ...@@ -533,6 +533,14 @@ export default {
title: 'API 密钥', title: 'API 密钥',
description: '管理您的 API 密钥和访问令牌', description: '管理您的 API 密钥和访问令牌',
searchPlaceholder: '搜索名称或Key...', searchPlaceholder: '搜索名称或Key...',
endpoints: {
title: 'API 端点',
default: '默认',
copied: '已复制',
copiedHint: '已复制到剪贴板',
clickToCopy: '点击可复制此端点',
speedTest: '测速',
},
allGroups: '全部分组', allGroups: '全部分组',
allStatus: '全部状态', allStatus: '全部状态',
createKey: '创建密钥', createKey: '创建密钥',
...@@ -4324,6 +4332,18 @@ export default { ...@@ -4324,6 +4332,18 @@ export default {
apiBaseUrl: 'API 端点地址', apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址', apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
apiBaseUrlPlaceholder: 'https://api.example.com', 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: '客服联系方式', contactInfo: '客服联系方式',
contactInfoPlaceholder: '例如:QQ: 123456789', contactInfoPlaceholder: '例如:QQ: 123456789',
contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置', contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置',
......
...@@ -330,6 +330,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -330,6 +330,7 @@ export const useAppStore = defineStore('app', () => {
purchase_subscription_enabled: false, purchase_subscription_enabled: false,
purchase_subscription_url: '', purchase_subscription_url: '',
custom_menu_items: [], custom_menu_items: [],
custom_endpoints: [],
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
sora_client_enabled: false, sora_client_enabled: false,
backend_mode_enabled: false, backend_mode_enabled: false,
......
...@@ -84,6 +84,12 @@ export interface CustomMenuItem { ...@@ -84,6 +84,12 @@ export interface CustomMenuItem {
sort_order: number sort_order: number
} }
export interface CustomEndpoint {
name: string
endpoint: string
description: string
}
export interface PublicSettings { export interface PublicSettings {
registration_enabled: boolean registration_enabled: boolean
email_verify_enabled: boolean email_verify_enabled: boolean
...@@ -104,6 +110,7 @@ export interface PublicSettings { ...@@ -104,6 +110,7 @@ export interface PublicSettings {
purchase_subscription_enabled: boolean purchase_subscription_enabled: boolean
purchase_subscription_url: string purchase_subscription_url: string
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
sora_client_enabled: boolean sora_client_enabled: boolean
backend_mode_enabled: boolean backend_mode_enabled: boolean
......
...@@ -1248,6 +1248,81 @@ ...@@ -1248,6 +1248,81 @@
</p> </p>
</div> </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 --> <!-- Contact Info -->
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
...@@ -1945,6 +2020,7 @@ const form = reactive<SettingsForm>({ ...@@ -1945,6 +2020,7 @@ const form = reactive<SettingsForm>({
purchase_subscription_url: '', purchase_subscription_url: '',
sora_client_enabled: false, 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_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: '', frontend_url: '',
smtp_host: '', smtp_host: '',
smtp_port: 587, smtp_port: 587,
...@@ -2114,6 +2190,15 @@ function moveMenuItem(index: number, direction: -1 | 1) { ...@@ -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() { async function loadSettings() {
loading.value = true loading.value = true
try { try {
...@@ -2253,6 +2338,7 @@ async function saveSettings() { ...@@ -2253,6 +2338,7 @@ async function saveSettings() {
purchase_subscription_url: form.purchase_subscription_url, purchase_subscription_url: form.purchase_subscription_url,
sora_client_enabled: form.sora_client_enabled, sora_client_enabled: form.sora_client_enabled,
custom_menu_items: form.custom_menu_items, custom_menu_items: form.custom_menu_items,
custom_endpoints: form.custom_endpoints,
frontend_url: form.frontend_url, frontend_url: form.frontend_url,
smtp_host: form.smtp_host, smtp_host: form.smtp_host,
smtp_port: form.smtp_port, smtp_port: form.smtp_port,
......
...@@ -2,24 +2,31 @@ ...@@ -2,24 +2,31 @@
<AppLayout> <AppLayout>
<TablePageLayout> <TablePageLayout>
<template #filters> <template #filters>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-col gap-3">
<SearchInput <div class="flex flex-wrap items-center gap-3">
v-model="filterSearch" <SearchInput
:placeholder="t('keys.searchPlaceholder')" v-model="filterSearch"
class="w-full sm:w-64" :placeholder="t('keys.searchPlaceholder')"
@search="onFilterChange" class="w-full sm:w-64"
/> @search="onFilterChange"
<Select />
:model-value="filterGroupId" <Select
class="w-40" :model-value="filterGroupId"
:options="groupFilterOptions" class="w-40"
@update:model-value="onGroupFilterChange" :options="groupFilterOptions"
/> @update:model-value="onGroupFilterChange"
<Select />
:model-value="filterStatus" <Select
class="w-40" :model-value="filterStatus"
:options="statusFilterOptions" class="w-40"
@update:model-value="onStatusFilterChange" :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> </div>
</template> </template>
...@@ -1050,6 +1057,7 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue' ...@@ -1050,6 +1057,7 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import SearchInput from '@/components/common/SearchInput.vue' import SearchInput from '@/components/common/SearchInput.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import UseKeyModal from '@/components/keys/UseKeyModal.vue' import UseKeyModal from '@/components/keys/UseKeyModal.vue'
import EndpointPopover from '@/components/keys/EndpointPopover.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
import GroupOptionItem from '@/components/common/GroupOptionItem.vue' import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types' import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types'
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment