fix: merge general improvements from release branch

Backend:
- gateway_handler: pass subject.UserID instead of int64(0) for user-level routing
- setting_handler: add missing BalanceLowNotifyRechargeURL to UpdateSettings response
- openai_gateway_service: use applyAccountStatsCost for account stats pricing integration
- embed_on: add local file override (data/public/) for embedded frontend assets

Frontend:
- useTableSelection: add batchUpdate method for batch operations
- AccountsView: virtual scrolling params, Set-based isSelected, swipe virtualization
- ProxiesView: add batchUpdate to selection and swipe-select
- BulkEditAccountModal: fix submit handler to prevent event object as argument
- SettingsView: move payload construction outside try block
- i18n: add general translation keys (saved, deleted, view, validation, allowUserRefund)
- api/client: reorder error fields for consistency
- stores/payment: clarify pollOrderStatus JSDoc
This commit is contained in:
erio
2026-04-14 19:29:37 +08:00
parent c14d739360
commit 63f539b382
13 changed files with 114 additions and 36 deletions

View File

@@ -1071,6 +1071,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableCCHSigning: updatedSettings.EnableCCHSigning,
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL,
AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled,
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
PaymentEnabled: updatedPaymentCfg.Enabled,

View File

@@ -522,7 +522,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
for {
// 选择支持该模型的账号
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, int64(0))
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)

View File

@@ -4575,14 +4575,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
// 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
if apiKey.GroupID != nil {
statsModel := result.UpstreamModel
if statsModel == "" {
statsModel = result.Model
}
usageLog.AccountStatsCost = resolveAccountStatsCost(
ctx, s.channelService, s.billingService,
account.ID, *apiKey.GroupID, statsModel,
tokens, 1, cost.TotalCost,
applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService,
account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model,
tokens, cost.TotalCost,
)
}

View File

@@ -10,6 +10,8 @@ import (
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -32,11 +34,12 @@ type PublicSettingsProvider interface {
// FrontendServer serves the embedded frontend with settings injection
type FrontendServer struct {
distFS fs.FS
fileServer http.Handler
baseHTML []byte
cache *HTMLCache
settings PublicSettingsProvider
distFS fs.FS
fileServer http.Handler
baseHTML []byte
cache *HTMLCache
settings PublicSettingsProvider
overrideDir string // local file override directory
}
// NewFrontendServer creates a new frontend server with settings injection
@@ -62,11 +65,12 @@ func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer
cache.SetBaseHTML(baseHTML)
return &FrontendServer{
distFS: distFS,
fileServer: http.FileServer(http.FS(distFS)),
baseHTML: baseHTML,
cache: cache,
settings: settingsProvider,
distFS: distFS,
fileServer: http.FileServer(http.FS(distFS)),
baseHTML: baseHTML,
cache: cache,
settings: settingsProvider,
overrideDir: filepath.Join("data", "public"),
}, nil
}
@@ -99,6 +103,11 @@ func (s *FrontendServer) Middleware() gin.HandlerFunc {
return
}
// Try local override first
if s.tryServeOverride(c, cleanPath) {
return
}
// Serve static files normally
s.fileServer.ServeHTTP(c.Writer, c.Request)
c.Abort()
@@ -114,6 +123,22 @@ func (s *FrontendServer) fileExists(path string) bool {
return true
}
// tryServeOverride checks if a local override file exists and serves it.
// Files in overrideDir take precedence over embedded files.
func (s *FrontendServer) tryServeOverride(c *gin.Context, cleanPath string) bool {
if s.overrideDir == "" {
return false
}
filePath := filepath.Join(s.overrideDir, filepath.Clean("/"+cleanPath))
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
return false
}
c.File(filePath)
c.Abort()
return true
}
func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
// Get nonce from context (generated by SecurityHeaders middleware)
nonce := middleware.GetNonceFromContext(c)
@@ -226,6 +251,7 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
panic("failed to get dist subdirectory: " + err.Error())
}
fileServer := http.FileServer(http.FS(distFS))
overrideDir := filepath.Join("data", "public")
return func(c *gin.Context) {
path := c.Request.URL.Path
@@ -242,6 +268,10 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
if file, err := distFS.Open(cleanPath); err == nil {
_ = file.Close()
// Try local override first
if tryServeOverrideFile(c, overrideDir, cleanPath) {
return
}
fileServer.ServeHTTP(c.Writer, c.Request)
c.Abort()
return
@@ -251,6 +281,21 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
}
}
// tryServeOverrideFile is a standalone version of tryServeOverride for legacy usage.
func tryServeOverrideFile(c *gin.Context, overrideDir, cleanPath string) bool {
if overrideDir == "" {
return false
}
filePath := filepath.Join(overrideDir, filepath.Clean("/"+cleanPath))
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
return false
}
c.File(filePath)
c.Abort()
return true
}
func shouldBypassEmbeddedFrontend(path string) bool {
trimmed := strings.TrimSpace(path)
return strings.HasPrefix(trimmed, "/api/") ||