From 39bde323761ee6015f268244f4834804262b7cb0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E8=82=B2=E6=96=B0?= <2635131263@qq.com>
Date: Tue, 6 Oct 2026 20:22:25 +0800
Subject: [PATCH] =?UTF-8?q?feat(polaris):=20=E6=9B=B4=E6=96=B0=E5=AF=BC?=
=?UTF-8?q?=E8=88=AA=E9=93=BE=E6=8E=A5=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=9B=BE?=
=?UTF-8?q?=E6=A0=87=E5=8A=A0=E8=BD=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
调整链接数据、图标映射和相关测试,新增 go-zero 本地图标。
修复图片在事件绑定前已加载或失败时的显示问题。
---
.../nextjs/polaris/public/icons/go-zero.svg | 5 +
.../app/categories/[categoryId]/page.test.tsx | 4 +-
.../src/components/shared/simple-icon-map.ts | 6 +
.../src/components/shared/site-icon.test.tsx | 64 ++++++-
.../src/components/shared/site-icon.tsx | 19 ++-
ts/web/nextjs/polaris/src/data/data.test.ts | 10 +-
ts/web/nextjs/polaris/src/data/links.json | 159 ++++++------------
.../components/category-content.test.tsx | 18 +-
.../polaris/src/services/services.test.ts | 24 +--
9 files changed, 173 insertions(+), 136 deletions(-)
create mode 100644 ts/web/nextjs/polaris/public/icons/go-zero.svg
diff --git a/ts/web/nextjs/polaris/public/icons/go-zero.svg b/ts/web/nextjs/polaris/public/icons/go-zero.svg
new file mode 100644
index 0000000..26b2715
--- /dev/null
+++ b/ts/web/nextjs/polaris/public/icons/go-zero.svg
@@ -0,0 +1,5 @@
+
diff --git a/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx
index 2929c70..30e02f0 100644
--- a/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx
+++ b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx
@@ -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",
diff --git a/ts/web/nextjs/polaris/src/components/shared/simple-icon-map.ts b/ts/web/nextjs/polaris/src/components/shared/simple-icon-map.ts
index d8ae7c4..ece6b0e 100644
--- a/ts/web/nextjs/polaris/src/components/shared/simple-icon-map.ts
+++ b/ts/web/nextjs/polaris/src/components/shared/simple-icon-map.ts
@@ -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> = {
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,
diff --git a/ts/web/nextjs/polaris/src/components/shared/site-icon.test.tsx b/ts/web/nextjs/polaris/src/components/shared/site-icon.test.tsx
index 7de3f00..66282b9 100644
--- a/ts/web/nextjs/polaris/src/components/shared/site-icon.test.tsx
+++ b/ts/web/nextjs/polaris/src/components/shared/site-icon.test.tsx
@@ -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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ // 候选链:自定义图片 → favicon → 首字母;首项挂载前已坏,应停在 favicon。
+ expect(container.querySelector("img")).toHaveAttribute(
+ "src",
+ "https://example.com/favicon.ico",
+ )
+ expect(container.querySelector(".site-icon")).toHaveAttribute(
+ "data-initial",
+ "E",
+ )
+ })
})
diff --git a/ts/web/nextjs/polaris/src/components/shared/site-icon.tsx b/ts/web/nextjs/polaris/src/components/shared/site-icon.tsx
index a2e748d..20fa26c 100644
--- a/ts/web/nextjs/polaris/src/components/shared/site-icon.tsx
+++ b/ts/web/nextjs/polaris/src/components/shared/site-icon.tsx
@@ -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(null)
+ const imageRef = useRef(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) =>
diff --git a/ts/web/nextjs/polaris/src/data/data.test.ts b/ts/web/nextjs/polaris/src/data/data.test.ts
index ef922ad..bfe1124 100644
--- a/ts/web/nextjs/polaris/src/data/data.test.ts
+++ b/ts/web/nextjs/polaris/src/data/data.test.ts
@@ -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("设置包含完整且合法的嵌套契约", () => {
diff --git a/ts/web/nextjs/polaris/src/data/links.json b/ts/web/nextjs/polaris/src/data/links.json
index b4ab8a4..f62db20 100644
--- a/ts/web/nextjs/polaris/src/data/links.json
+++ b/ts/web/nextjs/polaris/src/data/links.json
@@ -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",
diff --git a/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx b/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx
index df27d01..04070a2 100644
--- a/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx
+++ b/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx
@@ -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(
{
})
it.each([
- ["ai", 1],
- ["dev", 15],
- ["design", 0],
+ ["ai", 3],
+ ["dev", 8],
+ ["design", 1],
["learning", 0],
["efficiency", 2],
["media", 2],
diff --git a/ts/web/nextjs/polaris/src/services/services.test.ts b/ts/web/nextjs/polaris/src/services/services.test.ts
index c99aa55..9b96434 100644
--- a/ts/web/nextjs/polaris/src/services/services.test.ts
+++ b/ts/web/nextjs/polaris/src/services/services.test.ts
@@ -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",
])
})