From ec8959af199d45bc3844fb7d2f22013289a0e121 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 14 Feb 2026 15:33:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dfavicon=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8D=E7=94=9F=E6=95=88=E5=B9=B6=E6=94=B9=E4=B8=BA=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=8B=89=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/favicon/route.ts | 65 ++++++++++++++++++++++++++++++++ app/layout.tsx | 6 +-- components/favicon-updater.tsx | 69 +++++++++++++++------------------- 3 files changed, 97 insertions(+), 43 deletions(-) create mode 100644 app/api/favicon/route.ts diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts new file mode 100644 index 000000000..7972c3167 --- /dev/null +++ b/app/api/favicon/route.ts @@ -0,0 +1,65 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { NextResponse } from "next/server"; +import { getSiteSettings } from "@/lib/db/queries"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const DEFAULT_CONTENT_TYPE = "image/x-icon"; + +function noStoreHeaders(contentType: string) { + return { + "Content-Type": contentType, + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + Pragma: "no-cache", + Expires: "0", + }; +} + +function inferContentType(url: string) { + const lower = url.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".webp")) return "image/webp"; + return DEFAULT_CONTENT_TYPE; +} + +async function readLocalFallbackFavicon() { + const file = await readFile(join(process.cwd(), "app", "favicon.ico")); + return new NextResponse(file, { + status: 200, + headers: noStoreHeaders(DEFAULT_CONTENT_TYPE), + }); +} + +export async function GET() { + try { + const settings = await getSiteSettings(); + const faviconUrl = String(settings?.favicon || "").trim(); + + if (!faviconUrl) { + return await readLocalFallbackFavicon(); + } + + const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`; + const upstream = await fetch(targetUrl, { cache: "no-store" }); + + if (!upstream.ok) { + return await readLocalFallbackFavicon(); + } + + const buffer = await upstream.arrayBuffer(); + const contentType = + upstream.headers.get("content-type") || inferContentType(faviconUrl); + + return new NextResponse(buffer, { + status: 200, + headers: noStoreHeaders(contentType), + }); + } catch (_error) { + return await readLocalFallbackFavicon(); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 0d4e29456..ca8c65cee 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,11 +11,7 @@ import { getSiteSettings } from "@/lib/db/queries"; export async function generateMetadata(): Promise { const settings = await getSiteSettings(); - - // 处理 favicon URL,添加缓存破坏参数确保更新 - const faviconUrl = settings.favicon - ? `${settings.favicon}${settings.favicon.includes('?') ? '&' : '?'}v=${Date.now()}` - : "/favicon.ico"; + const faviconUrl = "/api/favicon"; return { title: { diff --git a/components/favicon-updater.tsx b/components/favicon-updater.tsx index 43d127e9c..4f969d959 100644 --- a/components/favicon-updater.tsx +++ b/components/favicon-updater.tsx @@ -6,47 +6,40 @@ export function FaviconUpdater() { useEffect(() => { let isDisposed = false; - // 从 API 获取最新的 favicon 设置 - fetch("/api/site-settings") - .then((res) => res.json()) - .then((settings) => { - if (!settings.favicon || isDisposed) { - return; + const timestamp = Date.now(); + const urlWithCache = `/api/favicon?v=${timestamp}`; + + Promise.resolve().then(() => { + if (isDisposed) { + return; + } + + // 仅处理本组件创建的 link,避免删除 Next/React 管理的 head 节点 + const oldLinks = document.querySelectorAll( + 'link[data-managed-favicon="true"]' + ); + oldLinks.forEach((link) => { + if (link.parentNode) { + link.parentNode.removeChild(link); } - - const faviconUrl = settings.favicon; - const timestamp = Date.now(); - const urlWithCache = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${timestamp}`; - - // 仅处理本组件创建的 link,避免删除 Next/React 管理的 head 节点 - const oldLinks = document.querySelectorAll( - 'link[data-managed-favicon="true"]' - ); - oldLinks.forEach((link) => { - if (link.parentNode) { - link.parentNode.removeChild(link); - } - }); - - const createManagedLink = (rel: string, href: string, type?: string) => { - const link = document.createElement("link"); - link.rel = rel; - link.href = href; - link.setAttribute("data-managed-favicon", "true"); - if (type) { - link.type = type; - } - document.head.appendChild(link); - }; - - createManagedLink("icon", urlWithCache, "image/x-icon"); - createManagedLink("apple-touch-icon", urlWithCache); - createManagedLink("shortcut icon", urlWithCache); - }) - .catch((error) => { - console.error("[Favicon] Error updating favicon:", error); }); + const createManagedLink = (rel: string, href: string, type?: string) => { + const link = document.createElement("link"); + link.rel = rel; + link.href = href; + link.setAttribute("data-managed-favicon", "true"); + if (type) { + link.type = type; + } + document.head.appendChild(link); + }; + + createManagedLink("icon", urlWithCache, "image/x-icon"); + createManagedLink("apple-touch-icon", urlWithCache); + createManagedLink("shortcut icon", urlWithCache); + }); + return () => { isDisposed = true; };