feat(settings): add home content customization and config injection
- Add home_content setting for custom homepage (HTML or iframe URL) - Inject public settings into index.html to eliminate page flash - Support ETag caching with automatic invalidation on settings update - Add Vite plugin for dev mode settings injection - Refactor HomeView to use appStore instead of local API calls
This commit is contained in:
@@ -145,7 +145,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
||||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||||
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
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)
|
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig)
|
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig)
|
||||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||||
|
|||||||
@@ -274,6 +274,13 @@ type DatabaseConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DatabaseConfig) DSN() string {
|
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(
|
return fmt.Sprintf(
|
||||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||||
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
|
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 == "" {
|
if tz == "" {
|
||||||
tz = "Asia/Shanghai"
|
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(
|
return fmt.Sprintf(
|
||||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s",
|
"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,
|
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, tz,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
APIBaseURL: settings.APIBaseURL,
|
APIBaseURL: settings.APIBaseURL,
|
||||||
ContactInfo: settings.ContactInfo,
|
ContactInfo: settings.ContactInfo,
|
||||||
DocURL: settings.DocURL,
|
DocURL: settings.DocURL,
|
||||||
|
HomeContent: settings.HomeContent,
|
||||||
DefaultConcurrency: settings.DefaultConcurrency,
|
DefaultConcurrency: settings.DefaultConcurrency,
|
||||||
DefaultBalance: settings.DefaultBalance,
|
DefaultBalance: settings.DefaultBalance,
|
||||||
EnableModelFallback: settings.EnableModelFallback,
|
EnableModelFallback: settings.EnableModelFallback,
|
||||||
@@ -107,6 +108,7 @@ type UpdateSettingsRequest struct {
|
|||||||
APIBaseURL string `json:"api_base_url"`
|
APIBaseURL string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
DocURL string `json:"doc_url"`
|
DocURL string `json:"doc_url"`
|
||||||
|
HomeContent string `json:"home_content"`
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
DefaultConcurrency int `json:"default_concurrency"`
|
DefaultConcurrency int `json:"default_concurrency"`
|
||||||
@@ -229,6 +231,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
APIBaseURL: req.APIBaseURL,
|
APIBaseURL: req.APIBaseURL,
|
||||||
ContactInfo: req.ContactInfo,
|
ContactInfo: req.ContactInfo,
|
||||||
DocURL: req.DocURL,
|
DocURL: req.DocURL,
|
||||||
|
HomeContent: req.HomeContent,
|
||||||
DefaultConcurrency: req.DefaultConcurrency,
|
DefaultConcurrency: req.DefaultConcurrency,
|
||||||
DefaultBalance: req.DefaultBalance,
|
DefaultBalance: req.DefaultBalance,
|
||||||
EnableModelFallback: req.EnableModelFallback,
|
EnableModelFallback: req.EnableModelFallback,
|
||||||
@@ -277,6 +280,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
APIBaseURL: updatedSettings.APIBaseURL,
|
APIBaseURL: updatedSettings.APIBaseURL,
|
||||||
ContactInfo: updatedSettings.ContactInfo,
|
ContactInfo: updatedSettings.ContactInfo,
|
||||||
DocURL: updatedSettings.DocURL,
|
DocURL: updatedSettings.DocURL,
|
||||||
|
HomeContent: updatedSettings.HomeContent,
|
||||||
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
||||||
DefaultBalance: updatedSettings.DefaultBalance,
|
DefaultBalance: updatedSettings.DefaultBalance,
|
||||||
EnableModelFallback: updatedSettings.EnableModelFallback,
|
EnableModelFallback: updatedSettings.EnableModelFallback,
|
||||||
@@ -377,6 +381,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.DocURL != after.DocURL {
|
if before.DocURL != after.DocURL {
|
||||||
changed = append(changed, "doc_url")
|
changed = append(changed, "doc_url")
|
||||||
}
|
}
|
||||||
|
if before.HomeContent != after.HomeContent {
|
||||||
|
changed = append(changed, "home_content")
|
||||||
|
}
|
||||||
if before.DefaultConcurrency != after.DefaultConcurrency {
|
if before.DefaultConcurrency != after.DefaultConcurrency {
|
||||||
changed = append(changed, "default_concurrency")
|
changed = append(changed, "default_concurrency")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type SystemSettings struct {
|
|||||||
APIBaseURL string `json:"api_base_url"`
|
APIBaseURL string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
DocURL string `json:"doc_url"`
|
DocURL string `json:"doc_url"`
|
||||||
|
HomeContent string `json:"home_content"`
|
||||||
|
|
||||||
DefaultConcurrency int `json:"default_concurrency"`
|
DefaultConcurrency int `json:"default_concurrency"`
|
||||||
DefaultBalance float64 `json:"default_balance"`
|
DefaultBalance float64 `json:"default_balance"`
|
||||||
@@ -55,6 +56,7 @@ type PublicSettings struct {
|
|||||||
APIBaseURL string `json:"api_base_url"`
|
APIBaseURL string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
DocURL string `json:"doc_url"`
|
DocURL string `json:"doc_url"`
|
||||||
|
HomeContent string `json:"home_content"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
APIBaseURL: settings.APIBaseURL,
|
APIBaseURL: settings.APIBaseURL,
|
||||||
ContactInfo: settings.ContactInfo,
|
ContactInfo: settings.ContactInfo,
|
||||||
DocURL: settings.DocURL,
|
DocURL: settings.DocURL,
|
||||||
|
HomeContent: settings.HomeContent,
|
||||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
Version: h.version,
|
Version: h.version,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func ProvideRouter(
|
|||||||
apiKeyAuth middleware2.APIKeyAuthMiddleware,
|
apiKeyAuth middleware2.APIKeyAuthMiddleware,
|
||||||
apiKeyService *service.APIKeyService,
|
apiKeyService *service.APIKeyService,
|
||||||
subscriptionService *service.SubscriptionService,
|
subscriptionService *service.SubscriptionService,
|
||||||
|
settingService *service.SettingService,
|
||||||
) *gin.Engine {
|
) *gin.Engine {
|
||||||
if cfg.Server.Mode == "release" {
|
if cfg.Server.Mode == "release" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
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 服务器
|
// ProvideHTTPServer 提供 HTTP 服务器
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
@@ -20,6 +22,7 @@ func SetupRouter(
|
|||||||
apiKeyAuth middleware2.APIKeyAuthMiddleware,
|
apiKeyAuth middleware2.APIKeyAuthMiddleware,
|
||||||
apiKeyService *service.APIKeyService,
|
apiKeyService *service.APIKeyService,
|
||||||
subscriptionService *service.SubscriptionService,
|
subscriptionService *service.SubscriptionService,
|
||||||
|
settingService *service.SettingService,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
) *gin.Engine {
|
) *gin.Engine {
|
||||||
// 应用中间件
|
// 应用中间件
|
||||||
@@ -27,9 +30,17 @@ func SetupRouter(
|
|||||||
r.Use(middleware2.CORS(cfg.CORS))
|
r.Use(middleware2.CORS(cfg.CORS))
|
||||||
r.Use(middleware2.SecurityHeaders(cfg.Security.CSP))
|
r.Use(middleware2.SecurityHeaders(cfg.Security.CSP))
|
||||||
|
|
||||||
// Serve embedded frontend if available
|
// Serve embedded frontend with settings injection if available
|
||||||
if web.HasEmbeddedFrontend() {
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注册路由
|
// 注册路由
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const (
|
|||||||
SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入)
|
SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入)
|
||||||
SettingKeyContactInfo = "contact_info" // 客服联系方式
|
SettingKeyContactInfo = "contact_info" // 客服联系方式
|
||||||
SettingKeyDocURL = "doc_url" // 文档链接
|
SettingKeyDocURL = "doc_url" // 文档链接
|
||||||
|
SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
|
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type SettingRepository interface {
|
|||||||
type SettingService struct {
|
type SettingService struct {
|
||||||
settingRepo SettingRepository
|
settingRepo SettingRepository
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
onUpdate func() // Callback when settings are updated (for cache invalidation)
|
||||||
|
version string // Application version
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingService 创建系统设置服务实例
|
// NewSettingService 创建系统设置服务实例
|
||||||
@@ -65,6 +67,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyAPIBaseURL,
|
SettingKeyAPIBaseURL,
|
||||||
SettingKeyContactInfo,
|
SettingKeyContactInfo,
|
||||||
SettingKeyDocURL,
|
SettingKeyDocURL,
|
||||||
|
SettingKeyHomeContent,
|
||||||
SettingKeyLinuxDoConnectEnabled,
|
SettingKeyLinuxDoConnectEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +94,62 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||||
ContactInfo: settings[SettingKeyContactInfo],
|
ContactInfo: settings[SettingKeyContactInfo],
|
||||||
DocURL: settings[SettingKeyDocURL],
|
DocURL: settings[SettingKeyDocURL],
|
||||||
|
HomeContent: settings[SettingKeyHomeContent],
|
||||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||||
}, nil
|
}, 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 更新系统设置
|
// UpdateSettings 更新系统设置
|
||||||
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
|
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
|
||||||
updates := make(map[string]string)
|
updates := make(map[string]string)
|
||||||
@@ -136,6 +191,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|||||||
updates[SettingKeyAPIBaseURL] = settings.APIBaseURL
|
updates[SettingKeyAPIBaseURL] = settings.APIBaseURL
|
||||||
updates[SettingKeyContactInfo] = settings.ContactInfo
|
updates[SettingKeyContactInfo] = settings.ContactInfo
|
||||||
updates[SettingKeyDocURL] = settings.DocURL
|
updates[SettingKeyDocURL] = settings.DocURL
|
||||||
|
updates[SettingKeyHomeContent] = settings.HomeContent
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
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[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch)
|
||||||
updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt
|
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 检查是否开放注册
|
// IsRegistrationEnabled 检查是否开放注册
|
||||||
@@ -263,6 +323,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||||
ContactInfo: settings[SettingKeyContactInfo],
|
ContactInfo: settings[SettingKeyContactInfo],
|
||||||
DocURL: settings[SettingKeyDocURL],
|
DocURL: settings[SettingKeyDocURL],
|
||||||
|
HomeContent: settings[SettingKeyHomeContent],
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析整数类型
|
// 解析整数类型
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type SystemSettings struct {
|
|||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
ContactInfo string
|
ContactInfo string
|
||||||
DocURL string
|
DocURL string
|
||||||
|
HomeContent string
|
||||||
|
|
||||||
DefaultConcurrency int
|
DefaultConcurrency int
|
||||||
DefaultBalance float64
|
DefaultBalance float64
|
||||||
@@ -58,6 +59,7 @@ type PublicSettings struct {
|
|||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
ContactInfo string
|
ContactInfo string
|
||||||
DocURL string
|
DocURL string
|
||||||
|
HomeContent string
|
||||||
LinuxDoOAuthEnabled bool
|
LinuxDoOAuthEnabled bool
|
||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,38 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 {
|
func ServeEmbeddedFrontend() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.String(http.StatusNotFound, "Frontend not embedded. Build with -tags embed to include frontend.")
|
c.String(http.StatusNotFound, "Frontend not embedded. Build with -tags embed to include frontend.")
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -15,6 +19,162 @@ import (
|
|||||||
//go:embed all:dist
|
//go:embed all:dist
|
||||||
var frontendFS embed.FS
|
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(`<script>window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`)
|
||||||
|
|
||||||
|
// Inject before </head>
|
||||||
|
headClose := []byte("</head>")
|
||||||
|
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 {
|
func ServeEmbeddedFrontend() gin.HandlerFunc {
|
||||||
distFS, err := fs.Sub(frontendFS, "dist")
|
distFS, err := fs.Sub(frontendFS, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
77
backend/internal/web/html_cache.go
Normal file
77
backend/internal/web/html_cache.go
Normal file
@@ -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]) + `"`
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ export interface SystemSettings {
|
|||||||
api_base_url: string
|
api_base_url: string
|
||||||
contact_info: string
|
contact_info: string
|
||||||
doc_url: string
|
doc_url: string
|
||||||
|
home_content: string
|
||||||
// SMTP settings
|
// SMTP settings
|
||||||
smtp_host: string
|
smtp_host: string
|
||||||
smtp_port: number
|
smtp_port: number
|
||||||
@@ -55,6 +56,7 @@ export interface UpdateSettingsRequest {
|
|||||||
api_base_url?: string
|
api_base_url?: string
|
||||||
contact_info?: string
|
contact_info?: string
|
||||||
doc_url?: string
|
doc_url?: string
|
||||||
|
home_content?: string
|
||||||
smtp_host?: string
|
smtp_host?: string
|
||||||
smtp_port?: number
|
smtp_port?: number
|
||||||
smtp_username?: string
|
smtp_username?: string
|
||||||
|
|||||||
@@ -1828,7 +1828,10 @@ export default {
|
|||||||
logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.',
|
logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.',
|
||||||
logoSizeError: 'Image size exceeds 300KB limit ({size}KB)',
|
logoSizeError: 'Image size exceeds 300KB limit ({size}KB)',
|
||||||
logoTypeError: 'Please select an image file',
|
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: {
|
smtp: {
|
||||||
title: 'SMTP Settings',
|
title: 'SMTP Settings',
|
||||||
|
|||||||
@@ -1971,7 +1971,10 @@ export default {
|
|||||||
logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。',
|
logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。',
|
||||||
logoSizeError: '图片大小超过 300KB 限制({size}KB)',
|
logoSizeError: '图片大小超过 300KB 限制({size}KB)',
|
||||||
logoTypeError: '请选择图片文件',
|
logoTypeError: '请选择图片文件',
|
||||||
logoReadError: '读取图片文件失败'
|
logoReadError: '读取图片文件失败',
|
||||||
|
homeContent: '首页内容',
|
||||||
|
homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
|
||||||
|
homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。'
|
||||||
},
|
},
|
||||||
smtp: {
|
smtp: {
|
||||||
title: 'SMTP 设置',
|
title: 'SMTP 设置',
|
||||||
|
|||||||
@@ -6,7 +6,20 @@ import i18n from './i18n'
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
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(router)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route definitions with lazy loading
|
* Route definitions with lazy loading
|
||||||
@@ -311,10 +312,12 @@ router.beforeEach((to, _from, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set page title
|
// Set page title
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const siteName = appStore.siteName || 'Sub2API'
|
||||||
if (to.meta.title) {
|
if (to.meta.title) {
|
||||||
document.title = `${to.meta.title} - Sub2API`
|
document.title = `${to.meta.title} - ${siteName}`
|
||||||
} else {
|
} else {
|
||||||
document.title = 'Sub2API'
|
document.title = siteName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if route requires authentication
|
// Check if route requires authentication
|
||||||
|
|||||||
@@ -279,11 +279,31 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
// ==================== Public Settings Management ====================
|
// ==================== 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)
|
* Fetch public settings (uses cache unless force=true)
|
||||||
* @param force - Force refresh from API
|
* @param force - Force refresh from API
|
||||||
*/
|
*/
|
||||||
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
|
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
|
||||||
|
// 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
|
// Return cached data if available and not forcing refresh
|
||||||
if (publicSettingsLoaded.value && !force) {
|
if (publicSettingsLoaded.value && !force) {
|
||||||
if (cachedPublicSettings.value) {
|
if (cachedPublicSettings.value) {
|
||||||
@@ -300,6 +320,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
api_base_url: apiBaseUrl.value,
|
api_base_url: apiBaseUrl.value,
|
||||||
contact_info: contactInfo.value,
|
contact_info: contactInfo.value,
|
||||||
doc_url: docUrl.value,
|
doc_url: docUrl.value,
|
||||||
|
home_content: '',
|
||||||
linuxdo_oauth_enabled: false,
|
linuxdo_oauth_enabled: false,
|
||||||
version: siteVersion.value
|
version: siteVersion.value
|
||||||
}
|
}
|
||||||
@@ -313,14 +334,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
publicSettingsLoading.value = true
|
publicSettingsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await fetchPublicSettingsAPI()
|
const data = await fetchPublicSettingsAPI()
|
||||||
cachedPublicSettings.value = data
|
applySettings(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
|
|
||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch public settings:', error)
|
console.error('Failed to fetch public settings:', error)
|
||||||
@@ -338,6 +352,19 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
cachedPublicSettings.value = null
|
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 Store API ====================
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -355,6 +382,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
contactInfo,
|
contactInfo,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
docUrl,
|
docUrl,
|
||||||
|
cachedPublicSettings,
|
||||||
|
|
||||||
// Version state
|
// Version state
|
||||||
versionLoaded,
|
versionLoaded,
|
||||||
@@ -391,6 +419,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
// Public settings actions
|
// Public settings actions
|
||||||
fetchPublicSettings,
|
fetchPublicSettings,
|
||||||
clearPublicSettingsCache
|
clearPublicSettingsCache,
|
||||||
|
initFromInjectedConfig
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
9
frontend/src/types/global.d.ts
vendored
Normal file
9
frontend/src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { PublicSettings } from '@/types'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__APP_CONFIG__?: PublicSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -73,6 +73,7 @@ export interface PublicSettings {
|
|||||||
api_base_url: string
|
api_base_url: string
|
||||||
contact_info: string
|
contact_info: string
|
||||||
doc_url: string
|
doc_url: string
|
||||||
|
home_content: string
|
||||||
linuxdo_oauth_enabled: boolean
|
linuxdo_oauth_enabled: boolean
|
||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- Custom Home Content: Full Page Mode -->
|
||||||
|
<div v-if="homeContent" class="min-h-screen">
|
||||||
|
<!-- iframe mode -->
|
||||||
|
<iframe
|
||||||
|
v-if="isHomeContentUrl"
|
||||||
|
:src="homeContent.trim()"
|
||||||
|
class="h-screen w-full border-0"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
<!-- HTML mode - SECURITY: homeContent is admin-only setting, XSS risk is acceptable -->
|
||||||
|
<div v-else v-html="homeContent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default Home Page -->
|
||||||
<div
|
<div
|
||||||
class="relative min-h-screen overflow-hidden bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
|
v-else
|
||||||
|
class="relative flex min-h-screen flex-col overflow-hidden bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
|
||||||
>
|
>
|
||||||
<!-- Background Decorations -->
|
<!-- Background Decorations -->
|
||||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
@@ -96,7 +111,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="relative z-10 px-6 py-16">
|
<main class="relative z-10 flex-1 px-6 py-16">
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<!-- Hero Section - Left/Right Layout -->
|
<!-- Hero Section - Left/Right Layout -->
|
||||||
<div class="mb-12 flex flex-col items-center justify-between gap-12 lg:flex-row lg:gap-16">
|
<div class="mb-12 flex flex-col items-center justify-between gap-12 lg:flex-row lg:gap-16">
|
||||||
@@ -392,21 +407,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { getPublicSettings } from '@/api/auth'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
import { useAuthStore } from '@/stores'
|
|
||||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { sanitizeUrl } from '@/utils/url'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
// Site settings
|
// Site settings - directly from appStore (already initialized from injected config)
|
||||||
const siteName = ref('Sub2API')
|
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
|
||||||
const siteLogo = ref('')
|
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
|
||||||
const siteSubtitle = ref('AI API Gateway Platform')
|
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
|
||||||
const docUrl = ref('')
|
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
|
||||||
|
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
|
||||||
|
|
||||||
|
// Check if homeContent is a URL (for iframe display)
|
||||||
|
const isHomeContentUrl = computed(() => {
|
||||||
|
const content = homeContent.value.trim()
|
||||||
|
return content.startsWith('http://') || content.startsWith('https://')
|
||||||
|
})
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||||
@@ -446,20 +467,15 @@ function initTheme() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
initTheme()
|
initTheme()
|
||||||
|
|
||||||
// Check auth state
|
// Check auth state
|
||||||
authStore.checkAuth()
|
authStore.checkAuth()
|
||||||
|
|
||||||
try {
|
// Ensure public settings are loaded (will use cache if already loaded from injected config)
|
||||||
const settings = await getPublicSettings()
|
if (!appStore.publicSettingsLoaded) {
|
||||||
siteName.value = settings.site_name || 'Sub2API'
|
appStore.fetchPublicSettings()
|
||||||
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
|
|
||||||
siteSubtitle.value = settings.site_subtitle || 'AI API Gateway Platform'
|
|
||||||
docUrl.value = sanitizeUrl(settings.doc_url || '', { allowRelative: true })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load public settings:', error)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -562,6 +562,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Home Content -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.site.homeContent') }}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.home_content"
|
||||||
|
rows="6"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.site.homeContentPlaceholder')"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.site.homeContentHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -837,6 +853,7 @@ const form = reactive<SettingsForm>({
|
|||||||
api_base_url: '',
|
api_base_url: '',
|
||||||
contact_info: '',
|
contact_info: '',
|
||||||
doc_url: '',
|
doc_url: '',
|
||||||
|
home_content: '',
|
||||||
smtp_host: '',
|
smtp_host: '',
|
||||||
smtp_port: 587,
|
smtp_port: 587,
|
||||||
smtp_username: '',
|
smtp_username: '',
|
||||||
@@ -945,6 +962,7 @@ async function saveSettings() {
|
|||||||
api_base_url: form.api_base_url,
|
api_base_url: form.api_base_url,
|
||||||
contact_info: form.contact_info,
|
contact_info: form.contact_info,
|
||||||
doc_url: form.doc_url,
|
doc_url: form.doc_url,
|
||||||
|
home_content: form.home_content,
|
||||||
smtp_host: form.smtp_host,
|
smtp_host: form.smtp_host,
|
||||||
smtp_port: form.smtp_port,
|
smtp_port: form.smtp_port,
|
||||||
smtp_username: form.smtp_username,
|
smtp_username: form.smtp_username,
|
||||||
|
|||||||
@@ -1,8 +1,39 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, Plugin } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import checker from 'vite-plugin-checker'
|
import checker from 'vite-plugin-checker'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite 插件:开发模式下注入公开配置到 index.html
|
||||||
|
* 与生产模式的后端注入行为保持一致,消除闪烁
|
||||||
|
*/
|
||||||
|
function injectPublicSettings(): Plugin {
|
||||||
|
const backendUrl = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080'
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'inject-public-settings',
|
||||||
|
transformIndexHtml: {
|
||||||
|
order: 'pre',
|
||||||
|
async handler(html) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${backendUrl}/api/v1/settings/public`, {
|
||||||
|
signal: AbortSignal.timeout(2000)
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.code === 0 && data.data) {
|
||||||
|
const script = `<script>window.__APP_CONFIG__=${JSON.stringify(data.data)};</script>`
|
||||||
|
return html.replace('</head>', `${script}\n</head>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[vite] 无法获取公开配置,将回退到 API 调用:', (e as Error).message)
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -10,7 +41,8 @@ export default defineConfig({
|
|||||||
checker({
|
checker({
|
||||||
typescript: true,
|
typescript: true,
|
||||||
vueTsc: true
|
vueTsc: true
|
||||||
})
|
}),
|
||||||
|
injectPublicSettings()
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user