feat(polaris): import bookmarks and refine category scrolling

- 导入 29 条精选公开书签,排除重复与敏感入口
- 将滚动限制在分类页内层卡片区域,并适配窄屏
- 更新数据与页面测试
This commit is contained in:
2026-10-08 19:49:30 +08:00
parent 6641d181ab
commit e37cf9936e
7 changed files with 547 additions and 50 deletions
@@ -1,6 +1,8 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { getLinksByCategory } from "@/services/links"
import CategoryPage, { dynamicParams, generateStaticParams } from "./page"
describe("CategoryPage", () => {
@@ -18,6 +20,7 @@ describe("CategoryPage", () => {
})
it("通过 service 组合开发工具分类页", async () => {
const links = getLinksByCategory("dev")
const page = await CategoryPage({
params: Promise.resolve({ categoryId: "dev" }),
})
@@ -26,8 +29,8 @@ describe("CategoryPage", () => {
expect(
screen.getByRole("heading", { level: 1, name: "开发工具" }),
).toBeInTheDocument()
expect(screen.getByText("共 8 个")).toBeInTheDocument()
expect(container.querySelectorAll(".tool-card")).toHaveLength(8)
expect(screen.getByText(`共 ${links.length} 个`)).toBeInTheDocument()
expect(container.querySelectorAll(".tool-card")).toHaveLength(links.length)
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
"aria-current",
"page",
@@ -35,7 +38,8 @@ describe("CategoryPage", () => {
expect(screen.queryByRole("searchbox")).not.toBeInTheDocument()
})
it("通过同一路由组合空分类页", async () => {
it("通过同一路由组合生活服务分类页", async () => {
const links = getLinksByCategory("life")
const page = await CategoryPage({
params: Promise.resolve({ categoryId: "life" }),
})
@@ -44,7 +48,7 @@ describe("CategoryPage", () => {
expect(
screen.getByRole("heading", { level: 1, name: "生活服务" }),
).toBeInTheDocument()
expect(screen.getByText("共 0 个")).toBeInTheDocument()
expect(screen.getByText("暂无工具")).toBeInTheDocument()
expect(screen.getByText(`共 ${links.length} 个`)).toBeInTheDocument()
expect(screen.queryByText("暂无工具")).not.toBeInTheDocument()
})
})
+159 -3
View File
@@ -569,12 +569,16 @@ a {
}
.category-page {
min-height: 100svh;
height: 100svh;
padding: 20px 20px 20px 0;
}
.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;
background: var(--surface-sidebar);
box-shadow: var(--shadow-panel);
@@ -603,11 +607,35 @@ a {
}
.category-tools {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
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 {
display: flex;
flex: none;
align-items: center;
gap: 12px;
margin-bottom: 20px;
@@ -705,6 +733,7 @@ a {
.tool-grid {
display: grid;
align-content: start;
margin: 0;
padding: 0;
list-style: none;
@@ -726,6 +755,12 @@ a {
text-align: center;
}
.category-tools__viewport > .category-empty-state {
height: 100%;
min-height: 0;
padding-block: 20px;
}
.category-empty-state > svg {
width: 36px;
height: 36px;
@@ -791,7 +826,8 @@ a {
}
.local-search-results,
.sidebar__nav {
.sidebar__nav,
.category-tools__viewport {
scrollbar-color: rgba(100, 116, 139, 0.32) transparent;
scrollbar-width: thin;
}
@@ -816,6 +852,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) {
*,
*::before,
+31 -2
View File
@@ -27,9 +27,9 @@ describe("Polaris 静态数据", () => {
}
})
it("包含 16 条合法链接且分类引用均存在", () => {
it("包含 45 条合法链接且分类引用均存在", () => {
const categoryIds = new Set(categories.map(({ id }) => id))
expect(links).toHaveLength(16)
expect(links).toHaveLength(45)
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
for (const link of links) {
@@ -86,4 +86,33 @@ describe("Polaris 静态数据", () => {
expect(Number.isFinite(settings.background.blur)).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("")
}
})
})
})
+293
View File
@@ -179,5 +179,298 @@
"pinnedSort": 80,
"keywords": ["哔哩哔哩", "视频", "动漫", "社区"],
"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"
describe("CategoryContent", () => {
it("展示开发工具标题、描述、数量和 8 张卡片", () => {
it("展示开发工具标题、描述、数量和链接卡片", () => {
const category = getCategoryById("dev")
if (!category) throw new Error("测试数据缺少 dev 分类")
const links = getLinksByCategory(category.id)
const { container } = render(
<CategoryContent
category={category}
links={getLinksByCategory(category.id)}
/>,
<CategoryContent category={category} links={links} />,
)
expect(
screen.getByRole("heading", { level: 1, name: "开发工具" }),
).toBeInTheDocument()
expect(screen.getByText(category.description)).toBeInTheDocument()
expect(screen.getByText("共 8 个")).toBeInTheDocument()
expect(container.querySelectorAll(".tool-card")).toHaveLength(8)
expect(screen.getByText(`共 ${links.length} 个`)).toBeInTheDocument()
expect(container.querySelectorAll(".tool-card")).toHaveLength(links.length)
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
.getByRole("list", { name: "工具列表" })
.querySelectorAll("a")
expect(toolLinks).toHaveLength(8)
expect(toolLinks).toHaveLength(links.length)
for (const link of toolLinks) {
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
@@ -37,19 +47,18 @@ describe("CategoryContent", () => {
})
it("为空分类展示计数和纯浏览空状态", () => {
// life 分类当前已有数据,空状态契约改用空 links 夹具保证始终可测。
const category = getCategoryById("life")
if (!category) throw new Error("测试数据缺少 life 分类")
render(
<CategoryContent
category={category}
links={getLinksByCategory(category.id)}
/>,
)
render(<CategoryContent category={category} links={[]} />)
expect(screen.getByText("共 0 个")).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("list", { name: "工具列表" }),
@@ -57,24 +66,20 @@ describe("CategoryContent", () => {
})
it.each([
["ai", 3],
["dev", 8],
["design", 1],
["learning", 0],
["efficiency", 2],
["media", 2],
["life", 0],
] as const)("%s 分类展示正确数量", (categoryId, count) => {
"ai",
"dev",
"design",
"learning",
"efficiency",
"media",
"life",
] as const)("%s 分类展示服务提供的数量", (categoryId) => {
const category = getCategoryById(categoryId)
if (!category) throw new Error(`测试数据缺少 ${categoryId} 分类`)
const links = getLinksByCategory(category.id)
render(
<CategoryContent
category={category}
links={getLinksByCategory(category.id)}
/>,
)
render(<CategoryContent category={category} links={links} />)
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">
<GlassPanel className="category-page__panel">
<CategoryHeader category={category} />
<section
className="category-tools"
aria-labelledby="category-tools-title"
>
<section className="category-tools">
<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 ? (
<ToolGrid links={links} />
) : (
<CategoryEmptyState />
)}
</div>
</section>
</GlassPanel>
</main>
@@ -33,14 +33,18 @@ describe("静态数据 service", () => {
const first = getLinksByCategory("dev")
const second = getLinksByCategory("dev")
expect(first).toHaveLength(8)
expect(first).toHaveLength(12)
expect(first.map(({ id }) => id)).toEqual([
"github",
"gitee",
"go",
"go-zero",
"go-stdlib-zh",
"element-plus",
"v-md-editor",
"docker",
"kubernetes",
"tsinghua-mirror",
"npm",
"pnpm",
])
@@ -49,8 +53,10 @@ describe("静态数据 service", () => {
expect(first[0].keywords).not.toBe(second[0].keywords)
expect(getLinksByCategory("design").map(({ id }) => id)).toEqual([
"iconfont",
"haowallpaper",
"iodraw",
])
expect(getLinksByCategory("life")).toEqual([])
expect(getLinksByCategory("life").map(({ id }) => id)).toEqual(["amap"])
})
it("按 pinnedSort 返回首页 6 个常用链接", () => {