fix: prevent custom branding flash on initial load

This commit is contained in:
weiness
2026-07-18 00:24:06 +08:00
parent bc2244c83f
commit 500f241692
8 changed files with 199 additions and 34 deletions
+1 -16
View File
@@ -8,6 +8,7 @@ import { resolveRouteDocumentTitle } from '@/router/title'
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore, useAdminComplianceStore, useAdminSettingsStore } from '@/stores'
import { getSetupStatus } from '@/api/setup'
import { updateFavicon } from '@/utils/branding'
const router = useRouter()
const route = useRoute()
@@ -26,22 +27,6 @@ function updateDocumentTitle() {
document.title = resolveRouteDocumentTitle(route, appStore.siteName, customMenuItems)
}
/**
* Update favicon dynamically
* @param logoUrl - URL of the logo to use as favicon
*/
function updateFavicon(logoUrl: string) {
// Find existing favicon link or create new one
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.type = logoUrl.endsWith('.svg') ? 'image/svg+xml' : 'image/x-icon'
link.href = logoUrl
}
// Watch for site settings changes and update favicon/title
watch(
() => appStore.siteLogo,
+2
View File
@@ -4,6 +4,7 @@ import App from './App.vue'
import router from './router'
import i18n, { initI18n } from './i18n'
import { useAppStore } from '@/stores/app'
import { updateFavicon } from '@/utils/branding'
import './style.css'
function initThemeClass() {
@@ -31,6 +32,7 @@ async function bootstrap() {
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
document.title = `${appStore.siteName} - AI API Gateway`
}
updateFavicon(appStore.siteLogo)
await initI18n()
@@ -0,0 +1,22 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { updateFavicon } from '@/utils/branding'
describe('updateFavicon', () => {
beforeEach(() => {
document.head.innerHTML = '<link rel="icon" href="/logo.png">'
})
it('replaces the default favicon with the configured logo', () => {
updateFavicon('https://example.com/custom-logo.png')
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
expect(link?.href).toBe('https://example.com/custom-logo.png')
})
it('ignores unsafe logo URLs', () => {
updateFavicon('javascript:alert(1)')
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
expect(link?.getAttribute('href')).toBe('/logo.png')
})
})
+21
View File
@@ -0,0 +1,21 @@
import { sanitizeUrl } from '@/utils/url'
export function updateFavicon(logoUrl: string): void {
const sanitizedLogoUrl = sanitizeUrl(logoUrl, {
allowRelative: true,
allowDataUrl: true,
})
if (!sanitizedLogoUrl) {
return
}
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.type = sanitizedLogoUrl.endsWith('.svg') ? 'image/svg+xml' : 'image/x-icon'
link.href = sanitizedLogoUrl
}
+20 -16
View File
@@ -3,12 +3,18 @@
<header class="border-b border-gray-200 bg-white/95 dark:border-dark-800 dark:bg-dark-900/95">
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<RouterLink to="/home" class="flex min-w-0 items-center gap-3">
<span class="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</span>
<span class="truncate text-base font-semibold text-gray-950 dark:text-white">
{{ siteName }}
</span>
<template v-if="settings">
<span class="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</span>
<span class="truncate text-base font-semibold text-gray-950 dark:text-white">
{{ siteName }}
</span>
</template>
<template v-else>
<span class="h-10 w-10 flex-shrink-0 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700" aria-hidden="true"></span>
<span class="h-5 w-28 animate-pulse rounded bg-gray-200 dark:bg-dark-700" aria-hidden="true"></span>
</template>
</RouterLink>
<RouterLink
to="/login"
@@ -90,10 +96,10 @@ import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { getPublicSettings } from '@/api/auth'
import { getLocale } from '@/i18n'
import { sanitizeUrl } from '@/utils/url'
import type { LoginAgreementDocument, PublicSettings } from '@/types'
import { useAppStore } from '@/stores/app'
import type { LoginAgreementDocument } from '@/types'
import zhAdminCompliance from '../../../../docs/legal/admin-compliance.zh.md?raw'
import enAdminCompliance from '../../../../docs/legal/admin-compliance.en.md?raw'
@@ -101,8 +107,9 @@ type LegalDocumentIcon = 'document' | 'shield' | 'globe' | 'cog'
const route = useRoute()
const { t } = useI18n()
const settings = ref<PublicSettings | null>(null)
const loading = ref(true)
const appStore = useAppStore()
const settings = computed(() => appStore.cachedPublicSettings)
const loading = ref(!settings.value)
const loadError = ref(false)
marked.setOptions({
@@ -166,15 +173,12 @@ const documentIcon = computed<LegalDocumentIcon>(() => {
})
onMounted(async () => {
loading.value = true
loadError.value = false
try {
settings.value = await getPublicSettings()
} catch {
const loadedSettings = await appStore.fetchPublicSettings()
if (!loadedSettings) {
loadError.value = true
} finally {
loading.value = false
}
loading.value = false
})
</script>
+44 -1
View File
@@ -3,6 +3,49 @@ import vue from '@vitejs/plugin-vue'
import checker from 'vite-plugin-checker'
import { resolve } from 'path'
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character] || character)
}
function isSafeImageUrl(value: string): boolean {
const trimmed = value.trim()
if ((trimmed.startsWith('/') && !trimmed.startsWith('//')) || /^data:image\//i.test(trimmed)) {
return true
}
try {
const parsed = new URL(trimmed)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
function injectBranding(html: string, config: { site_name?: string; site_logo?: string }): string {
let brandedHtml = html
const siteName = config.site_name?.trim()
if (siteName) {
brandedHtml = brandedHtml.replace(
/<title>[^<]*<\/title>/i,
`<title>${escapeHtml(siteName)} - AI API Gateway</title>`,
)
}
const siteLogo = config.site_logo?.trim()
if (siteLogo && isSafeImageUrl(siteLogo)) {
brandedHtml = brandedHtml.replace(
/<link\s+rel=["']icon["'][^>]*>/i,
`<link rel="icon" href="${escapeHtml(siteLogo)}" />`,
)
}
return brandedHtml
}
/**
* Vite 插件:开发模式下注入公开配置到 index.html
* 与生产模式的后端注入行为保持一致,消除闪烁
@@ -22,7 +65,7 @@ function injectPublicSettings(backendUrl: string): Plugin {
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>`)
return injectBranding(html, data.data).replace('</head>', `${script}\n</head>`)
}
}
} catch (e) {