Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
08c4e514
Commit
08c4e514
authored
Mar 24, 2026
by
InCerry
Browse files
Merge branch 'main' of github.com:InCerryGit/sub2api
# Conflicts: # backend/internal/service/billing_service.go
parents
73708da6
995bee14
Changes
59
Show whitespace changes
Inline
Side-by-side
backend/internal/service/ops_alert_evaluator_service.go
View file @
08c4e514
...
@@ -88,6 +88,7 @@ func (s *OpsAlertEvaluatorService) Start() {
...
@@ -88,6 +88,7 @@ func (s *OpsAlertEvaluatorService) Start() {
if
s
.
stopCh
==
nil
{
if
s
.
stopCh
==
nil
{
s
.
stopCh
=
make
(
chan
struct
{})
s
.
stopCh
=
make
(
chan
struct
{})
}
}
s
.
wg
.
Add
(
1
)
go
s
.
run
()
go
s
.
run
()
})
})
}
}
...
@@ -105,7 +106,6 @@ func (s *OpsAlertEvaluatorService) Stop() {
...
@@ -105,7 +106,6 @@ func (s *OpsAlertEvaluatorService) Stop() {
}
}
func
(
s
*
OpsAlertEvaluatorService
)
run
()
{
func
(
s
*
OpsAlertEvaluatorService
)
run
()
{
s
.
wg
.
Add
(
1
)
defer
s
.
wg
.
Done
()
defer
s
.
wg
.
Done
()
// Start immediately to produce early feedback in ops dashboard.
// Start immediately to produce early feedback in ops dashboard.
...
@@ -848,7 +848,9 @@ func (s *OpsAlertEvaluatorService) tryAcquireLeaderLock(ctx context.Context, loc
...
@@ -848,7 +848,9 @@ func (s *OpsAlertEvaluatorService) tryAcquireLeaderLock(ctx context.Context, loc
return
nil
,
false
return
nil
,
false
}
}
return
func
()
{
return
func
()
{
_
,
_
=
opsAlertEvaluatorReleaseScript
.
Run
(
ctx
,
s
.
redisClient
,
[]
string
{
key
},
s
.
instanceID
)
.
Result
()
releaseCtx
,
releaseCancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Second
)
defer
releaseCancel
()
_
,
_
=
opsAlertEvaluatorReleaseScript
.
Run
(
releaseCtx
,
s
.
redisClient
,
[]
string
{
key
},
s
.
instanceID
)
.
Result
()
},
true
},
true
}
}
...
...
backend/internal/service/setting_service.go
View file @
08c4e514
...
@@ -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"
,
}
}
...
...
backend/internal/service/settings_view.go
View file @
08c4e514
...
@@ -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
...
...
backend/internal/service/sora_gateway_service.go
View file @
08c4e514
...
@@ -148,10 +148,13 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
...
@@ -148,10 +148,13 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
s
.
writeSoraError
(
c
,
http
.
StatusBadRequest
,
"invalid_request_error"
,
"model is required"
,
clientStream
)
s
.
writeSoraError
(
c
,
http
.
StatusBadRequest
,
"invalid_request_error"
,
"model is required"
,
clientStream
)
return
nil
,
errors
.
New
(
"model is required"
)
return
nil
,
errors
.
New
(
"model is required"
)
}
}
originalModel
:=
reqModel
mappedModel
:=
account
.
GetMappedModel
(
reqModel
)
mappedModel
:=
account
.
GetMappedModel
(
reqModel
)
var
upstreamModel
string
if
mappedModel
!=
""
&&
mappedModel
!=
reqModel
{
if
mappedModel
!=
""
&&
mappedModel
!=
reqModel
{
reqModel
=
mappedModel
reqModel
=
mappedModel
upstreamModel
=
mappedModel
}
}
modelCfg
,
ok
:=
GetSoraModelConfig
(
reqModel
)
modelCfg
,
ok
:=
GetSoraModelConfig
(
reqModel
)
...
@@ -214,7 +217,8 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
...
@@ -214,7 +217,8 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
}
}
return
&
ForwardResult
{
return
&
ForwardResult
{
RequestID
:
""
,
RequestID
:
""
,
Model
:
reqModel
,
Model
:
originalModel
,
UpstreamModel
:
upstreamModel
,
Stream
:
clientStream
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
FirstTokenMs
:
firstTokenMs
,
...
@@ -270,7 +274,8 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
...
@@ -270,7 +274,8 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
}
}
return
&
ForwardResult
{
return
&
ForwardResult
{
RequestID
:
""
,
RequestID
:
""
,
Model
:
reqModel
,
Model
:
originalModel
,
UpstreamModel
:
upstreamModel
,
Stream
:
clientStream
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
FirstTokenMs
:
firstTokenMs
,
...
@@ -420,7 +425,8 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
...
@@ -420,7 +425,8 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
return
&
ForwardResult
{
return
&
ForwardResult
{
RequestID
:
taskID
,
RequestID
:
taskID
,
Model
:
reqModel
,
Model
:
originalModel
,
UpstreamModel
:
upstreamModel
,
Stream
:
clientStream
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
FirstTokenMs
:
firstTokenMs
,
...
...
backend/internal/service/sora_gateway_service_test.go
View file @
08c4e514
...
@@ -144,6 +144,11 @@ func TestSoraGatewayService_ForwardPromptEnhance(t *testing.T) {
...
@@ -144,6 +144,11 @@ func TestSoraGatewayService_ForwardPromptEnhance(t *testing.T) {
ID
:
1
,
ID
:
1
,
Platform
:
PlatformSora
,
Platform
:
PlatformSora
,
Status
:
StatusActive
,
Status
:
StatusActive
,
Credentials
:
map
[
string
]
any
{
"model_mapping"
:
map
[
string
]
any
{
"prompt-enhance-short-10s"
:
"prompt-enhance-short-15s"
,
},
},
}
}
body
:=
[]
byte
(
`{"model":"prompt-enhance-short-10s","messages":[{"role":"user","content":"cat running"}],"stream":false}`
)
body
:=
[]
byte
(
`{"model":"prompt-enhance-short-10s","messages":[{"role":"user","content":"cat running"}],"stream":false}`
)
...
@@ -152,6 +157,7 @@ func TestSoraGatewayService_ForwardPromptEnhance(t *testing.T) {
...
@@ -152,6 +157,7 @@ func TestSoraGatewayService_ForwardPromptEnhance(t *testing.T) {
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
Equal
(
t
,
"prompt"
,
result
.
MediaType
)
require
.
Equal
(
t
,
"prompt"
,
result
.
MediaType
)
require
.
Equal
(
t
,
"prompt-enhance-short-10s"
,
result
.
Model
)
require
.
Equal
(
t
,
"prompt-enhance-short-10s"
,
result
.
Model
)
require
.
Equal
(
t
,
"prompt-enhance-short-15s"
,
result
.
UpstreamModel
)
}
}
func
TestSoraGatewayService_ForwardStoryboardPrompt
(
t
*
testing
.
T
)
{
func
TestSoraGatewayService_ForwardStoryboardPrompt
(
t
*
testing
.
T
)
{
...
...
backend/internal/service/usage_log.go
View file @
08c4e514
...
@@ -98,6 +98,9 @@ type UsageLog struct {
...
@@ -98,6 +98,9 @@ type UsageLog struct {
AccountID
int64
AccountID
int64
RequestID
string
RequestID
string
Model
string
Model
string
// RequestedModel is the client-requested model name recorded for stable user/admin display.
// Empty should be treated as Model for backward compatibility with historical rows.
RequestedModel
string
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// Nil means no mapping was applied (requested model was used as-is).
// Nil means no mapping was applied (requested model was used as-is).
UpstreamModel
*
string
UpstreamModel
*
string
...
...
backend/internal/service/usage_log_helpers.go
View file @
08c4e514
...
@@ -19,3 +19,10 @@ func optionalNonEqualStringPtr(value, compare string) *string {
...
@@ -19,3 +19,10 @@ func optionalNonEqualStringPtr(value, compare string) *string {
}
}
return
&
value
return
&
value
}
}
func
forwardResultBillingModel
(
requestedModel
,
upstreamModel
string
)
string
{
if
trimmedUpstream
:=
strings
.
TrimSpace
(
upstreamModel
);
trimmedUpstream
!=
""
{
return
trimmedUpstream
}
return
strings
.
TrimSpace
(
requestedModel
)
}
backend/migrations/077_add_usage_log_requested_model.sql
0 → 100644
View file @
08c4e514
-- Add requested_model field to usage_logs for normalized request/upstream model tracking.
-- NULL means historical rows written before requested_model dual-write was introduced.
ALTER
TABLE
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
requested_model
VARCHAR
(
100
);
backend/migrations/078_add_usage_log_requested_model_index_notx.sql
0 → 100644
View file @
08c4e514
-- Support requested_model / upstream_model aggregations with time-range filters.
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
idx_usage_logs_created_requested_model_upstream_model
ON
usage_logs
(
created_at
,
requested_model
,
upstream_model
);
frontend/src/api/admin/settings.ts
View file @
08c4e514
...
@@ -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
...
...
frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts
View file @
08c4e514
...
@@ -39,6 +39,7 @@ const DataTableStub = {
...
@@ -39,6 +39,7 @@ const DataTableStub = {
template
:
`
template
:
`
<div>
<div>
<div v-for="row in data" :key="row.request_id">
<div v-for="row in data" :key="row.request_id">
<slot name="cell-model" :row="row" :value="row.model" />
<slot name="cell-cost" :row="row" />
<slot name="cell-cost" :row="row" />
</div>
</div>
</div>
</div>
...
@@ -108,4 +109,42 @@ describe('admin UsageTable tooltip', () => {
...
@@ -108,4 +109,42 @@ describe('admin UsageTable tooltip', () => {
expect
(
text
).
toContain
(
'
$30.0000 / 1M tokens
'
)
expect
(
text
).
toContain
(
'
$30.0000 / 1M tokens
'
)
expect
(
text
).
toContain
(
'
$0.069568
'
)
expect
(
text
).
toContain
(
'
$0.069568
'
)
})
})
it
(
'
shows requested and upstream models separately for admin rows
'
,
()
=>
{
const
row
=
{
request_id
:
'
req-admin-model-1
'
,
model
:
'
claude-sonnet-4
'
,
upstream_model
:
'
claude-sonnet-4-20250514
'
,
actual_cost
:
0
,
total_cost
:
0
,
account_rate_multiplier
:
1
,
rate_multiplier
:
1
,
input_cost
:
0
,
output_cost
:
0
,
cache_creation_cost
:
0
,
cache_read_cost
:
0
,
input_tokens
:
0
,
output_tokens
:
0
,
}
const
wrapper
=
mount
(
UsageTable
,
{
props
:
{
data
:
[
row
],
loading
:
false
,
columns
:
[],
},
global
:
{
stubs
:
{
DataTable
:
DataTableStub
,
EmptyState
:
true
,
Icon
:
true
,
Teleport
:
true
,
},
},
})
const
text
=
wrapper
.
text
()
expect
(
text
).
toContain
(
'
claude-sonnet-4
'
)
expect
(
text
).
toContain
(
'
claude-sonnet-4-20250514
'
)
})
})
})
frontend/src/components/keys/EndpointPopover.vue
0 → 100644
View file @
08c4e514
<
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
>
frontend/src/components/keys/__tests__/EndpointPopover.spec.ts
0 → 100644
View file @
08c4e514
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
)
})
})
frontend/src/i18n/locales/en.ts
View file @
08c4e514
...
@@ -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.
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
08c4e514
...
@@ -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
:
'
填写客服联系方式,将展示在兑换页面、个人资料等位置
'
,
...
...
frontend/src/stores/app.ts
View file @
08c4e514
...
@@ -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
,
...
...
frontend/src/types/index.ts
View file @
08c4e514
...
@@ -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
...
@@ -978,7 +985,6 @@ export interface UsageLog {
...
@@ -978,7 +985,6 @@ export interface UsageLog {
account_id
:
number
|
null
account_id
:
number
|
null
request_id
:
string
request_id
:
string
model
:
string
model
:
string
upstream_model
?:
string
|
null
service_tier
?:
string
|
null
service_tier
?:
string
|
null
reasoning_effort
?:
string
|
null
reasoning_effort
?:
string
|
null
inbound_endpoint
?:
string
|
null
inbound_endpoint
?:
string
|
null
...
@@ -1033,6 +1039,8 @@ export interface UsageLogAccountSummary {
...
@@ -1033,6 +1039,8 @@ export interface UsageLogAccountSummary {
}
}
export
interface
AdminUsageLog
extends
UsageLog
{
export
interface
AdminUsageLog
extends
UsageLog
{
upstream_model
?:
string
|
null
// 账号计费倍率(仅管理员可见)
// 账号计费倍率(仅管理员可见)
account_rate_multiplier
?:
number
|
null
account_rate_multiplier
?:
number
|
null
...
...
frontend/src/views/admin/SettingsView.vue
View file @
08c4e514
...
@@ -7,7 +7,7 @@
...
@@ -7,7 +7,7 @@
</div>
</div>
<!-- Settings Form -->
<!-- Settings Form -->
<form
v-else
@
submit.prevent=
"saveSettings"
class=
"space-y-6"
>
<form
v-else
@
submit.prevent=
"saveSettings"
class=
"space-y-6"
novalidate
>
<!-- Tab Navigation -->
<!-- Tab Navigation -->
<div
class=
"sticky top-0 z-10 overflow-x-auto settings-tabs-scroll"
>
<div
class=
"sticky top-0 z-10 overflow-x-auto settings-tabs-scroll"
>
<nav
class=
"settings-tabs"
>
<nav
class=
"settings-tabs"
>
...
@@ -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
{
...
@@ -2198,6 +2283,35 @@ async function saveSettings() {
...
@@ -2198,6 +2283,35 @@ async function saveSettings() {
return
return
}
}
// Validate URL fields — novalidate disables browser-native checks, so we validate here
const
isValidHttpUrl
=
(
url
:
string
):
boolean
=>
{
if
(
!
url
)
return
true
try
{
const
u
=
new
URL
(
url
)
return
u
.
protocol
===
'
http:
'
||
u
.
protocol
===
'
https:
'
}
catch
{
return
false
}
}
// Optional URL fields: auto-clear invalid values so they don't cause backend 400 errors
if
(
!
isValidHttpUrl
(
form
.
frontend_url
))
form
.
frontend_url
=
''
if
(
!
isValidHttpUrl
(
form
.
doc_url
))
form
.
doc_url
=
''
// Purchase URL: required when enabled; auto-clear when disabled to avoid backend rejection
if
(
form
.
purchase_subscription_enabled
)
{
if
(
!
form
.
purchase_subscription_url
)
{
appStore
.
showError
(
t
(
'
admin.settings.purchase.url
'
)
+
'
: URL is required when purchase is enabled
'
)
saving
.
value
=
false
return
}
if
(
!
isValidHttpUrl
(
form
.
purchase_subscription_url
))
{
appStore
.
showError
(
t
(
'
admin.settings.purchase.url
'
)
+
'
: must be an absolute http(s) URL (e.g. https://example.com)
'
)
saving
.
value
=
false
return
}
}
else
if
(
!
isValidHttpUrl
(
form
.
purchase_subscription_url
))
{
form
.
purchase_subscription_url
=
''
}
const
payload
:
UpdateSettingsRequest
=
{
const
payload
:
UpdateSettingsRequest
=
{
registration_enabled
:
form
.
registration_enabled
,
registration_enabled
:
form
.
registration_enabled
,
email_verify_enabled
:
form
.
email_verify_enabled
,
email_verify_enabled
:
form
.
email_verify_enabled
,
...
@@ -2224,6 +2338,7 @@ async function saveSettings() {
...
@@ -2224,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
,
...
...
frontend/src/views/user/KeysView.vue
View file @
08c4e514
...
@@ -2,6 +2,7 @@
...
@@ -2,6 +2,7 @@
<AppLayout>
<AppLayout>
<TablePageLayout>
<TablePageLayout>
<template
#filters
>
<template
#filters
>
<div
class=
"flex flex-col gap-3"
>
<div
class=
"flex flex-wrap items-center gap-3"
>
<div
class=
"flex flex-wrap items-center gap-3"
>
<SearchInput
<SearchInput
v-model=
"filterSearch"
v-model=
"filterSearch"
...
@@ -22,6 +23,12 @@
...
@@ -22,6 +23,12 @@
@
update:model-value=
"onStatusFilterChange"
@
update:model-value=
"onStatusFilterChange"
/>
/>
</div>
</div>
<EndpointPopover
v-if=
"publicSettings?.api_base_url || (publicSettings?.custom_endpoints?.length ?? 0) > 0"
:api-base-url=
"publicSettings?.api_base_url || ''"
:custom-endpoints=
"publicSettings?.custom_endpoints || []"
/>
</div>
</
template
>
</
template
>
<
template
#actions
>
<
template
#actions
>
...
@@ -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
'
...
...
Prev
1
2
3
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment