feat: 支持自定义端点配置与展示
This commit is contained in:
parent
bda7c39e55
commit
995bee143a
@ -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 {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
}
|
}
|
||||||
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) {
|
|||||||
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) {
|
|||||||
"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 (
|
|||||||
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
|
|||||||
SettingKeyPurchaseSubscriptionURL,
|
SettingKeyPurchaseSubscriptionURL,
|
||||||
SettingKeySoraClientEnabled,
|
SettingKeySoraClientEnabled,
|
||||||
SettingKeyCustomMenuItems,
|
SettingKeyCustomMenuItems,
|
||||||
|
SettingKeyCustomEndpoints,
|
||||||
SettingKeyLinuxDoConnectEnabled,
|
SettingKeyLinuxDoConnectEnabled,
|
||||||
SettingKeyBackendModeEnabled,
|
SettingKeyBackendModeEnabled,
|
||||||
}
|
}
|
||||||
@ -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
|
|||||||
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
|
|||||||
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 {
|
|||||||
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
|
|||||||
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 {
|
|||||||
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
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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 {
|
|||||||
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 {
|
|||||||
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
|
||||||
|
|||||||
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',
|
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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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', () => {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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 @@
|
|||||||
</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>({
|
|||||||
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) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
|||||||
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 @@
|
|||||||
<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'
|
|||||||
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'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user