feat(polaris): 更新导航链接并修复图标加载

调整链接数据、图标映射和相关测试,新增 go-zero 本地图标。
修复图片在事件绑定前已加载或失败时的显示问题。
This commit is contained in:
2026-10-08 19:49:30 +08:00
parent 1a48960022
commit 39bde32376
9 changed files with 173 additions and 136 deletions
@@ -1,11 +1,14 @@
import {
siBilibili,
siBitbucket,
siDeepseek,
siDocker,
siFigma,
siGithub,
siGitlab,
siGmail,
siGo,
siGooglegemini,
siGoland,
siJetbrains,
siKubernetes,
@@ -23,11 +26,14 @@ import {
export const SIMPLE_ICON_MAP: Readonly<Record<string, SimpleIcon>> = {
bilibili: siBilibili,
bitbucket: siBitbucket,
deepseek: siDeepseek,
docker: siDocker,
figma: siFigma,
github: siGithub,
gitlab: siGitlab,
gmail: siGmail,
go: siGo,
googlegemini: siGooglegemini,
goland: siGoland,
jetbrains: siJetbrains,
kubernetes: siKubernetes,
@@ -1,8 +1,12 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { afterEach, describe, expect, it, vi } from "vitest"
import { SiteIcon } from "./site-icon"
afterEach(() => {
vi.restoreAllMocks()
})
describe("SiteIcon", () => {
it("渲染已知 Simple Icon", () => {
const { container } = render(
@@ -81,4 +85,62 @@ describe("SiteIcon", () => {
fireEvent.error(container.querySelector("img")!)
expect(screen.getByText("E")).toBeInTheDocument()
})
it("缓存或水合前已加载完成的图片挂载后直接显示,不再停留首字母", () => {
// 模拟图片在 React 附加 onLoad 之前(如缓存命中 + 水合竞态)已完成:不再有 load 事件。
vi.spyOn(HTMLImageElement.prototype, "complete", "get").mockReturnValue(true)
vi.spyOn(HTMLImageElement.prototype, "naturalWidth", "get").mockReturnValue(
48,
)
const { container } = render(
<SiteIcon
link={{
title: "go-zero",
url: "https://go-zero.dev",
icon: "local:go-zero",
}}
/>,
)
const wrapper = container.querySelector(".site-icon")!
const image = container.querySelector("img")!
expect(image).toHaveAttribute("src", "/icons/go-zero.svg")
expect(wrapper).not.toHaveAttribute("data-initial")
expect(wrapper).not.toHaveClass("site-icon--initial")
expect(image).not.toHaveClass("site-icon__image--loading")
})
it("缓存或水合前已失败的图片挂载后推进候选链,不卡死占位", () => {
// complete 为真但 naturalWidth 为 0 表示解码失败的坏图,同样不会补发 error 事件。
// 仅首项候选处于该状态;推进后的候选按全新加载处理(complete 为假,等待事件)。
vi.spyOn(HTMLImageElement.prototype, "complete", "get").mockImplementation(
function (this: HTMLImageElement) {
return this.getAttribute("src") === "https://cdn.example.com/icon.png"
},
)
vi.spyOn(HTMLImageElement.prototype, "naturalWidth", "get").mockReturnValue(
0,
)
const { container } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
// 候选链:自定义图片 → favicon → 首字母;首项挂载前已坏,应停在 favicon。
expect(container.querySelector("img")).toHaveAttribute(
"src",
"https://example.com/favicon.ico",
)
expect(container.querySelector(".site-icon")).toHaveAttribute(
"data-initial",
"E",
)
})
})
@@ -1,7 +1,7 @@
"use client"
/* eslint-disable @next/next/no-img-element -- favicon 来源由数据动态决定,静态 Image 配置无法预枚举域名。 */
import { useState } from "react"
import { useEffect, useRef, useState } from "react"
import {
getIconCandidates,
@@ -29,6 +29,7 @@ function SiteIconContent({
}: SiteIconContentProps) {
const [candidateIndex, setCandidateIndex] = useState(0)
const [loadedImageSrc, setLoadedImageSrc] = useState<string | null>(null)
const imageRef = useRef<HTMLImageElement | null>(null)
const candidate = candidates[candidateIndex]
const initialCandidate = candidates.at(-1)
const initialValue =
@@ -36,6 +37,21 @@ function SiteIconContent({
const wrapperClassName = cn("site-icon", className)
const style = { width: size, height: size }
// 缓存命中的图片可能在 React 附加 onLoad/onError(如水合)之前就完成或失败,
// 之后不再派发任何事件;挂载后按 complete/naturalWidth 补偿判定,
// 避免透明图片永久停留在首字母占位,也避免坏图卡住候选链。
// 候选推进有末位钳制,末位为首字母分支,不会形成无限循环。
useEffect(() => {
if (candidate.kind !== "image") return
const image = imageRef.current
if (!image?.complete) return
if (image.naturalWidth > 0) {
setLoadedImageSrc(candidate.src)
} else {
setCandidateIndex((index) => Math.min(index + 1, candidates.length - 1))
}
}, [candidate, candidates.length])
if (candidate.kind === "simple") {
const icon = SIMPLE_ICON_MAP[candidate.slug]
return (
@@ -72,6 +88,7 @@ function SiteIconContent({
alt={`${link.title} 图标`}
width={size}
height={size}
ref={imageRef}
onLoad={() => setLoadedImageSrc(candidate.src)}
onError={() =>
setCandidateIndex((index) =>