diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 85bed3f3..1041cce1 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -145,7 +145,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) + engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, settingService) 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 84a14ca2..30a6282f 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 d95a8980..e1584acb 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 dab5eb75..c95bb6e5 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 e1b20c8c..cac79e9c 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/http.go b/backend/internal/server/http.go index a8740ecc..9fb77c61 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -30,6 +30,7 @@ func ProvideRouter( apiKeyAuth middleware2.APIKeyAuthMiddleware, apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, + settingService *service.SettingService, ) *gin.Engine { if cfg.Server.Mode == "release" { gin.SetMode(gin.ReleaseMode) @@ -47,7 +48,7 @@ func ProvideRouter( } } - return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg) + return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, settingService, cfg) } // ProvideHTTPServer 提供 HTTP 服务器 diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 15a1b325..f5e2e657 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" @@ -20,6 +22,7 @@ func SetupRouter( apiKeyAuth middleware2.APIKeyAuthMiddleware, apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, + settingService *service.SettingService, cfg *config.Config, ) *gin.Engine { // 应用中间件 @@ -27,9 +30,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 df34e167..3e083981 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -84,6 +84,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 d25698de..e8a29e02 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) (interface{}, 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 26051418..325b7f8f 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 60a42bd3..2a6f558c 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) (interface{}, 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 0ee8d614..e05bcfda 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) (interface{}, 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 00000000..28269c89 --- /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/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 2f6991e7..fc68eee4 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 e7d3a28d..051badc8 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1828,7 +1828,10 @@ 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.' }, smtp: { title: 'SMTP Settings', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fc1e6fff..8899206f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1971,7 +1971,10 @@ 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 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。' }, smtp: { title: 'SMTP 设置', diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 78aebe30..11c0b1e8 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 238982ef..2777ce73 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 @@ -311,10 +312,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 ce7081e1..55476ca0 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 00000000..138bd6e7 --- /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 bc858c6a..705ad8fb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -73,6 +73,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 7f0994ca..6a3753f1 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,6 +1,21 @@