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 { ...@@ -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 { ...@@ -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
......
...@@ -1900,7 +1900,11 @@ export default { ...@@ -1900,7 +1900,11 @@ 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.',
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: { smtp: {
title: 'SMTP Settings', title: 'SMTP Settings',
......
...@@ -2043,7 +2043,11 @@ export default { ...@@ -2043,7 +2043,11 @@ 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 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。'
}, },
smtp: { smtp: {
title: 'SMTP 设置', title: 'SMTP 设置',
......
...@@ -6,7 +6,20 @@ import i18n from './i18n' ...@@ -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 @@ ...@@ -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
...@@ -323,10 +324,12 @@ router.beforeEach((to, _from, next) => { ...@@ -323,10 +324,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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -391,6 +419,7 @@ export const useAppStore = defineStore('app', () => {
// Public settings actions // Public settings actions
fetchPublicSettings, fetchPublicSettings,
clearPublicSettingsCache clearPublicSettingsCache,
initFromInjectedConfig
} }
}) })
import type { PublicSettings } from '@/types'
declare global {
interface Window {
__APP_CONFIG__?: PublicSettings
}
}
export {}
...@@ -74,6 +74,7 @@ export interface PublicSettings { ...@@ -74,6 +74,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
} }
......
<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 @@ ...@@ -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 @@ ...@@ -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
const siteName = ref('Sub2API') // Site settings - directly from appStore (already initialized from injected config)
const siteLogo = ref('') const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
const siteSubtitle = ref('AI API Gateway Platform') const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
const docUrl = ref('') 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 // Theme
const isDark = ref(document.documentElement.classList.contains('dark')) const isDark = ref(document.documentElement.classList.contains('dark'))
...@@ -446,20 +467,15 @@ function initTheme() { ...@@ -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,26 @@ ...@@ -562,6 +562,26 @@
</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>
<!-- 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>
</div> </div>
...@@ -837,6 +857,7 @@ const form = reactive<SettingsForm>({ ...@@ -837,6 +857,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 +966,7 @@ async function saveSettings() { ...@@ -945,6 +966,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,
......
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({ ...@@ -10,7 +41,8 @@ export default defineConfig({
checker({ checker({
typescript: true, typescript: true,
vueTsc: true vueTsc: true
}) }),
injectPublicSettings()
], ],
resolve: { resolve: {
alias: { 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