feat(polaris): 更新导航链接并修复图标加载
调整链接数据、图标映射和相关测试,新增 go-zero 本地图标。 修复图片在事件绑定前已加载或失败时的显示问题。
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="go-zero">
|
||||
<rect width="64" height="64" rx="14" fill="#1f2937" />
|
||||
<path d="M14 32c0-10.5 7.6-18 18-18 6.7 0 11.4 2.3 15.5 7.1l-5.4 4.6c-2.3-2.8-5-4.1-9.7-4.1-6.5 0-10.8 4.7-10.8 10.4 0 5.9 4.3 10.4 10.8 10.4 4 0 7-1 9.7-3.8l5.4 4.6c-3.9 4.6-8.9 6.8-15.5 6.8-10.4 0-18-7.4-18-18z" fill="#60a5fa" />
|
||||
<circle cx="49" cy="18" r="5" fill="#10b981" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 441 B |
@@ -26,8 +26,8 @@ describe("CategoryPage", () => {
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "开发工具" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("共 15 个")).toBeInTheDocument()
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(15)
|
||||
expect(screen.getByText("共 8 个")).toBeInTheDocument()
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(8)
|
||||
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -27,9 +27,9 @@ describe("Polaris 静态数据", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("包含 20 条合法链接且分类引用均存在", () => {
|
||||
it("包含 16 条合法链接且分类引用均存在", () => {
|
||||
const categoryIds = new Set(categories.map(({ id }) => id))
|
||||
expect(links).toHaveLength(20)
|
||||
expect(links).toHaveLength(16)
|
||||
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
|
||||
|
||||
for (const link of links) {
|
||||
@@ -55,13 +55,13 @@ describe("Polaris 静态数据", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("8 条常用链接拥有唯一 pinnedSort", () => {
|
||||
it("6 条常用链接拥有唯一 pinnedSort", () => {
|
||||
const pinned = links.filter(({ pinned }) => pinned)
|
||||
const pinnedSorts = pinned.map(({ pinnedSort }) => pinnedSort)
|
||||
|
||||
expect(pinned).toHaveLength(8)
|
||||
expect(pinned).toHaveLength(6)
|
||||
expect(pinnedSorts.every(Number.isFinite)).toBe(true)
|
||||
expect(new Set(pinnedSorts).size).toBe(8)
|
||||
expect(new Set(pinnedSorts).size).toBe(6)
|
||||
})
|
||||
|
||||
it("设置包含完整且合法的嵌套契约", () => {
|
||||
|
||||
@@ -12,71 +12,16 @@
|
||||
"sort": 10
|
||||
},
|
||||
{
|
||||
"id": "gitlab",
|
||||
"title": "GitLab",
|
||||
"description": "一体化 DevOps 平台",
|
||||
"url": "https://gitlab.com",
|
||||
"icon": "si:gitlab",
|
||||
"id": "gitee",
|
||||
"title": "Gitee",
|
||||
"description": "国内领先的开发者社区",
|
||||
"url": "https://gitee.com",
|
||||
"icon": "si:gitee",
|
||||
"categoryId": "dev",
|
||||
"pinned": false,
|
||||
"keywords": ["代码", "仓库", "devops", "ci", "cd"],
|
||||
"sort": 20
|
||||
},
|
||||
{
|
||||
"id": "bitbucket",
|
||||
"title": "Bitbucket",
|
||||
"description": "代码托管和协作开发",
|
||||
"url": "https://bitbucket.org",
|
||||
"icon": "si:bitbucket",
|
||||
"categoryId": "dev",
|
||||
"pinned": false,
|
||||
"keywords": ["代码", "仓库", "git"],
|
||||
"sort": 30
|
||||
},
|
||||
{
|
||||
"id": "vscode",
|
||||
"title": "VS Code",
|
||||
"description": "强大的代码编辑器",
|
||||
"url": "https://code.visualstudio.com",
|
||||
"icon": "si:vscode",
|
||||
"categoryId": "dev",
|
||||
"pinned": false,
|
||||
"keywords": ["编辑器", "ide", "代码"],
|
||||
"sort": 40
|
||||
},
|
||||
{
|
||||
"id": "jetbrains",
|
||||
"title": "JetBrains",
|
||||
"description": "智能开发工具套件",
|
||||
"url": "https://www.jetbrains.com",
|
||||
"icon": "si:jetbrains",
|
||||
"categoryId": "dev",
|
||||
"pinned": false,
|
||||
"keywords": ["ide", "开发工具", "编辑器"],
|
||||
"sort": 50
|
||||
},
|
||||
{
|
||||
"id": "goland",
|
||||
"title": "GoLand",
|
||||
"description": "Go 语言 IDE",
|
||||
"url": "https://www.jetbrains.com/go/",
|
||||
"icon": "si:goland",
|
||||
"categoryId": "dev",
|
||||
"pinned": false,
|
||||
"keywords": ["go", "golang", "ide"],
|
||||
"sort": 60
|
||||
},
|
||||
{
|
||||
"id": "postman",
|
||||
"title": "Postman",
|
||||
"description": "API 开发和测试工具",
|
||||
"url": "https://www.postman.com",
|
||||
"icon": "si:postman",
|
||||
"categoryId": "dev",
|
||||
"pinned": false,
|
||||
"keywords": ["api", "接口", "测试"],
|
||||
"sort": 70
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"title": "Docker",
|
||||
@@ -99,40 +44,6 @@
|
||||
"keywords": ["k8s", "容器", "编排", "云原生"],
|
||||
"sort": 90
|
||||
},
|
||||
{
|
||||
"id": "vercel",
|
||||
"title": "Vercel",
|
||||
"description": "前端部署与托管平台",
|
||||
"url": "https://vercel.com",
|
||||
"icon": "si:vercel",
|
||||
"categoryId": "dev",
|
||||
"pinned": true,
|
||||
"pinnedSort": 70,
|
||||
"keywords": ["部署", "前端", "托管"],
|
||||
"sort": 100
|
||||
},
|
||||
{
|
||||
"id": "netlify",
|
||||
"title": "Netlify",
|
||||
"description": "现代化的开发平台",
|
||||
"url": "https://www.netlify.com",
|
||||
"icon": "si:netlify",
|
||||
"categoryId": "dev",
|
||||
"pinned": false,
|
||||
"keywords": ["部署", "前端", "托管"],
|
||||
"sort": 110
|
||||
},
|
||||
{
|
||||
"id": "render",
|
||||
"title": "Render",
|
||||
"description": "云应用托管平台",
|
||||
"url": "https://render.com",
|
||||
"icon": "si:render",
|
||||
"categoryId": "dev",
|
||||
"pinned": false,
|
||||
"keywords": ["部署", "后端", "托管", "云服务"],
|
||||
"sort": 120
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"title": "npm",
|
||||
@@ -156,16 +67,36 @@
|
||||
"sort": 140
|
||||
},
|
||||
{
|
||||
"id": "figma",
|
||||
"title": "Figma",
|
||||
"description": "界面设计协作工具",
|
||||
"url": "https://www.figma.com",
|
||||
"icon": "si:figma",
|
||||
"id": "go",
|
||||
"title": "Go 标准库文档",
|
||||
"description": "Go 语言标准库 API 参考文档",
|
||||
"url": "https://pkg.go.dev/std",
|
||||
"icon": "si:go",
|
||||
"categoryId": "dev",
|
||||
"pinned": true,
|
||||
"pinnedSort": 50,
|
||||
"keywords": ["设计", "ui", "协作"],
|
||||
"sort": 150
|
||||
"pinned": false,
|
||||
"keywords": ["go", "golang", "标准库", "文档"],
|
||||
"sort": 30
|
||||
},
|
||||
{
|
||||
"id": "go-zero",
|
||||
"title": "go-zero",
|
||||
"description": "Go 微服务框架",
|
||||
"url": "https://go-zero.dev",
|
||||
"icon": "local:go-zero",
|
||||
"categoryId": "dev",
|
||||
"pinned": false,
|
||||
"keywords": ["go", "golang", "微服务", "框架"],
|
||||
"sort": 40
|
||||
},
|
||||
{
|
||||
"id": "iconfont",
|
||||
"title": "Iconfont",
|
||||
"description": "阿里巴巴矢量图标库",
|
||||
"url": "https://www.iconfont.cn",
|
||||
"categoryId": "design",
|
||||
"pinned": false,
|
||||
"keywords": ["图标", "矢量", "素材", "设计"],
|
||||
"sort": 10
|
||||
},
|
||||
{
|
||||
"id": "chatgpt",
|
||||
@@ -179,6 +110,28 @@
|
||||
"keywords": ["ai", "人工智能", "对话", "写作"],
|
||||
"sort": 10
|
||||
},
|
||||
{
|
||||
"id": "deepseek",
|
||||
"title": "DeepSeek",
|
||||
"description": "蓝色大肥鱼",
|
||||
"url": "https://deepseek.com",
|
||||
"icon": "si:deepseek",
|
||||
"categoryId": "ai",
|
||||
"pinned": false,
|
||||
"keywords": ["ai", "人工智能", "对话", "写作"],
|
||||
"sort": 10
|
||||
},
|
||||
{
|
||||
"id": "gemini",
|
||||
"title": "Gemini",
|
||||
"description": "谷歌 Gemini AI",
|
||||
"url": "https://gemini.google.com/app",
|
||||
"icon": "si:googlegemini",
|
||||
"categoryId": "ai",
|
||||
"pinned": false,
|
||||
"keywords": ["ai", "人工智能", "对话", "写作"],
|
||||
"sort": 10
|
||||
},
|
||||
{
|
||||
"id": "gmail",
|
||||
"title": "Gmail",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getLinksByCategory } from "@/services/links"
|
||||
import { CategoryContent } from "./category-content"
|
||||
|
||||
describe("CategoryContent", () => {
|
||||
it("展示开发工具标题、描述、数量和 15 张卡片", () => {
|
||||
it("展示开发工具标题、描述、数量和 8 张卡片", () => {
|
||||
const category = getCategoryById("dev")
|
||||
if (!category) throw new Error("测试数据缺少 dev 分类")
|
||||
|
||||
@@ -22,14 +22,14 @@ describe("CategoryContent", () => {
|
||||
screen.getByRole("heading", { level: 1, name: "开发工具" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(category.description)).toBeInTheDocument()
|
||||
expect(screen.getByText("共 15 个")).toBeInTheDocument()
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(15)
|
||||
expect(screen.getByText("共 8 个")).toBeInTheDocument()
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(8)
|
||||
expect(screen.queryByText("暂无工具")).not.toBeInTheDocument()
|
||||
|
||||
const toolLinks = screen
|
||||
.getByRole("list", { name: "工具列表" })
|
||||
.querySelectorAll("a")
|
||||
expect(toolLinks).toHaveLength(15)
|
||||
expect(toolLinks).toHaveLength(8)
|
||||
for (const link of toolLinks) {
|
||||
expect(link).toHaveAttribute("target", "_blank")
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer")
|
||||
@@ -37,8 +37,8 @@ describe("CategoryContent", () => {
|
||||
})
|
||||
|
||||
it("为空分类展示计数和纯浏览空状态", () => {
|
||||
const category = getCategoryById("design")
|
||||
if (!category) throw new Error("测试数据缺少 design 分类")
|
||||
const category = getCategoryById("life")
|
||||
if (!category) throw new Error("测试数据缺少 life 分类")
|
||||
|
||||
render(
|
||||
<CategoryContent
|
||||
@@ -57,9 +57,9 @@ describe("CategoryContent", () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
["ai", 1],
|
||||
["dev", 15],
|
||||
["design", 0],
|
||||
["ai", 3],
|
||||
["dev", 8],
|
||||
["design", 1],
|
||||
["learning", 0],
|
||||
["efficiency", 2],
|
||||
["media", 2],
|
||||
|
||||
@@ -33,39 +33,33 @@ describe("静态数据 service", () => {
|
||||
const first = getLinksByCategory("dev")
|
||||
const second = getLinksByCategory("dev")
|
||||
|
||||
expect(first).toHaveLength(15)
|
||||
expect(first).toHaveLength(8)
|
||||
expect(first.map(({ id }) => id)).toEqual([
|
||||
"github",
|
||||
"gitlab",
|
||||
"bitbucket",
|
||||
"vscode",
|
||||
"jetbrains",
|
||||
"goland",
|
||||
"postman",
|
||||
"gitee",
|
||||
"go",
|
||||
"go-zero",
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"vercel",
|
||||
"netlify",
|
||||
"render",
|
||||
"npm",
|
||||
"pnpm",
|
||||
"figma",
|
||||
])
|
||||
expect(first).not.toBe(second)
|
||||
expect(first[0]).not.toBe(second[0])
|
||||
expect(first[0].keywords).not.toBe(second[0].keywords)
|
||||
expect(getLinksByCategory("design")).toEqual([])
|
||||
expect(getLinksByCategory("design").map(({ id }) => id)).toEqual([
|
||||
"iconfont",
|
||||
])
|
||||
expect(getLinksByCategory("life")).toEqual([])
|
||||
})
|
||||
|
||||
it("按 pinnedSort 返回首页 8 个常用链接", () => {
|
||||
it("按 pinnedSort 返回首页 6 个常用链接", () => {
|
||||
expect(getPinnedLinks().map(({ id }) => id)).toEqual([
|
||||
"github",
|
||||
"chatgpt",
|
||||
"gmail",
|
||||
"notion",
|
||||
"figma",
|
||||
"youtube",
|
||||
"vercel",
|
||||
"bilibili",
|
||||
])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user