Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35aaa0e17e | ||
|
|
e37cf9936e | ||
|
|
6641d181ab | ||
|
|
39bde32376 |
@@ -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 |
@@ -1,6 +1,8 @@
|
|||||||
import { render, screen } from "@testing-library/react"
|
import { render, screen } from "@testing-library/react"
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { getLinksByCategory } from "@/services/links"
|
||||||
|
|
||||||
import CategoryPage, { dynamicParams, generateStaticParams } from "./page"
|
import CategoryPage, { dynamicParams, generateStaticParams } from "./page"
|
||||||
|
|
||||||
describe("CategoryPage", () => {
|
describe("CategoryPage", () => {
|
||||||
@@ -18,6 +20,7 @@ describe("CategoryPage", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("通过 service 组合开发工具分类页", async () => {
|
it("通过 service 组合开发工具分类页", async () => {
|
||||||
|
const links = getLinksByCategory("dev")
|
||||||
const page = await CategoryPage({
|
const page = await CategoryPage({
|
||||||
params: Promise.resolve({ categoryId: "dev" }),
|
params: Promise.resolve({ categoryId: "dev" }),
|
||||||
})
|
})
|
||||||
@@ -26,8 +29,8 @@ describe("CategoryPage", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { level: 1, name: "开发工具" }),
|
screen.getByRole("heading", { level: 1, name: "开发工具" }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(screen.getByText("共 15 个")).toBeInTheDocument()
|
expect(screen.getByText(`共 ${links.length} 个`)).toBeInTheDocument()
|
||||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(15)
|
expect(container.querySelectorAll(".tool-card")).toHaveLength(links.length)
|
||||||
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
|
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
|
||||||
"aria-current",
|
"aria-current",
|
||||||
"page",
|
"page",
|
||||||
@@ -35,7 +38,8 @@ describe("CategoryPage", () => {
|
|||||||
expect(screen.queryByRole("searchbox")).not.toBeInTheDocument()
|
expect(screen.queryByRole("searchbox")).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("通过同一路由组合空分类页", async () => {
|
it("通过同一路由组合生活服务分类页", async () => {
|
||||||
|
const links = getLinksByCategory("life")
|
||||||
const page = await CategoryPage({
|
const page = await CategoryPage({
|
||||||
params: Promise.resolve({ categoryId: "life" }),
|
params: Promise.resolve({ categoryId: "life" }),
|
||||||
})
|
})
|
||||||
@@ -44,7 +48,7 @@ describe("CategoryPage", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { level: 1, name: "生活服务" }),
|
screen.getByRole("heading", { level: 1, name: "生活服务" }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(screen.getByText("共 0 个")).toBeInTheDocument()
|
expect(screen.getByText(`共 ${links.length} 个`)).toBeInTheDocument()
|
||||||
expect(screen.getByText("暂无工具")).toBeInTheDocument()
|
expect(screen.queryByText("暂无工具")).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -267,23 +267,21 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-shell[data-sidebar-collapsed="true"] .sidebar__header {
|
.app-shell[data-sidebar-collapsed="true"] .sidebar__header {
|
||||||
|
min-height: 116px;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-inline: 10px;
|
gap: 4px;
|
||||||
|
padding: 12px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell[data-sidebar-collapsed="true"] .sidebar__brand {
|
.app-shell[data-sidebar-collapsed="true"] .sidebar__brand {
|
||||||
|
flex: none;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell[data-sidebar-collapsed="true"] .sidebar__header > button {
|
.app-shell[data-sidebar-collapsed="true"] .sidebar__header > button {
|
||||||
position: absolute;
|
width: 48px;
|
||||||
top: 22px;
|
height: 48px;
|
||||||
right: -14px;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1px solid var(--border-glass);
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell[data-sidebar-collapsed="true"] .sidebar__nav {
|
.app-shell[data-sidebar-collapsed="true"] .sidebar__nav {
|
||||||
@@ -571,12 +569,16 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-page {
|
.category-page {
|
||||||
min-height: 100svh;
|
height: 100svh;
|
||||||
padding: 20px 20px 20px 0;
|
padding: 20px 20px 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-page__panel {
|
.category-page__panel {
|
||||||
min-height: calc(100svh - 40px);
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
padding: 48px clamp(32px, 4vw, 64px) 56px;
|
padding: 48px clamp(32px, 4vw, 64px) 56px;
|
||||||
background: var(--surface-sidebar);
|
background: var(--surface-sidebar);
|
||||||
box-shadow: var(--shadow-panel);
|
box-shadow: var(--shadow-panel);
|
||||||
@@ -605,11 +607,35 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-tools {
|
.category-tools {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
margin-top: 36px;
|
margin-top: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-tools__viewport {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.36);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(82, 113, 151, 0.09);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 10px rgba(29, 56, 93, 0.07),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tools__viewport:focus-visible {
|
||||||
|
outline-offset: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
.category-section-header {
|
.category-section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -623,11 +649,12 @@ a {
|
|||||||
|
|
||||||
.category-section-header__count {
|
.category-section-header__count {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
color: var(--text-muted);
|
color: #334155;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(71, 104, 138, 0.24);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(238, 246, 253, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-card {
|
.tool-card {
|
||||||
@@ -707,6 +734,7 @@ a {
|
|||||||
|
|
||||||
.tool-grid {
|
.tool-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -728,6 +756,12 @@ a {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-tools__viewport > .category-empty-state {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding-block: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.category-empty-state > svg {
|
.category-empty-state > svg {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -793,7 +827,8 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.local-search-results,
|
.local-search-results,
|
||||||
.sidebar__nav {
|
.sidebar__nav,
|
||||||
|
.category-tools__viewport {
|
||||||
scrollbar-color: rgba(100, 116, 139, 0.32) transparent;
|
scrollbar-color: rgba(100, 116, 139, 0.32) transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
@@ -818,6 +853,126 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 999px) {
|
||||||
|
.tool-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* Keep category navigation usable without squeezing the boxed content. */
|
||||||
|
.app-shell:has(.category-page) .sidebar {
|
||||||
|
inset: 12px 12px auto;
|
||||||
|
width: auto;
|
||||||
|
height: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.category-page) .sidebar__header,
|
||||||
|
.app-shell:has(.category-page)[data-sidebar-collapsed="true"]
|
||||||
|
.sidebar__header {
|
||||||
|
min-height: 56px;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.category-page) .sidebar__header > button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.category-page) .sidebar__brand {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.category-page) .sidebar__nav,
|
||||||
|
.app-shell:has(.category-page)[data-sidebar-collapsed="true"] .sidebar__nav {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.category-page) .sidebar__categories {
|
||||||
|
flex: none;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.category-page) .sidebar__nav-item,
|
||||||
|
.app-shell:has(.category-page)[data-sidebar-collapsed="true"]
|
||||||
|
.sidebar__nav-item {
|
||||||
|
width: auto;
|
||||||
|
height: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
flex: none;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.category-page) .sidebar__site-name.sr-only,
|
||||||
|
.app-shell:has(.category-page) .sidebar__label.sr-only {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:has(.category-page) .app-shell__content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-page {
|
||||||
|
padding: 148px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-page__panel {
|
||||||
|
padding: 28px 20px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tools__viewport {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header__title {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When the header would leave only a sliver of cards, let the page scroll instead. */
|
||||||
|
@media (max-height: 540px), (max-width: 767px) and (max-height: 700px) {
|
||||||
|
.category-page {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-page__panel {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100svh - 40px);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tools {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tools__viewport {
|
||||||
|
overflow: visible;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tools__viewport > .category-empty-state {
|
||||||
|
height: auto;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) and (max-height: 700px) {
|
||||||
|
.category-page__panel {
|
||||||
|
min-height: calc(100svh - 160px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
siBilibili,
|
siBilibili,
|
||||||
siBitbucket,
|
siBitbucket,
|
||||||
|
siDeepseek,
|
||||||
siDocker,
|
siDocker,
|
||||||
siFigma,
|
siFigma,
|
||||||
siGithub,
|
siGithub,
|
||||||
siGitlab,
|
siGitlab,
|
||||||
siGmail,
|
siGmail,
|
||||||
|
siGo,
|
||||||
|
siGooglegemini,
|
||||||
siGoland,
|
siGoland,
|
||||||
siJetbrains,
|
siJetbrains,
|
||||||
siKubernetes,
|
siKubernetes,
|
||||||
@@ -23,11 +26,14 @@ import {
|
|||||||
export const SIMPLE_ICON_MAP: Readonly<Record<string, SimpleIcon>> = {
|
export const SIMPLE_ICON_MAP: Readonly<Record<string, SimpleIcon>> = {
|
||||||
bilibili: siBilibili,
|
bilibili: siBilibili,
|
||||||
bitbucket: siBitbucket,
|
bitbucket: siBitbucket,
|
||||||
|
deepseek: siDeepseek,
|
||||||
docker: siDocker,
|
docker: siDocker,
|
||||||
figma: siFigma,
|
figma: siFigma,
|
||||||
github: siGithub,
|
github: siGithub,
|
||||||
gitlab: siGitlab,
|
gitlab: siGitlab,
|
||||||
gmail: siGmail,
|
gmail: siGmail,
|
||||||
|
go: siGo,
|
||||||
|
googlegemini: siGooglegemini,
|
||||||
goland: siGoland,
|
goland: siGoland,
|
||||||
jetbrains: siJetbrains,
|
jetbrains: siJetbrains,
|
||||||
kubernetes: siKubernetes,
|
kubernetes: siKubernetes,
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react"
|
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"
|
import { SiteIcon } from "./site-icon"
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
describe("SiteIcon", () => {
|
describe("SiteIcon", () => {
|
||||||
it("渲染已知 Simple Icon", () => {
|
it("渲染已知 Simple Icon", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
@@ -81,4 +85,62 @@ describe("SiteIcon", () => {
|
|||||||
fireEvent.error(container.querySelector("img")!)
|
fireEvent.error(container.querySelector("img")!)
|
||||||
expect(screen.getByText("E")).toBeInTheDocument()
|
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"
|
"use client"
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element -- favicon 来源由数据动态决定,静态 Image 配置无法预枚举域名。 */
|
/* eslint-disable @next/next/no-img-element -- favicon 来源由数据动态决定,静态 Image 配置无法预枚举域名。 */
|
||||||
import { useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getIconCandidates,
|
getIconCandidates,
|
||||||
@@ -29,6 +29,7 @@ function SiteIconContent({
|
|||||||
}: SiteIconContentProps) {
|
}: SiteIconContentProps) {
|
||||||
const [candidateIndex, setCandidateIndex] = useState(0)
|
const [candidateIndex, setCandidateIndex] = useState(0)
|
||||||
const [loadedImageSrc, setLoadedImageSrc] = useState<string | null>(null)
|
const [loadedImageSrc, setLoadedImageSrc] = useState<string | null>(null)
|
||||||
|
const imageRef = useRef<HTMLImageElement | null>(null)
|
||||||
const candidate = candidates[candidateIndex]
|
const candidate = candidates[candidateIndex]
|
||||||
const initialCandidate = candidates.at(-1)
|
const initialCandidate = candidates.at(-1)
|
||||||
const initialValue =
|
const initialValue =
|
||||||
@@ -36,6 +37,21 @@ function SiteIconContent({
|
|||||||
const wrapperClassName = cn("site-icon", className)
|
const wrapperClassName = cn("site-icon", className)
|
||||||
const style = { width: size, height: size }
|
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") {
|
if (candidate.kind === "simple") {
|
||||||
const icon = SIMPLE_ICON_MAP[candidate.slug]
|
const icon = SIMPLE_ICON_MAP[candidate.slug]
|
||||||
return (
|
return (
|
||||||
@@ -72,6 +88,7 @@ function SiteIconContent({
|
|||||||
alt={`${link.title} 图标`}
|
alt={`${link.title} 图标`}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
|
ref={imageRef}
|
||||||
onLoad={() => setLoadedImageSrc(candidate.src)}
|
onLoad={() => setLoadedImageSrc(candidate.src)}
|
||||||
onError={() =>
|
onError={() =>
|
||||||
setCandidateIndex((index) =>
|
setCandidateIndex((index) =>
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ describe("Polaris 静态数据", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it("包含 20 条合法链接且分类引用均存在", () => {
|
it("包含 45 条合法链接且分类引用均存在", () => {
|
||||||
const categoryIds = new Set(categories.map(({ id }) => id))
|
const categoryIds = new Set(categories.map(({ id }) => id))
|
||||||
expect(links).toHaveLength(20)
|
expect(links).toHaveLength(45)
|
||||||
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
|
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
|
||||||
|
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
@@ -55,13 +55,13 @@ describe("Polaris 静态数据", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it("8 条常用链接拥有唯一 pinnedSort", () => {
|
it("6 条常用链接拥有唯一 pinnedSort", () => {
|
||||||
const pinned = links.filter(({ pinned }) => pinned)
|
const pinned = links.filter(({ pinned }) => pinned)
|
||||||
const pinnedSorts = pinned.map(({ pinnedSort }) => pinnedSort)
|
const pinnedSorts = pinned.map(({ pinnedSort }) => pinnedSort)
|
||||||
|
|
||||||
expect(pinned).toHaveLength(8)
|
expect(pinned).toHaveLength(6)
|
||||||
expect(pinnedSorts.every(Number.isFinite)).toBe(true)
|
expect(pinnedSorts.every(Number.isFinite)).toBe(true)
|
||||||
expect(new Set(pinnedSorts).size).toBe(8)
|
expect(new Set(pinnedSorts).size).toBe(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("设置包含完整且合法的嵌套契约", () => {
|
it("设置包含完整且合法的嵌套契约", () => {
|
||||||
@@ -86,4 +86,33 @@ describe("Polaris 静态数据", () => {
|
|||||||
expect(Number.isFinite(settings.background.blur)).toBe(true)
|
expect(Number.isFinite(settings.background.blur)).toBe(true)
|
||||||
expect(Number.isFinite(settings.background.overlay)).toBe(true)
|
expect(Number.isFinite(settings.background.overlay)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("导入链接卫生检查", () => {
|
||||||
|
const normalizeUrl = (url: string) => url.replace(/\/+$/, "")
|
||||||
|
|
||||||
|
it("全部链接 URL 唯一(忽略末尾斜杠)", () => {
|
||||||
|
const urls = links.map(({ url }) => normalizeUrl(url))
|
||||||
|
expect(hasUniqueValues(urls)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("不包含本地或内网地址", () => {
|
||||||
|
for (const link of links) {
|
||||||
|
const { hostname } = new URL(link.url)
|
||||||
|
expect(hostname).toMatch(/^[\w.-]+$/)
|
||||||
|
expect(hostname).not.toBe("localhost")
|
||||||
|
expect(hostname).not.toMatch(/\.(local|lan|internal)$/)
|
||||||
|
expect(hostname).not.toMatch(
|
||||||
|
/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|0\.0\.0\.0)/,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("不携带查询参数或片段,防止 sid/jumpuin 等会话标识混入", () => {
|
||||||
|
for (const link of links) {
|
||||||
|
const url = new URL(link.url)
|
||||||
|
expect([...url.searchParams.keys()]).toEqual([])
|
||||||
|
expect(url.hash).toBe("")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,71 +12,16 @@
|
|||||||
"sort": 10
|
"sort": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "gitlab",
|
"id": "gitee",
|
||||||
"title": "GitLab",
|
"title": "Gitee",
|
||||||
"description": "一体化 DevOps 平台",
|
"description": "国内领先的开发者社区",
|
||||||
"url": "https://gitlab.com",
|
"url": "https://gitee.com",
|
||||||
"icon": "si:gitlab",
|
"icon": "si:gitee",
|
||||||
"categoryId": "dev",
|
"categoryId": "dev",
|
||||||
"pinned": false,
|
"pinned": false,
|
||||||
"keywords": ["代码", "仓库", "devops", "ci", "cd"],
|
"keywords": ["代码", "仓库", "devops", "ci", "cd"],
|
||||||
"sort": 20
|
"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",
|
"id": "docker",
|
||||||
"title": "Docker",
|
"title": "Docker",
|
||||||
@@ -99,40 +44,6 @@
|
|||||||
"keywords": ["k8s", "容器", "编排", "云原生"],
|
"keywords": ["k8s", "容器", "编排", "云原生"],
|
||||||
"sort": 90
|
"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",
|
"id": "npm",
|
||||||
"title": "npm",
|
"title": "npm",
|
||||||
@@ -156,16 +67,36 @@
|
|||||||
"sort": 140
|
"sort": 140
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "figma",
|
"id": "go",
|
||||||
"title": "Figma",
|
"title": "Go 标准库文档",
|
||||||
"description": "界面设计协作工具",
|
"description": "Go 语言标准库 API 参考文档",
|
||||||
"url": "https://www.figma.com",
|
"url": "https://pkg.go.dev/std",
|
||||||
"icon": "si:figma",
|
"icon": "si:go",
|
||||||
"categoryId": "dev",
|
"categoryId": "dev",
|
||||||
"pinned": true,
|
"pinned": false,
|
||||||
"pinnedSort": 50,
|
"keywords": ["go", "golang", "标准库", "文档"],
|
||||||
"keywords": ["设计", "ui", "协作"],
|
"sort": 30
|
||||||
"sort": 150
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"id": "chatgpt",
|
||||||
@@ -179,6 +110,28 @@
|
|||||||
"keywords": ["ai", "人工智能", "对话", "写作"],
|
"keywords": ["ai", "人工智能", "对话", "写作"],
|
||||||
"sort": 10
|
"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",
|
"id": "gmail",
|
||||||
"title": "Gmail",
|
"title": "Gmail",
|
||||||
@@ -226,5 +179,298 @@
|
|||||||
"pinnedSort": 80,
|
"pinnedSort": 80,
|
||||||
"keywords": ["哔哩哔哩", "视频", "动漫", "社区"],
|
"keywords": ["哔哩哔哩", "视频", "动漫", "社区"],
|
||||||
"sort": 20
|
"sort": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chatbot-arena",
|
||||||
|
"title": "Chatbot Arena",
|
||||||
|
"description": "大模型匿名对战与排行榜社区",
|
||||||
|
"url": "https://huggingface.co/spaces/lmarena-ai/chatbot-arena",
|
||||||
|
"categoryId": "ai",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["ai", "大模型", "排行榜", "对战"],
|
||||||
|
"sort": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "go-stdlib-zh",
|
||||||
|
"title": "Go 标准库中文文档",
|
||||||
|
"description": "studygolang 维护的标准库中文参考",
|
||||||
|
"url": "https://studygolang.com/pkgdoc",
|
||||||
|
"categoryId": "dev",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["go", "golang", "标准库", "文档"],
|
||||||
|
"sort": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "element-plus",
|
||||||
|
"title": "Element Plus",
|
||||||
|
"description": "Vue 3 组件库与组件总览文档",
|
||||||
|
"url": "https://element-plus.org",
|
||||||
|
"categoryId": "dev",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["vue", "组件库", "ui", "文档"],
|
||||||
|
"sort": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "v-md-editor",
|
||||||
|
"title": "v-md-editor",
|
||||||
|
"description": "Vue Markdown 编辑器中文文档",
|
||||||
|
"url": "https://code-farmer-i.github.io/vue-markdown-editor/zh/",
|
||||||
|
"categoryId": "dev",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["vue", "markdown", "编辑器", "组件"],
|
||||||
|
"sort": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tsinghua-mirror",
|
||||||
|
"title": "清华镜像站",
|
||||||
|
"description": "清华 TUNA 开源软件镜像仓库",
|
||||||
|
"url": "https://mirrors.tuna.tsinghua.edu.cn",
|
||||||
|
"categoryId": "dev",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["镜像", "开源", "linux", "pypi"],
|
||||||
|
"sort": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "haowallpaper",
|
||||||
|
"title": "哲风壁纸",
|
||||||
|
"description": "高质量免费壁纸下载站",
|
||||||
|
"url": "https://www.haowallpaper.com",
|
||||||
|
"categoryId": "design",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["壁纸", "素材", "背景", "4k"],
|
||||||
|
"sort": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "leetcode",
|
||||||
|
"title": "LeetCode",
|
||||||
|
"description": "算法与数据结构刷题平台",
|
||||||
|
"url": "https://leetcode.cn",
|
||||||
|
"icon": "si:leetcode",
|
||||||
|
"categoryId": "learning",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["算法", "刷题", "面试", "题库"],
|
||||||
|
"sort": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "socpk",
|
||||||
|
"title": "SOCPK",
|
||||||
|
"description": "手机 SoC 性能天梯图与排行",
|
||||||
|
"url": "https://www.socpk.com",
|
||||||
|
"categoryId": "efficiency",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["手机", "芯片", "天梯图", "性能"],
|
||||||
|
"sort": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wiki-52poke",
|
||||||
|
"title": "神奇宝贝百科",
|
||||||
|
"description": "宝可梦中文资料维基",
|
||||||
|
"url": "https://wiki.52poke.com/wiki/%E4%B8%BB%E9%A1%B5",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["宝可梦", "口袋妖怪", "攻略", "资料"],
|
||||||
|
"sort": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wiki-52poke-typechart",
|
||||||
|
"title": "宝可梦属性相克表",
|
||||||
|
"description": "宝可梦全属性克制关系速查",
|
||||||
|
"url": "https://wiki.52poke.com/wiki/%E5%B1%9E%E6%80%A7%E7%9B%B8%E5%85%8B%E8%A1%A8",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["宝可梦", "属性", "相克", "速查"],
|
||||||
|
"sort": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wiki-blhx",
|
||||||
|
"title": "碧蓝航线WIKI",
|
||||||
|
"description": "碧蓝航线官方中文百科",
|
||||||
|
"url": "https://wiki.biligame.com/blhx/%E9%A6%96%E9%A1%B5",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["碧蓝航线", "手游", "攻略", "wiki"],
|
||||||
|
"sort": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wiki-blhx-boss",
|
||||||
|
"title": "碧蓝航线月度BOSS解析",
|
||||||
|
"description": "大型作战月度 BOSS 攻略解析",
|
||||||
|
"url": "https://wiki.biligame.com/blhx/%E5%A4%A7%E5%9E%8B%E4%BD%9C%E6%88%98%E6%9C%88%E5%BA%A6BOSS%E8%A7%A3%E6%9E%90",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["碧蓝航线", "boss", "攻略", "大型作战"],
|
||||||
|
"sort": 110
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wiki-fgo",
|
||||||
|
"title": "Mooncell",
|
||||||
|
"description": "FGO 中文维基与攻略资料站",
|
||||||
|
"url": "https://fgo.wiki/w/%E9%A6%96%E9%A1%B5",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["fgo", "mooncell", "从者", "攻略"],
|
||||||
|
"sort": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wiki-stardew",
|
||||||
|
"title": "星露谷物语中文维基",
|
||||||
|
"description": "星露谷物语官方中文资料维基",
|
||||||
|
"url": "https://zh.stardewvalleywiki.com",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["星露谷物语", "攻略", "作物", "wiki"],
|
||||||
|
"sort": 130
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wiki-terraria",
|
||||||
|
"title": "泰拉瑞亚中文维基",
|
||||||
|
"description": "泰拉瑞亚官方中文百科",
|
||||||
|
"url": "https://terraria.wiki.gg/zh",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["泰拉瑞亚", "攻略", "物品", "wiki"],
|
||||||
|
"sort": 140
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wiki-moegirl",
|
||||||
|
"title": "萌娘百科",
|
||||||
|
"description": "ACG 中文百科社区",
|
||||||
|
"url": "https://zh.moegirl.org.cn/zh-hans/Mainpage",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["动漫", "acg", "百科", "二次元"],
|
||||||
|
"sort": 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "farmgamehub",
|
||||||
|
"title": "星露谷洒水器规划器",
|
||||||
|
"description": "星露谷物语洒水器布局可视化工具",
|
||||||
|
"url": "https://www.farmgamehub.com/zh/tools/stardew-sprinklers",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["星露谷物语", "洒水器", "布局", "工具"],
|
||||||
|
"sort": 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iodraw",
|
||||||
|
"title": "iodraw",
|
||||||
|
"description": "在线思维导图与白板工具",
|
||||||
|
"url": "https://www.iodraw.com/mind",
|
||||||
|
"categoryId": "design",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["思维导图", "脑图", "白板", "在线"],
|
||||||
|
"sort": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "google-translate",
|
||||||
|
"title": "Google 翻译",
|
||||||
|
"description": "Google 在线机器翻译",
|
||||||
|
"url": "https://translate.google.com",
|
||||||
|
"icon": "si:googletranslate",
|
||||||
|
"categoryId": "efficiency",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["翻译", "翻译器", "英文", "中英"],
|
||||||
|
"sort": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bing-translate",
|
||||||
|
"title": "微软翻译",
|
||||||
|
"description": "微软必应在线翻译",
|
||||||
|
"url": "https://www.bing.com/translator",
|
||||||
|
"categoryId": "efficiency",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["翻译", "必应", "英文", "中英"],
|
||||||
|
"sort": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "oschina-encrypt",
|
||||||
|
"title": "OSChina 加密解密",
|
||||||
|
"description": "在线加密解密工具集",
|
||||||
|
"url": "https://tool.oschina.net/encrypt",
|
||||||
|
"categoryId": "efficiency",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["加密", "解密", "base64", "工具"],
|
||||||
|
"sort": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tencent-cloud-console",
|
||||||
|
"title": "腾讯云控制台",
|
||||||
|
"description": "腾讯云服务管理控制台",
|
||||||
|
"url": "https://console.cloud.tencent.com",
|
||||||
|
"categoryId": "efficiency",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["腾讯云", "控制台", "云服务", "服务器"],
|
||||||
|
"sort": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aliyun-console",
|
||||||
|
"title": "阿里云轻量控制台",
|
||||||
|
"description": "阿里云轻量应用服务器控制台",
|
||||||
|
"url": "https://swas.console.aliyun.com",
|
||||||
|
"categoryId": "efficiency",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["阿里云", "控制台", "轻量服务器", "云服务"],
|
||||||
|
"sort": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spotify",
|
||||||
|
"title": "Spotify",
|
||||||
|
"description": "在线音乐流媒体播放器",
|
||||||
|
"url": "https://open.spotify.com",
|
||||||
|
"icon": "si:spotify",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["音乐", "播放器", "歌单", "流媒体"],
|
||||||
|
"sort": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nexusmods",
|
||||||
|
"title": "Nexus Mods",
|
||||||
|
"description": "星露谷等游戏 MOD 下载社区",
|
||||||
|
"url": "https://www.nexusmods.com/games/stardewvalley/mods",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["mod", "游戏", "星露谷物语", "下载"],
|
||||||
|
"sort": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pokemon-showdown",
|
||||||
|
"title": "Pokemon Showdown",
|
||||||
|
"description": "宝可梦在线对战模拟器",
|
||||||
|
"url": "https://play.pokemonshowdown.com",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["宝可梦", "对战", "游戏", "在线"],
|
||||||
|
"sort": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "expreview",
|
||||||
|
"title": "超能网",
|
||||||
|
"description": "硬件评测与数码资讯媒体",
|
||||||
|
"url": "https://www.expreview.com",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["硬件", "评测", "数码", "装机"],
|
||||||
|
"sort": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chairo",
|
||||||
|
"title": "Chairo 导航",
|
||||||
|
"description": "二次元 ACG 网站导航",
|
||||||
|
"url": "https://www.chairo.cc",
|
||||||
|
"categoryId": "media",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["导航", "二次元", "acg", "动漫"],
|
||||||
|
"sort": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "amap",
|
||||||
|
"title": "高德地图",
|
||||||
|
"description": "在线地图与出行导航",
|
||||||
|
"url": "https://www.amap.com",
|
||||||
|
"categoryId": "life",
|
||||||
|
"pinned": false,
|
||||||
|
"keywords": ["地图", "导航", "出行", "路线"],
|
||||||
|
"sort": 10
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,29 +7,39 @@ import { getLinksByCategory } from "@/services/links"
|
|||||||
import { CategoryContent } from "./category-content"
|
import { CategoryContent } from "./category-content"
|
||||||
|
|
||||||
describe("CategoryContent", () => {
|
describe("CategoryContent", () => {
|
||||||
it("展示开发工具标题、描述、数量和 15 张卡片", () => {
|
it("展示开发工具标题、描述、数量和链接卡片", () => {
|
||||||
const category = getCategoryById("dev")
|
const category = getCategoryById("dev")
|
||||||
if (!category) throw new Error("测试数据缺少 dev 分类")
|
if (!category) throw new Error("测试数据缺少 dev 分类")
|
||||||
|
const links = getLinksByCategory(category.id)
|
||||||
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<CategoryContent
|
<CategoryContent category={category} links={links} />,
|
||||||
category={category}
|
|
||||||
links={getLinksByCategory(category.id)}
|
|
||||||
/>,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { level: 1, name: "开发工具" }),
|
screen.getByRole("heading", { level: 1, name: "开发工具" }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(screen.getByText(category.description)).toBeInTheDocument()
|
expect(screen.getByText(category.description)).toBeInTheDocument()
|
||||||
expect(screen.getByText("共 15 个")).toBeInTheDocument()
|
expect(screen.getByText(`共 ${links.length} 个`)).toBeInTheDocument()
|
||||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(15)
|
expect(container.querySelectorAll(".tool-card")).toHaveLength(links.length)
|
||||||
expect(screen.queryByText("暂无工具")).not.toBeInTheDocument()
|
expect(screen.queryByText("暂无工具")).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const viewport = screen.getByRole("region", { name: "全部工具" })
|
||||||
|
expect(viewport).toHaveAttribute("tabindex", "0")
|
||||||
|
expect(viewport).toContainElement(
|
||||||
|
screen.getByRole("list", { name: "工具列表" }),
|
||||||
|
)
|
||||||
|
expect(viewport).not.toContainElement(
|
||||||
|
screen.getByText(`共 ${links.length} 个`),
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
container.querySelector(".category-page__panel"),
|
||||||
|
).not.toHaveAttribute("tabindex")
|
||||||
|
|
||||||
const toolLinks = screen
|
const toolLinks = screen
|
||||||
.getByRole("list", { name: "工具列表" })
|
.getByRole("list", { name: "工具列表" })
|
||||||
.querySelectorAll("a")
|
.querySelectorAll("a")
|
||||||
expect(toolLinks).toHaveLength(15)
|
expect(toolLinks).toHaveLength(links.length)
|
||||||
for (const link of toolLinks) {
|
for (const link of toolLinks) {
|
||||||
expect(link).toHaveAttribute("target", "_blank")
|
expect(link).toHaveAttribute("target", "_blank")
|
||||||
expect(link).toHaveAttribute("rel", "noopener noreferrer")
|
expect(link).toHaveAttribute("rel", "noopener noreferrer")
|
||||||
@@ -37,19 +47,18 @@ describe("CategoryContent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("为空分类展示计数和纯浏览空状态", () => {
|
it("为空分类展示计数和纯浏览空状态", () => {
|
||||||
const category = getCategoryById("design")
|
// life 分类当前已有数据,空状态契约改用空 links 夹具保证始终可测。
|
||||||
if (!category) throw new Error("测试数据缺少 design 分类")
|
const category = getCategoryById("life")
|
||||||
|
if (!category) throw new Error("测试数据缺少 life 分类")
|
||||||
|
|
||||||
render(
|
render(<CategoryContent category={category} links={[]} />)
|
||||||
<CategoryContent
|
|
||||||
category={category}
|
|
||||||
links={getLinksByCategory(category.id)}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByText("共 0 个")).toBeInTheDocument()
|
expect(screen.getByText("共 0 个")).toBeInTheDocument()
|
||||||
expect(screen.getByText("暂无工具")).toBeInTheDocument()
|
expect(screen.getByText("暂无工具")).toBeInTheDocument()
|
||||||
expect(screen.getByText("这个分类下还没有添加链接。")).toBeInTheDocument()
|
expect(screen.getByText("这个分类下还没有添加链接。")).toBeInTheDocument()
|
||||||
|
const viewport = screen.getByRole("region", { name: "全部工具" })
|
||||||
|
expect(viewport).toContainElement(screen.getByText("暂无工具"))
|
||||||
|
expect(viewport).not.toHaveAttribute("tabindex")
|
||||||
expect(screen.queryByRole("button")).not.toBeInTheDocument()
|
expect(screen.queryByRole("button")).not.toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("list", { name: "工具列表" }),
|
screen.queryByRole("list", { name: "工具列表" }),
|
||||||
@@ -57,24 +66,20 @@ describe("CategoryContent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["ai", 1],
|
"ai",
|
||||||
["dev", 15],
|
"dev",
|
||||||
["design", 0],
|
"design",
|
||||||
["learning", 0],
|
"learning",
|
||||||
["efficiency", 2],
|
"efficiency",
|
||||||
["media", 2],
|
"media",
|
||||||
["life", 0],
|
"life",
|
||||||
] as const)("%s 分类展示正确数量", (categoryId, count) => {
|
] as const)("%s 分类展示服务提供的数量", (categoryId) => {
|
||||||
const category = getCategoryById(categoryId)
|
const category = getCategoryById(categoryId)
|
||||||
if (!category) throw new Error(`测试数据缺少 ${categoryId} 分类`)
|
if (!category) throw new Error(`测试数据缺少 ${categoryId} 分类`)
|
||||||
|
const links = getLinksByCategory(category.id)
|
||||||
|
|
||||||
render(
|
render(<CategoryContent category={category} links={links} />)
|
||||||
<CategoryContent
|
|
||||||
category={category}
|
|
||||||
links={getLinksByCategory(category.id)}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByText(`共 ${count} 个`)).toBeInTheDocument()
|
expect(screen.getByText(`共 ${links.length} 个`)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,16 +15,20 @@ export function CategoryContent({ category, links }: CategoryContentProps) {
|
|||||||
<main className="category-page">
|
<main className="category-page">
|
||||||
<GlassPanel className="category-page__panel">
|
<GlassPanel className="category-page__panel">
|
||||||
<CategoryHeader category={category} />
|
<CategoryHeader category={category} />
|
||||||
<section
|
<section className="category-tools">
|
||||||
className="category-tools"
|
|
||||||
aria-labelledby="category-tools-title"
|
|
||||||
>
|
|
||||||
<CategorySectionHeader count={links.length} />
|
<CategorySectionHeader count={links.length} />
|
||||||
|
<div
|
||||||
|
className="category-tools__viewport"
|
||||||
|
role="region"
|
||||||
|
aria-labelledby="category-tools-title"
|
||||||
|
tabIndex={links.length > 0 ? 0 : undefined}
|
||||||
|
>
|
||||||
{links.length > 0 ? (
|
{links.length > 0 ? (
|
||||||
<ToolGrid links={links} />
|
<ToolGrid links={links} />
|
||||||
) : (
|
) : (
|
||||||
<CategoryEmptyState />
|
<CategoryEmptyState />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -33,39 +33,39 @@ describe("静态数据 service", () => {
|
|||||||
const first = getLinksByCategory("dev")
|
const first = getLinksByCategory("dev")
|
||||||
const second = getLinksByCategory("dev")
|
const second = getLinksByCategory("dev")
|
||||||
|
|
||||||
expect(first).toHaveLength(15)
|
expect(first).toHaveLength(12)
|
||||||
expect(first.map(({ id }) => id)).toEqual([
|
expect(first.map(({ id }) => id)).toEqual([
|
||||||
"github",
|
"github",
|
||||||
"gitlab",
|
"gitee",
|
||||||
"bitbucket",
|
"go",
|
||||||
"vscode",
|
"go-zero",
|
||||||
"jetbrains",
|
"go-stdlib-zh",
|
||||||
"goland",
|
"element-plus",
|
||||||
"postman",
|
"v-md-editor",
|
||||||
"docker",
|
"docker",
|
||||||
"kubernetes",
|
"kubernetes",
|
||||||
"vercel",
|
"tsinghua-mirror",
|
||||||
"netlify",
|
|
||||||
"render",
|
|
||||||
"npm",
|
"npm",
|
||||||
"pnpm",
|
"pnpm",
|
||||||
"figma",
|
|
||||||
])
|
])
|
||||||
expect(first).not.toBe(second)
|
expect(first).not.toBe(second)
|
||||||
expect(first[0]).not.toBe(second[0])
|
expect(first[0]).not.toBe(second[0])
|
||||||
expect(first[0].keywords).not.toBe(second[0].keywords)
|
expect(first[0].keywords).not.toBe(second[0].keywords)
|
||||||
expect(getLinksByCategory("design")).toEqual([])
|
expect(getLinksByCategory("design").map(({ id }) => id)).toEqual([
|
||||||
|
"iconfont",
|
||||||
|
"haowallpaper",
|
||||||
|
"iodraw",
|
||||||
|
])
|
||||||
|
expect(getLinksByCategory("life").map(({ id }) => id)).toEqual(["amap"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("按 pinnedSort 返回首页 8 个常用链接", () => {
|
it("按 pinnedSort 返回首页 6 个常用链接", () => {
|
||||||
expect(getPinnedLinks().map(({ id }) => id)).toEqual([
|
expect(getPinnedLinks().map(({ id }) => id)).toEqual([
|
||||||
"github",
|
"github",
|
||||||
"chatgpt",
|
"chatgpt",
|
||||||
"gmail",
|
"gmail",
|
||||||
"notion",
|
"notion",
|
||||||
"figma",
|
|
||||||
"youtube",
|
"youtube",
|
||||||
"vercel",
|
|
||||||
"bilibili",
|
"bilibili",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user