diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 30ea0fdb7b..58f8cebf68 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -148,7 +148,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) - engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, redisClient) + engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, settingService, redisClient) httpServer := server.ProvideHTTPServer(configConfig, engine) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2cc1196760..40344cd411 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -274,6 +274,13 @@ type DatabaseConfig struct { } func (d *DatabaseConfig) DSN() string { + // 当密码为空时不包含 password 参数,避免 libpq 解析错误 + if d.Password == "" { + return fmt.Sprintf( + "host=%s port=%d user=%s dbname=%s sslmode=%s", + d.Host, d.Port, d.User, d.DBName, d.SSLMode, + ) + } return fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, @@ -285,6 +292,13 @@ func (d *DatabaseConfig) DSNWithTimezone(tz string) string { if tz == "" { tz = "Asia/Shanghai" } + // 当密码为空时不包含 password 参数,避免 libpq 解析错误 + if d.Password == "" { + return fmt.Sprintf( + "host=%s port=%d user=%s dbname=%s sslmode=%s TimeZone=%s", + d.Host, d.Port, d.User, d.DBName, d.SSLMode, tz, + ) + } return fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s", d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, tz, diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index d95a898062..e1584acb26 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -62,6 +62,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { APIBaseURL: settings.APIBaseURL, ContactInfo: settings.ContactInfo, DocURL: settings.DocURL, + HomeContent: settings.HomeContent, DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, EnableModelFallback: settings.EnableModelFallback, @@ -107,6 +108,7 @@ type UpdateSettingsRequest struct { APIBaseURL string `json:"api_base_url"` ContactInfo string `json:"contact_info"` DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` // 默认配置 DefaultConcurrency int `json:"default_concurrency"` @@ -229,6 +231,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { APIBaseURL: req.APIBaseURL, ContactInfo: req.ContactInfo, DocURL: req.DocURL, + HomeContent: req.HomeContent, DefaultConcurrency: req.DefaultConcurrency, DefaultBalance: req.DefaultBalance, EnableModelFallback: req.EnableModelFallback, @@ -277,6 +280,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { APIBaseURL: updatedSettings.APIBaseURL, ContactInfo: updatedSettings.ContactInfo, DocURL: updatedSettings.DocURL, + HomeContent: updatedSettings.HomeContent, DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultBalance: updatedSettings.DefaultBalance, EnableModelFallback: updatedSettings.EnableModelFallback, @@ -377,6 +381,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.DocURL != after.DocURL { changed = append(changed, "doc_url") } + if before.HomeContent != after.HomeContent { + changed = append(changed, "home_content") + } if before.DefaultConcurrency != after.DefaultConcurrency { changed = append(changed, "default_concurrency") } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index dab5eb75b8..c95bb6e553 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -28,6 +28,7 @@ type SystemSettings struct { APIBaseURL string `json:"api_base_url"` ContactInfo string `json:"contact_info"` DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` @@ -55,6 +56,7 @@ type PublicSettings struct { APIBaseURL string `json:"api_base_url"` ContactInfo string `json:"contact_info"` DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` Version string `json:"version"` } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index e1b20c8ce9..cac79e9cbc 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -42,6 +42,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { APIBaseURL: settings.APIBaseURL, ContactInfo: settings.ContactInfo, DocURL: settings.DocURL, + HomeContent: settings.HomeContent, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, Version: h.version, }) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 41d8bfdb76..aa5c6a3e58 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -326,7 +326,8 @@ func TestAPIContracts(t *testing.T) { "fallback_model_gemini": "gemini-2.5-pro", "fallback_model_openai": "gpt-4o", "enable_identity_patch": true, - "identity_patch_prompt": "" + "identity_patch_prompt": "", + "home_content": "" } }`, }, diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index 90955867a2..a7d1d3b52f 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -31,6 +31,7 @@ func ProvideRouter( apiKeyAuth middleware2.APIKeyAuthMiddleware, apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, + settingService *service.SettingService, redisClient *redis.Client, ) *gin.Engine { if cfg.Server.Mode == "release" { @@ -49,7 +50,7 @@ func ProvideRouter( } } - return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg, redisClient) + return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, settingService, cfg, redisClient) } // ProvideHTTPServer 提供 HTTP 服务器 diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 2c0852a47a..70f7da844f 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -1,6 +1,8 @@ package server import ( + "log" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -21,6 +23,7 @@ func SetupRouter( apiKeyAuth middleware2.APIKeyAuthMiddleware, apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, + settingService *service.SettingService, cfg *config.Config, redisClient *redis.Client, ) *gin.Engine { @@ -29,9 +32,17 @@ func SetupRouter( r.Use(middleware2.CORS(cfg.CORS)) r.Use(middleware2.SecurityHeaders(cfg.Security.CSP)) - // Serve embedded frontend if available + // Serve embedded frontend with settings injection if available if web.HasEmbeddedFrontend() { - r.Use(web.ServeEmbeddedFrontend()) + frontendServer, err := web.NewFrontendServer(settingService) + if err != nil { + log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err) + r.Use(web.ServeEmbeddedFrontend()) + } else { + // Register cache invalidation callback + settingService.SetOnUpdateCallback(frontendServer.InvalidateCache) + r.Use(frontendServer.Middleware()) + } } // 注册路由 diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 9014670dc5..77709553ed 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -90,6 +90,7 @@ const ( SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入) SettingKeyContactInfo = "contact_info" // 客服联系方式 SettingKeyDocURL = "doc_url" // 文档链接 + SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src) // 默认配置 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index d25698dec4..3e47d9d47a 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -32,6 +32,8 @@ type SettingRepository interface { type SettingService struct { settingRepo SettingRepository cfg *config.Config + onUpdate func() // Callback when settings are updated (for cache invalidation) + version string // Application version } // NewSettingService 创建系统设置服务实例 @@ -65,6 +67,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyAPIBaseURL, SettingKeyContactInfo, SettingKeyDocURL, + SettingKeyHomeContent, SettingKeyLinuxDoConnectEnabled, } @@ -91,10 +94,62 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings APIBaseURL: settings[SettingKeyAPIBaseURL], ContactInfo: settings[SettingKeyContactInfo], DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], LinuxDoOAuthEnabled: linuxDoEnabled, }, nil } +// SetOnUpdateCallback sets a callback function to be called when settings are updated +// This is used for cache invalidation (e.g., HTML cache in frontend server) +func (s *SettingService) SetOnUpdateCallback(callback func()) { + s.onUpdate = callback +} + +// SetVersion sets the application version for injection into public settings +func (s *SettingService) SetVersion(version string) { + s.version = version +} + +// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection +// This implements the web.PublicSettingsProvider interface +func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) { + settings, err := s.GetPublicSettings(ctx) + if err != nil { + return nil, err + } + + // Return a struct that matches the frontend's expected format + return &struct { + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo,omitempty"` + SiteSubtitle string `json:"site_subtitle,omitempty"` + APIBaseURL string `json:"api_base_url,omitempty"` + ContactInfo string `json:"contact_info,omitempty"` + DocURL string `json:"doc_url,omitempty"` + HomeContent string `json:"home_content,omitempty"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + Version string `json:"version,omitempty"` + }{ + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + Version: s.version, + }, nil +} + // UpdateSettings 更新系统设置 func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { updates := make(map[string]string) @@ -136,6 +191,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyAPIBaseURL] = settings.APIBaseURL updates[SettingKeyContactInfo] = settings.ContactInfo updates[SettingKeyDocURL] = settings.DocURL + updates[SettingKeyHomeContent] = settings.HomeContent // 默认配置 updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) @@ -152,7 +208,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch) updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt - return s.settingRepo.SetMultiple(ctx, updates) + err := s.settingRepo.SetMultiple(ctx, updates) + if err == nil && s.onUpdate != nil { + s.onUpdate() // Invalidate cache after settings update + } + return err } // IsRegistrationEnabled 检查是否开放注册 @@ -263,6 +323,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin APIBaseURL: settings[SettingKeyAPIBaseURL], ContactInfo: settings[SettingKeyContactInfo], DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], } // 解析整数类型 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 26051418cf..325b7f8f0d 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -31,6 +31,7 @@ type SystemSettings struct { APIBaseURL string ContactInfo string DocURL string + HomeContent string DefaultConcurrency int DefaultBalance float64 @@ -58,6 +59,7 @@ type PublicSettings struct { APIBaseURL string ContactInfo string DocURL string + HomeContent string LinuxDoOAuthEnabled bool Version string } diff --git a/backend/internal/web/embed_off.go b/backend/internal/web/embed_off.go index 60a42bd35a..346c31e92f 100644 --- a/backend/internal/web/embed_off.go +++ b/backend/internal/web/embed_off.go @@ -4,11 +4,38 @@ package web import ( + "context" + "errors" "net/http" "github.com/gin-gonic/gin" ) +// PublicSettingsProvider is an interface to fetch public settings +// This stub is needed for compilation when frontend is not embedded +type PublicSettingsProvider interface { + GetPublicSettingsForInjection(ctx context.Context) (any, error) +} + +// FrontendServer is a stub for non-embed builds +type FrontendServer struct{} + +// NewFrontendServer returns an error when frontend is not embedded +func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer, error) { + return nil, errors.New("frontend not embedded") +} + +// InvalidateCache is a no-op for non-embed builds +func (s *FrontendServer) InvalidateCache() {} + +// Middleware returns a handler that returns 404 for non-embed builds +func (s *FrontendServer) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.String(http.StatusNotFound, "Frontend not embedded. Build with -tags embed to include frontend.") + c.Abort() + } +} + func ServeEmbeddedFrontend() gin.HandlerFunc { return func(c *gin.Context) { c.String(http.StatusNotFound, "Frontend not embedded. Build with -tags embed to include frontend.") diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go index 0ee8d6141d..35697fbbb6 100644 --- a/backend/internal/web/embed_on.go +++ b/backend/internal/web/embed_on.go @@ -3,11 +3,15 @@ package web import ( + "bytes" + "context" "embed" + "encoding/json" "io" "io/fs" "net/http" "strings" + "time" "github.com/gin-gonic/gin" ) @@ -15,6 +19,162 @@ import ( //go:embed all:dist var frontendFS embed.FS +// PublicSettingsProvider is an interface to fetch public settings +type PublicSettingsProvider interface { + GetPublicSettingsForInjection(ctx context.Context) (any, error) +} + +// FrontendServer serves the embedded frontend with settings injection +type FrontendServer struct { + distFS fs.FS + fileServer http.Handler + baseHTML []byte + cache *HTMLCache + settings PublicSettingsProvider +} + +// NewFrontendServer creates a new frontend server with settings injection +func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer, error) { + distFS, err := fs.Sub(frontendFS, "dist") + if err != nil { + return nil, err + } + + // Read base HTML once + file, err := distFS.Open("index.html") + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + baseHTML, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + cache := NewHTMLCache() + cache.SetBaseHTML(baseHTML) + + return &FrontendServer{ + distFS: distFS, + fileServer: http.FileServer(http.FS(distFS)), + baseHTML: baseHTML, + cache: cache, + settings: settingsProvider, + }, nil +} + +// InvalidateCache invalidates the HTML cache (call when settings change) +func (s *FrontendServer) InvalidateCache() { + if s != nil && s.cache != nil { + s.cache.Invalidate() + } +} + +// Middleware returns the Gin middleware handler +func (s *FrontendServer) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Request.URL.Path + + // Skip API routes + if strings.HasPrefix(path, "/api/") || + strings.HasPrefix(path, "/v1/") || + strings.HasPrefix(path, "/v1beta/") || + strings.HasPrefix(path, "/antigravity/") || + strings.HasPrefix(path, "/setup/") || + path == "/health" || + path == "/responses" { + c.Next() + return + } + + cleanPath := strings.TrimPrefix(path, "/") + if cleanPath == "" { + cleanPath = "index.html" + } + + // For index.html or SPA routes, serve with injected settings + if cleanPath == "index.html" || !s.fileExists(cleanPath) { + s.serveIndexHTML(c) + return + } + + // Serve static files normally + s.fileServer.ServeHTTP(c.Writer, c.Request) + c.Abort() + } +} + +func (s *FrontendServer) fileExists(path string) bool { + file, err := s.distFS.Open(path) + if err != nil { + return false + } + _ = file.Close() + return true +} + +func (s *FrontendServer) serveIndexHTML(c *gin.Context) { + // Check cache first + cached := s.cache.Get() + if cached != nil { + // Check If-None-Match for 304 response + if match := c.GetHeader("If-None-Match"); match == cached.ETag { + c.Status(http.StatusNotModified) + c.Abort() + return + } + + c.Header("ETag", cached.ETag) + c.Header("Cache-Control", "no-cache") // Must revalidate + c.Data(http.StatusOK, "text/html; charset=utf-8", cached.Content) + c.Abort() + return + } + + // Cache miss - fetch settings and render + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + + settings, err := s.settings.GetPublicSettingsForInjection(ctx) + if err != nil { + // Fallback: serve without injection + c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML) + c.Abort() + return + } + + settingsJSON, err := json.Marshal(settings) + if err != nil { + // Fallback: serve without injection + c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML) + c.Abort() + return + } + + rendered := s.injectSettings(settingsJSON) + s.cache.Set(rendered, settingsJSON) + + cached = s.cache.Get() + if cached != nil { + c.Header("ETag", cached.ETag) + } + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "text/html; charset=utf-8", rendered) + c.Abort() +} + +func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte { + // Create the script tag to inject + script := []byte(``) + + // Inject before + headClose := []byte("") + return bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1) +} + +// ServeEmbeddedFrontend returns a middleware for serving embedded frontend +// This is the legacy function for backward compatibility when no settings provider is available func ServeEmbeddedFrontend() gin.HandlerFunc { distFS, err := fs.Sub(frontendFS, "dist") if err != nil { diff --git a/backend/internal/web/html_cache.go b/backend/internal/web/html_cache.go new file mode 100644 index 0000000000..28269c89c2 --- /dev/null +++ b/backend/internal/web/html_cache.go @@ -0,0 +1,77 @@ +//go:build embed + +package web + +import ( + "crypto/sha256" + "encoding/hex" + "sync" +) + +// HTMLCache manages the cached index.html with injected settings +type HTMLCache struct { + mu sync.RWMutex + cachedHTML []byte + etag string + baseHTMLHash string // Hash of the original index.html (immutable after build) + settingsVersion uint64 // Incremented when settings change +} + +// CachedHTML represents the cache state +type CachedHTML struct { + Content []byte + ETag string +} + +// NewHTMLCache creates a new HTML cache instance +func NewHTMLCache() *HTMLCache { + return &HTMLCache{} +} + +// SetBaseHTML initializes the cache with the base HTML template +func (c *HTMLCache) SetBaseHTML(baseHTML []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + hash := sha256.Sum256(baseHTML) + c.baseHTMLHash = hex.EncodeToString(hash[:8]) // First 8 bytes for brevity +} + +// Invalidate marks the cache as stale +func (c *HTMLCache) Invalidate() { + c.mu.Lock() + defer c.mu.Unlock() + + c.settingsVersion++ + c.cachedHTML = nil + c.etag = "" +} + +// Get returns the cached HTML or nil if cache is stale +func (c *HTMLCache) Get() *CachedHTML { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cachedHTML == nil { + return nil + } + return &CachedHTML{ + Content: c.cachedHTML, + ETag: c.etag, + } +} + +// Set updates the cache with new rendered HTML +func (c *HTMLCache) Set(html []byte, settingsJSON []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + c.cachedHTML = html + c.etag = c.generateETag(settingsJSON) +} + +// generateETag creates an ETag from base HTML hash + settings hash +func (c *HTMLCache) generateETag(settingsJSON []byte) string { + settingsHash := sha256.Sum256(settingsJSON) + return `"` + c.baseHTMLHash + "-" + hex.EncodeToString(settingsHash[:8]) + `"` +} diff --git a/backend/repository.test b/backend/repository.test new file mode 100755 index 0000000000..9ecc014cf2 Binary files /dev/null and b/backend/repository.test differ diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 2f6991e7d1..fc68eee412 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -22,6 +22,7 @@ export interface SystemSettings { api_base_url: string contact_info: string doc_url: string + home_content: string // SMTP settings smtp_host: string smtp_port: number @@ -55,6 +56,7 @@ export interface UpdateSettingsRequest { api_base_url?: string contact_info?: string doc_url?: string + home_content?: string smtp_host?: string smtp_port?: number smtp_username?: string diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c9633e385c..babe31e7ce 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1900,7 +1900,11 @@ export default { logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.', logoSizeError: 'Image size exceeds 300KB limit ({size}KB)', logoTypeError: 'Please select an image file', - logoReadError: 'Failed to read the image file' + logoReadError: 'Failed to read the image file', + homeContent: 'Home Page Content', + homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.', + homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.', + homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.' }, smtp: { title: 'SMTP Settings', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 6571d0e592..889c2463c6 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2043,7 +2043,11 @@ export default { logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。', logoSizeError: '图片大小超过 300KB 限制({size}KB)', logoTypeError: '请选择图片文件', - logoReadError: '读取图片文件失败' + logoReadError: '读取图片文件失败', + homeContent: '首页内容', + homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。', + homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。', + homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。' }, smtp: { title: 'SMTP 设置', diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 78aebe3080..11c0b1e8ae 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,7 +6,20 @@ import i18n from './i18n' import './style.css' const app = createApp(App) -app.use(createPinia()) +const pinia = createPinia() +app.use(pinia) + +// Initialize settings from injected config BEFORE mounting (prevents flash) +// This must happen after pinia is installed but before router and i18n +import { useAppStore } from '@/stores/app' +const appStore = useAppStore() +appStore.initFromInjectedConfig() + +// Set document title immediately after config is loaded +if (appStore.siteName && appStore.siteName !== 'Sub2API') { + document.title = `${appStore.siteName} - AI API Gateway` +} + app.use(router) app.use(i18n) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6886704d5e..7a8f226863 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -5,6 +5,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { useAppStore } from '@/stores/app' /** * Route definitions with lazy loading @@ -323,10 +324,12 @@ router.beforeEach((to, _from, next) => { } // Set page title + const appStore = useAppStore() + const siteName = appStore.siteName || 'Sub2API' if (to.meta.title) { - document.title = `${to.meta.title} - Sub2API` + document.title = `${to.meta.title} - ${siteName}` } else { - document.title = 'Sub2API' + document.title = siteName } // Check if route requires authentication diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index ce7081e13c..55476ca06d 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -279,11 +279,31 @@ export const useAppStore = defineStore('app', () => { // ==================== Public Settings Management ==================== + /** + * Apply settings to store state (internal helper to avoid code duplication) + */ + function applySettings(config: PublicSettings): void { + cachedPublicSettings.value = config + siteName.value = config.site_name || 'Sub2API' + siteLogo.value = config.site_logo || '' + siteVersion.value = config.version || '' + contactInfo.value = config.contact_info || '' + apiBaseUrl.value = config.api_base_url || '' + docUrl.value = config.doc_url || '' + publicSettingsLoaded.value = true + } + /** * Fetch public settings (uses cache unless force=true) * @param force - Force refresh from API */ async function fetchPublicSettings(force = false): Promise { + // Check for injected config from server (eliminates flash) + if (!publicSettingsLoaded.value && !force && window.__APP_CONFIG__) { + applySettings(window.__APP_CONFIG__) + return window.__APP_CONFIG__ + } + // Return cached data if available and not forcing refresh if (publicSettingsLoaded.value && !force) { if (cachedPublicSettings.value) { @@ -300,6 +320,7 @@ export const useAppStore = defineStore('app', () => { api_base_url: apiBaseUrl.value, contact_info: contactInfo.value, doc_url: docUrl.value, + home_content: '', linuxdo_oauth_enabled: false, version: siteVersion.value } @@ -313,14 +334,7 @@ export const useAppStore = defineStore('app', () => { publicSettingsLoading.value = true try { const data = await fetchPublicSettingsAPI() - cachedPublicSettings.value = data - siteName.value = data.site_name || 'Sub2API' - siteLogo.value = data.site_logo || '' - siteVersion.value = data.version || '' - contactInfo.value = data.contact_info || '' - apiBaseUrl.value = data.api_base_url || '' - docUrl.value = data.doc_url || '' - publicSettingsLoaded.value = true + applySettings(data) return data } catch (error) { console.error('Failed to fetch public settings:', error) @@ -338,6 +352,19 @@ export const useAppStore = defineStore('app', () => { cachedPublicSettings.value = null } + /** + * Initialize settings from injected config (window.__APP_CONFIG__) + * This is called synchronously before Vue app mounts to prevent flash + * @returns true if config was found and applied, false otherwise + */ + function initFromInjectedConfig(): boolean { + if (window.__APP_CONFIG__) { + applySettings(window.__APP_CONFIG__) + return true + } + return false + } + // ==================== Return Store API ==================== return { @@ -355,6 +382,7 @@ export const useAppStore = defineStore('app', () => { contactInfo, apiBaseUrl, docUrl, + cachedPublicSettings, // Version state versionLoaded, @@ -391,6 +419,7 @@ export const useAppStore = defineStore('app', () => { // Public settings actions fetchPublicSettings, - clearPublicSettingsCache + clearPublicSettingsCache, + initFromInjectedConfig } }) diff --git a/frontend/src/types/global.d.ts b/frontend/src/types/global.d.ts new file mode 100644 index 0000000000..138bd6e7d2 --- /dev/null +++ b/frontend/src/types/global.d.ts @@ -0,0 +1,9 @@ +import type { PublicSettings } from '@/types' + +declare global { + interface Window { + __APP_CONFIG__?: PublicSettings + } +} + +export {} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3d1b17f6b0..40cb7c4d8a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -74,6 +74,7 @@ export interface PublicSettings { api_base_url: string contact_info: string doc_url: string + home_content: string linuxdo_oauth_enabled: boolean version: string } diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 7f0994ca51..6a3753f1c1 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,6 +1,21 @@