From 995bee143af8c66a49d592bfc9ad54739bcbe654 Mon Sep 17 00:00:00 2001
From: shaw
Date: Tue, 24 Mar 2026 10:13:28 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?=
=?UTF-8?q?=E4=B9=89=E7=AB=AF=E7=82=B9=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=B1=95?=
=?UTF-8?q?=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../internal/handler/admin/setting_handler.go | 53 +++++++
backend/internal/handler/dto/settings.go | 23 +++
backend/internal/handler/setting_handler.go | 1 +
backend/internal/server/api_contract_test.go | 3 +-
backend/internal/service/domain_constants.go | 1 +
backend/internal/service/setting_service.go | 19 +++
backend/internal/service/settings_view.go | 2 +
frontend/src/api/admin/settings.ts | 4 +-
.../src/components/keys/EndpointPopover.vue | 141 ++++++++++++++++++
.../keys/__tests__/EndpointPopover.spec.ts | 69 +++++++++
frontend/src/i18n/locales/en.ts | 20 +++
frontend/src/i18n/locales/zh.ts | 20 +++
frontend/src/stores/app.ts | 1 +
frontend/src/types/index.ts | 7 +
frontend/src/views/admin/SettingsView.vue | 86 +++++++++++
frontend/src/views/user/KeysView.vue | 44 +++---
16 files changed, 474 insertions(+), 20 deletions(-)
create mode 100644 frontend/src/components/keys/EndpointPopover.vue
create mode 100644 frontend/src/components/keys/__tests__/EndpointPopover.spec.ts
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 @@
+
+
+
+
+
+
{{ item.name }}
+
{{ t('keys.endpoints.default') }}
+
+
|
+
+
+
+
+ {{ item.description }}
+
+
+
+ {{ tooltipHint(item.endpoint) }}
+
+
+
+
+
{{ item.endpoint }}
+
+
+
+
+
+
+
+
+
+
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 }) }}
+
+
+
+
+
+
+
+
+
+