Unverified Commit 66c8b6f2 authored by 程序猿MT's avatar 程序猿MT Committed by GitHub
Browse files

Merge branch 'Wei-Shaw:main' into main

parents a66d3188 d75cd820
......@@ -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
......
......@@ -1900,7 +1900,11 @@ export default {
logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.',
logoSizeError: 'Image size exceeds 300KB limit ({size}KB)',
logoTypeError: 'Please select an image file',
logoReadError: 'Failed to read the image file'
logoReadError: 'Failed to read the image file',
homeContent: 'Home Page Content',
homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.',
homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.',
homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.'
},
smtp: {
title: 'SMTP Settings',
......
......@@ -2043,7 +2043,11 @@ export default {
logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。',
logoSizeError: '图片大小超过 300KB 限制({size}KB)',
logoTypeError: '请选择图片文件',
logoReadError: '读取图片文件失败'
logoReadError: '读取图片文件失败',
homeContent: '首页内容',
homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。'
},
smtp: {
title: 'SMTP 设置',
......
......@@ -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
......@@ -323,10 +324,12 @@ router.beforeEach((to, _from, next) => {
}
// Set page title
const appStore = useAppStore()
const siteName = appStore.siteName || 'Sub2API'
if (to.meta.title) {
document.title = `${to.meta.title} - Sub2API`
document.title = `${to.meta.title} - ${siteName}`
} else {
document.title = 'Sub2API'
document.title = siteName
}
// Check if route requires authentication
......
......@@ -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 {}
......@@ -74,6 +74,7 @@ export interface PublicSettings {
api_base_url: string
contact_info: string
doc_url: string
home_content: string
linuxdo_oauth_enabled: boolean
version: string
}
......
<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()
// Site settings
const siteName = ref('Sub2API')
const siteLogo = ref('')
const siteSubtitle = ref('AI API Gateway Platform')
const docUrl = ref('')
const appStore = useAppStore()
// 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,26 @@
</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>
<!-- iframe CSP Warning -->
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.settings.site.homeContentIframeWarning') }}
</p>
</div>
</div>
</div>
......@@ -837,6 +857,7 @@ const form = reactive<SettingsForm>({
api_base_url: '',
contact_info: '',
doc_url: '',
home_content: '',
smtp_host: '',
smtp_port: 587,
smtp_username: '',
......@@ -945,6 +966,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,
......
import { defineConfig } from 'vite'
import { defineConfig, Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import checker from 'vite-plugin-checker'
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({
plugins: [
......@@ -10,7 +41,8 @@ export default defineConfig({
checker({
typescript: true,
vueTsc: true
})
}),
injectPublicSettings()
],
resolve: {
alias: {
......
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