diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index c91566c8..c209caf9 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -110,6 +110,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, SoraClientEnabled: settings.SoraClientEnabled, CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems), + CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, DefaultSubscriptions: defaultSubscriptions, @@ -176,6 +177,7 @@ type UpdateSettingsRequest struct { PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` SoraClientEnabled bool `json:"sora_client_enabled"` CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"` // 默认配置 DefaultConcurrency int `json:"default_concurrency"` @@ -417,6 +419,55 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { 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). if req.OpsMetricsIntervalSeconds != nil { v := *req.OpsMetricsIntervalSeconds @@ -495,6 +546,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PurchaseSubscriptionURL: purchaseURL, SoraClientEnabled: req.SoraClientEnabled, CustomMenuItems: customMenuJSON, + CustomEndpoints: customEndpointsJSON, DefaultConcurrency: req.DefaultConcurrency, DefaultBalance: req.DefaultBalance, DefaultSubscriptions: defaultSubscriptions, @@ -592,6 +644,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, SoraClientEnabled: updatedSettings.SoraClientEnabled, CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems), + CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints), DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultBalance: updatedSettings.DefaultBalance, DefaultSubscriptions: updatedDefaultSubscriptions, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 0f4f8fdc..7ea34aa0 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -15,6 +15,13 @@ type CustomMenuItem struct { 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. type SystemSettings struct { RegistrationEnabled bool `json:"registration_enabled"` @@ -56,6 +63,7 @@ type SystemSettings struct { PurchaseSubscriptionURL string `json:"purchase_subscription_url"` SoraClientEnabled bool `json:"sora_client_enabled"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` @@ -114,6 +122,7 @@ type PublicSettings struct { PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` @@ -218,3 +227,17 @@ func ParseUserVisibleMenuItems(raw string) []CustomMenuItem { } 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 +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 92061895..2c999cf1 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -52,6 +52,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), + CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, SoraClientEnabled: settings.SoraClientEnabled, BackendModeEnabled: settings.BackendModeEnabled, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index a6bd50ac..8509c8a9 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -540,7 +540,8 @@ func TestAPIContracts(t *testing.T) { "max_claude_code_version": "", "allow_ungrouped_key_scheduling": false, "backend_mode_enabled": false, - "custom_menu_items": [] + "custom_menu_items": [], + "custom_endpoints": [] } }`, }, diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 384d5159..4ae5a469 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -119,6 +119,7 @@ const ( SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口 SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src) SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项(JSON 数组) + SettingKeyCustomEndpoints = "custom_endpoints" // 自定义端点列表(JSON 数组) // 默认配置 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index f652839c..44d20491 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -150,6 +150,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyPurchaseSubscriptionURL, SettingKeySoraClientEnabled, SettingKeyCustomMenuItems, + SettingKeyCustomEndpoints, SettingKeyLinuxDoConnectEnabled, SettingKeyBackendModeEnabled, } @@ -195,6 +196,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], LinuxDoOAuthEnabled: linuxDoEnabled, BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", }, nil @@ -247,6 +249,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"` SoraClientEnabled bool `json:"sora_client_enabled"` CustomMenuItems json.RawMessage `json:"custom_menu_items"` + CustomEndpoints json.RawMessage `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` Version string `json:"version,omitempty"` @@ -272,6 +275,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, SoraClientEnabled: settings.SoraClientEnabled, CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), + CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, Version: s.version, @@ -314,6 +318,18 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage { 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 // 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) { @@ -454,6 +470,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled) updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems + updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints // 默认配置 updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) @@ -740,6 +757,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyPurchaseSubscriptionURL: "", SettingKeySoraClientEnabled: "false", SettingKeyCustomMenuItems: "[]", + SettingKeyCustomEndpoints: "[]", SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultSubscriptions: "[]", @@ -805,6 +823,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", } diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index cd0bed0b..cf1d5eed 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -43,6 +43,7 @@ type SystemSettings struct { PurchaseSubscriptionURL string SoraClientEnabled bool CustomMenuItems string // JSON array of custom menu items + CustomEndpoints string // JSON array of custom endpoints DefaultConcurrency int DefaultBalance float64 @@ -104,6 +105,7 @@ type PublicSettings struct { PurchaseSubscriptionURL string SoraClientEnabled bool CustomMenuItems string // JSON array of custom menu items + CustomEndpoints string // JSON array of custom endpoints LinuxDoOAuthEnabled bool BackendModeEnabled bool diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 0519d2fc..83258bcc 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -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 diff --git a/frontend/src/components/keys/EndpointPopover.vue b/frontend/src/components/keys/EndpointPopover.vue new file mode 100644 index 00000000..49db50b0 --- /dev/null +++ b/frontend/src/components/keys/EndpointPopover.vue @@ -0,0 +1,141 @@ + + + diff --git a/frontend/src/components/keys/__tests__/EndpointPopover.spec.ts b/frontend/src/components/keys/__tests__/EndpointPopover.spec.ts new file mode 100644 index 00000000..4d753da2 --- /dev/null +++ b/frontend/src/components/keys/__tests__/EndpointPopover.spec.ts @@ -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 = { + '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) + }) +}) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e5a370c8..a2f69e2c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ac6632be..2eef299c 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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: '填写客服联系方式,将展示在兑换页面、个人资料等位置', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index dea920c0..c080c2af 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -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, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 65740527..2656a28d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 23338d34..dbaa9c37 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1248,6 +1248,81 @@

+ +
+ +

+ {{ t('admin.settings.site.customEndpoints.description') }} +

+ +
+
+
+ + {{ t('admin.settings.site.customEndpoints.itemLabel', { n: index + 1 }) }} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+