diff --git a/admin/src/lib/types.ts b/admin/src/lib/types.ts index 397ab81..8db3c3f 100644 --- a/admin/src/lib/types.ts +++ b/admin/src/lib/types.ts @@ -412,6 +412,7 @@ export interface AdminSiteSettingsResponse { media_r2_public_base_url: string | null media_r2_access_key_id: string | null media_r2_secret_access_key: string | null + seo_favicon_url: string | null seo_default_og_image: string | null seo_default_twitter_handle: string | null seo_wechat_share_qr_enabled: boolean @@ -489,6 +490,7 @@ export interface SiteSettingsPayload { mediaR2PublicBaseUrl?: string | null mediaR2AccessKeyId?: string | null mediaR2SecretAccessKey?: string | null + seoFaviconUrl?: string | null seoDefaultOgImage?: string | null seoDefaultTwitterHandle?: string | null seoWechatShareQrEnabled?: boolean diff --git a/admin/src/pages/site-settings-page.tsx b/admin/src/pages/site-settings-page.tsx index 54d7efc..a100da9 100644 --- a/admin/src/pages/site-settings-page.tsx +++ b/admin/src/pages/site-settings-page.tsx @@ -216,6 +216,7 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload { mediaR2PublicBaseUrl: form.media_r2_public_base_url, mediaR2AccessKeyId: form.media_r2_access_key_id, mediaR2SecretAccessKey: form.media_r2_secret_access_key, + seoFaviconUrl: form.seo_favicon_url, seoDefaultOgImage: form.seo_default_og_image, seoDefaultTwitterHandle: form.seo_default_twitter_handle, seoWechatShareQrEnabled: form.seo_wechat_share_qr_enabled, @@ -915,6 +916,22 @@ export function SiteSettingsPage() { + +
+ updateField('seo_favicon_url', event.target.value)} + /> + updateField('seo_favicon_url', seoFaviconUrl)} + prefix="seo-assets/" + contextLabel="站点 favicon 上传" + remoteTitle={form.site_name || form.site_title || '站点 favicon'} + dataTestIdPrefix="site-favicon" + /> +
+
Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if !manager.has_column("site_settings", "seo_favicon_url").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("seo_favicon_url")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if manager.has_column("site_settings", "seo_favicon_url").await? { + manager + .alter_table( + Table::alter() + .table(table) + .drop_column(Alias::new("seo_favicon_url")) + .to_owned(), + ) + .await?; + } + + Ok(()) + } +} diff --git a/backend/src/app.rs b/backend/src/app.rs index af39277..6e10757 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -397,6 +397,14 @@ impl Hooks for App { Some(trimmed.to_string()) } }); + let seo_favicon_url = settings["seo_favicon_url"].as_str().and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); let item = site_settings::ActiveModel { id: Set(settings["id"].as_i64().unwrap_or(1) as i32), @@ -441,6 +449,7 @@ impl Hooks for App { music_enabled: Set(music_enabled), maintenance_mode_enabled: Set(maintenance_mode_enabled), maintenance_access_code: Set(maintenance_access_code), + seo_favicon_url: Set(seo_favicon_url), ai_enabled: Set(settings["ai_enabled"].as_bool()), paragraph_comments_enabled: Set(settings["paragraph_comments_enabled"] .as_bool() diff --git a/backend/src/controllers/admin_api.rs b/backend/src/controllers/admin_api.rs index 6f85ec8..eb92522 100644 --- a/backend/src/controllers/admin_api.rs +++ b/backend/src/controllers/admin_api.rs @@ -213,6 +213,7 @@ pub struct AdminSiteSettingsResponse { pub media_r2_public_base_url: Option, pub media_r2_access_key_id: Option, pub media_r2_secret_access_key: Option, + pub seo_favicon_url: Option, pub seo_default_og_image: Option, pub seo_default_twitter_handle: Option, pub seo_wechat_share_qr_enabled: bool, @@ -1068,6 +1069,7 @@ fn build_settings_response( media_r2_public_base_url: item.media_r2_public_base_url, media_r2_access_key_id: item.media_r2_access_key_id, media_r2_secret_access_key: item.media_r2_secret_access_key, + seo_favicon_url: item.seo_favicon_url, seo_default_og_image: item.seo_default_og_image, seo_default_twitter_handle: item.seo_default_twitter_handle, seo_wechat_share_qr_enabled: item.seo_wechat_share_qr_enabled.unwrap_or(false), diff --git a/backend/src/controllers/site_settings.rs b/backend/src/controllers/site_settings.rs index 709c048..f40c5d3 100644 --- a/backend/src/controllers/site_settings.rs +++ b/backend/src/controllers/site_settings.rs @@ -160,6 +160,8 @@ pub struct SiteSettingsPayload { pub media_r2_access_key_id: Option, #[serde(default, alias = "mediaR2SecretAccessKey")] pub media_r2_secret_access_key: Option, + #[serde(default, alias = "seoFaviconUrl")] + pub seo_favicon_url: Option, #[serde(default, alias = "seoDefaultOgImage")] pub seo_default_og_image: Option, #[serde(default, alias = "seoDefaultTwitterHandle")] @@ -220,6 +222,7 @@ pub struct PublicSiteSettingsResponse { pub subscription_popup_title: String, pub subscription_popup_description: String, pub subscription_popup_delay_seconds: i32, + pub seo_favicon_url: Option, pub seo_default_og_image: Option, pub seo_default_twitter_handle: Option, pub seo_wechat_share_qr_enabled: bool, @@ -776,6 +779,9 @@ impl SiteSettingsPayload { item.media_r2_secret_access_key = normalize_optional_string(Some(media_r2_secret_access_key)); } + if let Some(seo_favicon_url) = self.seo_favicon_url { + item.seo_favicon_url = normalize_optional_string(Some(seo_favicon_url)); + } if let Some(seo_default_og_image) = self.seo_default_og_image { item.seo_default_og_image = normalize_optional_string(Some(seo_default_og_image)); } @@ -942,6 +948,7 @@ fn default_payload() -> SiteSettingsPayload { media_r2_public_base_url: None, media_r2_access_key_id: None, media_r2_secret_access_key: None, + seo_favicon_url: None, seo_default_og_image: None, seo_default_twitter_handle: None, seo_wechat_share_qr_enabled: Some(false), @@ -1041,6 +1048,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse { subscription_popup_delay_seconds: model .subscription_popup_delay_seconds .unwrap_or_else(default_subscription_popup_delay_seconds), + seo_favicon_url: model.seo_favicon_url, seo_default_og_image: model.seo_default_og_image, seo_default_twitter_handle: model.seo_default_twitter_handle, seo_wechat_share_qr_enabled: model.seo_wechat_share_qr_enabled.unwrap_or(false), diff --git a/backend/src/fixtures/site_settings.yaml b/backend/src/fixtures/site_settings.yaml index 6bd3d4b..1e736cc 100644 --- a/backend/src/fixtures/site_settings.yaml +++ b/backend/src/fixtures/site_settings.yaml @@ -60,3 +60,4 @@ ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。" ai_top_k: 4 ai_chunk_size: 1200 + seo_favicon_url: null diff --git a/backend/src/initializers/content_sync.rs b/backend/src/initializers/content_sync.rs index cf9b945..b3d1de5 100644 --- a/backend/src/initializers/content_sync.rs +++ b/backend/src/initializers/content_sync.rs @@ -111,6 +111,7 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { let music_enabled = seed["music_enabled"].as_bool().or(Some(true)); let maintenance_mode_enabled = seed["maintenance_mode_enabled"].as_bool().or(Some(false)); let maintenance_access_code = as_optional_string(&seed["maintenance_access_code"]); + let seo_favicon_url = as_optional_string(&seed["seo_favicon_url"]); let comment_verification_mode = as_optional_string(&seed["comment_verification_mode"]); let subscription_verification_mode = as_optional_string(&seed["subscription_verification_mode"]); @@ -196,6 +197,9 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { if is_blank(&existing.maintenance_access_code) { model.maintenance_access_code = Set(maintenance_access_code.clone()); } + if is_blank(&existing.seo_favicon_url) { + model.seo_favicon_url = Set(seo_favicon_url.clone()); + } if existing.ai_enabled.is_none() { model.ai_enabled = Set(seed["ai_enabled"].as_bool()); } @@ -278,6 +282,7 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { music_enabled: Set(music_enabled), maintenance_mode_enabled: Set(maintenance_mode_enabled), maintenance_access_code: Set(maintenance_access_code), + seo_favicon_url: Set(seo_favicon_url), ai_enabled: Set(seed["ai_enabled"].as_bool()), paragraph_comments_enabled: Set(seed["paragraph_comments_enabled"] .as_bool() diff --git a/backend/src/models/_entities/site_settings.rs b/backend/src/models/_entities/site_settings.rs index 15a0627..f0a766b 100644 --- a/backend/src/models/_entities/site_settings.rs +++ b/backend/src/models/_entities/site_settings.rs @@ -78,6 +78,8 @@ pub struct Model { #[sea_orm(column_type = "Text", nullable)] pub media_r2_secret_access_key: Option, #[sea_orm(column_type = "Text", nullable)] + pub seo_favicon_url: Option, + #[sea_orm(column_type = "Text", nullable)] pub seo_default_og_image: Option, pub seo_default_twitter_handle: Option, pub seo_wechat_share_qr_enabled: Option, diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico deleted file mode 100644 index 7f48a94..0000000 Binary files a/frontend/public/favicon.ico and /dev/null differ diff --git a/frontend/src/layouts/BaseLayout.astro b/frontend/src/layouts/BaseLayout.astro index 80c7079..10f874f 100644 --- a/frontend/src/layouts/BaseLayout.astro +++ b/frontend/src/layouts/BaseLayout.astro @@ -148,7 +148,7 @@ const i18nPayload = JSON.stringify({ locale, messages }); title={`${siteSettings.siteName} llms-full.txt`} href={`${siteUrl}/llms-full.txt`} /> - + {title} {jsonLd && } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 0eb8fbc..9030526 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -293,6 +293,7 @@ export interface ApiSiteSettings { subscription_popup_title: string | null; subscription_popup_description: string | null; subscription_popup_delay_seconds: number | null; + seo_favicon_url: string | null; seo_default_og_image: string | null; seo_default_twitter_handle: string | null; seo_wechat_share_qr_enabled: boolean; @@ -492,6 +493,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = { webPushVapidPublicKey: undefined, }, seo: { + faviconUrl: undefined, defaultOgImage: undefined, defaultTwitterHandle: undefined, wechatShareQrEnabled: false, @@ -669,6 +671,7 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => { undefined, }, seo: { + faviconUrl: settings.seo_favicon_url ?? undefined, defaultOgImage: settings.seo_default_og_image ?? undefined, defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined, wechatShareQrEnabled: Boolean(settings.seo_wechat_share_qr_enabled), diff --git a/frontend/src/lib/maintenance.ts b/frontend/src/lib/maintenance.ts index 554c55f..099d4ac 100644 --- a/frontend/src/lib/maintenance.ts +++ b/frontend/src/lib/maintenance.ts @@ -33,7 +33,9 @@ export function shouldBypassMaintenance(pathname: string): boolean { pathname === '/maintenance' || pathname.startsWith('/api/maintenance') || pathname === '/healthz' || - pathname === '/favicon.svg' || + pathname === '/favicon.ico' || + pathname.startsWith('/favicon.') || + pathname.startsWith('/apple-touch-icon') || pathname.startsWith('/_astro/') || pathname.startsWith('/_image') || pathname.startsWith('/_img') diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts index 8cfc5e0..49cf37a 100644 --- a/frontend/src/lib/types/index.ts +++ b/frontend/src/lib/types/index.ts @@ -106,6 +106,7 @@ export interface SiteSettings { webPushVapidPublicKey?: string; }; seo: { + faviconUrl?: string; defaultOgImage?: string; defaultTwitterHandle?: string; wechatShareQrEnabled: boolean; diff --git a/frontend/src/pages/favicon.ico.ts b/frontend/src/pages/favicon.ico.ts new file mode 100644 index 0000000..fba62f8 --- /dev/null +++ b/frontend/src/pages/favicon.ico.ts @@ -0,0 +1,35 @@ +import type { APIRoute } from 'astro' + +import { createApiClient, DEFAULT_SITE_SETTINGS } from '../lib/api/client' + +function resolveFaviconTarget(requestUrl: URL, configured: string | undefined) { + const fallbackTarget = '/favicon.svg' + const candidate = configured?.trim() + + if (!candidate) { + return fallbackTarget + } + + try { + const resolved = new URL(candidate, requestUrl) + if (resolved.pathname === '/favicon.ico' && resolved.origin === requestUrl.origin) { + return fallbackTarget + } + + return resolved.toString() + } catch { + return fallbackTarget + } +} + +export const GET: APIRoute = async ({ url, redirect }) => { + let siteSettings = DEFAULT_SITE_SETTINGS + + try { + siteSettings = await createApiClient({ requestUrl: url }).getSiteSettings() + } catch (error) { + console.error('Failed to load site settings for favicon:', error) + } + + return redirect(resolveFaviconTarget(url, siteSettings.seo.faviconUrl), 302) +} diff --git a/frontend/src/pages/maintenance.astro b/frontend/src/pages/maintenance.astro index 250db42..1de2d48 100644 --- a/frontend/src/pages/maintenance.astro +++ b/frontend/src/pages/maintenance.astro @@ -20,9 +20,9 @@ const errorMessage = ? '请先输入访问口令。' : errorCode === 'invalid' ? '口令不正确,请重新输入。' - : errorCode === 'unavailable' - ? '当前无法校验访问口令,请稍后再试。' - : '' + : errorCode === 'unavailable' + ? '当前无法校验访问口令,请稍后再试。' + : '' --- @@ -31,6 +31,7 @@ const errorMessage = + {siteSettings.siteName} · 维护模式 diff --git a/playwright-smoke/mock-server.mjs b/playwright-smoke/mock-server.mjs index eb89cce..b4a87a0 100644 --- a/playwright-smoke/mock-server.mjs +++ b/playwright-smoke/mock-server.mjs @@ -824,6 +824,7 @@ function createSiteSettings() { media_r2_public_base_url: `${MOCK_ORIGIN}/media`, media_r2_access_key_id: 'mock-access-key', media_r2_secret_access_key: 'mock-secret', + seo_favicon_url: `${MOCK_ORIGIN}/favicon.svg`, seo_default_og_image: `${MOCK_ORIGIN}/media-files/default-og.svg`, seo_default_twitter_handle: '@initcool', seo_wechat_share_qr_enabled: false, @@ -2768,6 +2769,7 @@ const server = createServer(async (req, res) => { mediaR2PublicBaseUrl: 'media_r2_public_base_url', mediaR2AccessKeyId: 'media_r2_access_key_id', mediaR2SecretAccessKey: 'media_r2_secret_access_key', + seoFaviconUrl: 'seo_favicon_url', seoDefaultOgImage: 'seo_default_og_image', seoDefaultTwitterHandle: 'seo_default_twitter_handle', seoWechatShareQrEnabled: 'seo_wechat_share_qr_enabled',