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:
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
linuxdo_oauth_enabled: boolean
|
||||
version: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<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
|
||||
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 -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
@@ -96,7 +111,7 @@
|
||||
</header>
|
||||
|
||||
<!-- 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">
|
||||
<!-- Hero Section - Left/Right Layout -->
|
||||
<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">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Site settings
|
||||
const siteName = ref('Sub2API')
|
||||
const siteLogo = ref('')
|
||||
const siteSubtitle = ref('AI API Gateway Platform')
|
||||
const docUrl = ref('')
|
||||
// Site settings - directly from appStore (already initialized from injected config)
|
||||
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
|
||||
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
|
||||
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
|
||||
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
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
@@ -446,20 +467,15 @@ function initTheme() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
|
||||
// Check auth state
|
||||
authStore.checkAuth()
|
||||
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
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)
|
||||
// Ensure public settings are loaded (will use cache if already loaded from injected config)
|
||||
if (!appStore.publicSettingsLoaded) {
|
||||
appStore.fetchPublicSettings()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -562,6 +562,22 @@
|
||||
</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>
|
||||
|
||||
@@ -837,6 +853,7 @@ const form = reactive<SettingsForm>({
|
||||
api_base_url: '',
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
@@ -945,6 +962,7 @@ async function saveSettings() {
|
||||
api_base_url: form.api_base_url,
|
||||
contact_info: form.contact_info,
|
||||
doc_url: form.doc_url,
|
||||
home_content: form.home_content,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
|
||||
Reference in New Issue
Block a user