Commit 5265b12c authored by Edric Li's avatar Edric Li
Browse files

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
parent e83f644c
......@@ -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)
......
......@@ -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,
......
......@@ -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")
}
......
......@@ -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"`
}
......@@ -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,
})
......
......@@ -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 服务器
......
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())
}
}
// 注册路由
......
......@@ -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" // 新用户默认并发量
......
......@@ -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],
}
// 解析整数类型
......
......@@ -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
}
......@@ -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.")
......
......@@ -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(`<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 {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
......
//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
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
......
......@@ -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',
......
......@@ -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 设置',
......
......@@ -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)
......
......@@ -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
......
......@@ -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<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
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
}
})
import type { PublicSettings } from '@/types'
declare global {
interface Window {
__APP_CONFIG__?: PublicSettings
}
}
export {}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment