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", ]) })