feat(ts): 实现 Polaris 可交互首页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 23:53:28 +08:00
co-authored by Claude Opus 4.8
parent 7eaa9e6f80
commit 9754d61ffe
71 changed files with 15613 additions and 46 deletions
+8
View File
@@ -24,6 +24,14 @@ node_modules/
dist/ dist/
.astro/ .astro/
*.tsbuildinfo *.tsbuildinfo
.next/
out/
coverage/
.env*
!.env.example
# ---- Claude Code ----
.claude/worktrees/
# ---- IDE / 编辑器 ---- # ---- IDE / 编辑器 ----
.idea/ .idea/
+5
View File
@@ -0,0 +1,5 @@
.next/
out/
node_modules/
coverage/
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,453 @@
# Polaris 首页实现规格
> 日期:2026-08-26
>
> 状态:用户已批准,可进入实施
>
> 实现分支:`feat/polaris-homepage`
## 1. 背景与目标
Polaris 是一个轻量、安静、高效的个人浏览器起始页,不是后台管理系统或信息密集型 Dashboard。
本次工作只建立推荐的项目结构并实现完整可交互首页。用户验收首页视觉和交互后,才会明确决定是否继续实现分类页及第一阶段其余目标。
## 2. 范围
### 2.1 本次实现
- 初始化 Next.js App Router 项目;
- 建立 Feature-first 混合目录;
- 整理现有设计文档和资产位置;
- 实现低饱和青蓝 CSS 渐变背景与浅色玻璃视觉;
- 实现可折叠且持久化的桌面侧栏;
- 实现实时时钟、日期和分时问候语;
- 实现 Google、Bing、GitHub、Local 四种严格分离的搜索模式;
- 实现 Local 搜索结果、键盘导航和空状态;
- 实现 8 个常用链接;
- 实现统一图标 fallback;
- 建立本地 JSON、service、类型与数据校验边界;
- 完成单元测试、组件交互测试、静态构建和真实页面视觉检查。
### 2.2 本次不实现
- 正式分类页;
- Go API 与 PostgreSQL;
- 管理页或隐藏管理入口;
- 深色模式;
- 移动端与平板布局;
- 自定义背景上传;
- 自定义搜索引擎、URL 直达、AI 搜索;
- 加载骨架和后端错误重试。
## 3. 已确认决策
| 决策项 | 结论 |
| ---------- | ----------------------------------------------------------------- |
| 首页范围 | 完整可交互首页 |
| 目录方案 | Feature-first + 共享基础层 |
| 分支粒度 | 项目骨架与首页位于同一个 `feat/polaris-homepage` 分支 |
| 文档整理 | 三份文档迁入 `docs/design/`,设计图迁入 `docs/assets/` |
| Logo | 现有占位 Logo 迁入 `public/brand/`,不追加品牌设计 |
| 运行时背景 | CSS 低饱和青蓝渐变;设计图只作构图参考 |
| 外部搜索 | Google、Bing、GitHub 在当前标签页跳转 |
| 本地链接 | Local 结果与常用链接在新标签页打开 |
| 排序模型 | `sort` 管理分类内顺序;可选 `pinnedSort` 独立管理首页常用链接顺序 |
| 分类导航 | 首页验收阶段展示但禁用,避免进入未实现路由;分类页完成后启用 |
| 注释 | 关键规则、非显然决策、边界条件和阶段性限制使用简洁中文注释 |
| 提交时机 | 分支全部目标完成并验证后再提交 |
## 4. 技术基线
- Next.js App Router;
- React;
- TypeScript strict;
- Tailwind CSS v4,使用 CSS-first 配置;
- shadcn/ui,只引入实际使用的基础原语;
- lucide-react;
- simple-icons;
- pnpm;
- Vitest;
- Testing Library;
- ESLint;
- Prettier。
`next.config.ts` 使用 `output: "export"`,构建产物必须能作为纯静态文件部署。
不引入 Redux、Zustand、React Query、Zod、Storybook、Repository interface 或依赖注入容器。当前规模下这些抽象没有必要。
## 5. 推荐目录
```text
polaris/
├─ docs/
│ ├─ design/
│ │ ├─ Polaris 项目说明文档.md
│ │ ├─ Polaris 首页设计文档.md
│ │ ├─ Polaris 分类页设计文档.md
│ │ └─ 2026-08-26-Polaris-首页实现规格.md
│ └─ assets/
│ └─ Polaris页面设计图.png
├─ public/
│ ├─ brand/
│ │ └─ logo.svg
│ └─ icons/
├─ src/
│ ├─ app/
│ │ ├─ globals.css
│ │ ├─ layout.tsx
│ │ └─ page.tsx
│ ├─ components/
│ │ ├─ ui/
│ │ └─ shared/
│ ├─ features/
│ │ ├─ home/
│ │ │ ├─ components/
│ │ │ ├─ hooks/
│ │ │ └─ lib/
│ │ └─ navigation/
│ │ └─ components/
│ ├─ data/
│ │ ├─ categories.json
│ │ ├─ links.json
│ │ └─ settings.json
│ ├─ services/
│ │ ├─ categories.ts
│ │ ├─ links.ts
│ │ └─ settings.ts
│ ├─ types/
│ │ ├─ navigation.ts
│ │ ├─ search.ts
│ │ └─ settings.ts
│ ├─ lib/
│ │ └─ utils.ts
│ └─ test/
│ └─ setup.ts
├─ components.json
├─ eslint.config.mjs
├─ next.config.ts
├─ package.json
├─ pnpm-lock.yaml
├─ postcss.config.mjs
├─ prettier.config.mjs
├─ tsconfig.json
└─ vitest.config.ts
```
### 5.1 目录职责
- `app/`:路由、metadata、根布局、全局样式和页面组合;
- `features/`:按功能聚合组件、hooks 和专属纯函数;
- `components/ui/`:shadcn/ui 基础原语;
- `components/shared/`:跨功能复用组件,例如 `GlassPanel`、`SiteIcon`;
- `data/`:第一版本地 JSON;
- `services/`:页面与数据源之间的唯一访问边界;
- `types/`:跨功能共享的数据契约;
- `lib/`:真正通用且不依赖业务功能的工具;
- `test/`:全局测试环境与共享测试辅助代码。
测试文件与被测试实现就近放置,`src/test/` 不作为所有测试的集中目录。
## 6. 渲染与组件边界
`src/app/page.tsx` 保持为 Server Component,在构建时调用 service 层读取数据,再将可序列化数据传给页面组件。
```text
app/page.tsx
└─ AppShell
├─ Sidebar
│ ├─ SidebarHeader
│ └─ SidebarNav
└─ HomeContent
├─ ClockGreeting
├─ SearchBox
│ ├─ SearchProviderMenu
│ └─ LocalSearchResults
└─ QuickLinks
└─ QuickLinkCard
└─ SiteIcon
```
Client Component 边界仅用于浏览器状态:
- `AppShell`:侧栏折叠与持久化;
- `ClockGreeting`:当前时间;
- `SearchBox`:Provider、query、结果选中项和快捷键;
- `SiteIcon`:图标加载失败后的 fallback。
把 Server Component 作为 `AppShell` 的 `children` 传入,不因为外层存在 Client Component 就将全部展示代码改成客户端代码。
不为每个小标签或图标创建组件。只有存在独立状态、复用价值或清晰职责时才拆分。
## 7. 数据模型与数据流
### 7.1 数据规模
- 7 个分类;
- 开发工具 15 条;
- ChatGPT、Gmail、Notion、YouTube、Bilibili 5 条跨分类链接;
- 共 20 条链接;
- 8 条 `pinned` 常用链接;
- `sort` 只表示分类内顺序,可选 `pinnedSort` 只表示首页常用链接顺序。
首页常用链接固定按以下 `pinnedSort` 顺序展示:GitHub、ChatGPT、Gmail、Notion、Figma、YouTube、Vercel、Bilibili。该字段解决开发工具分类中 Vercel 位于 Figma 之前、但首页中 Figma 位于 Vercel 之前的双重排序需求。
设计资源、学习资料、生活服务保持无链接;AI 工具、效率工具和娱乐媒体因常用链接而有少量数据。
### 7.2 访问路径
1. `page.tsx` 调用 `getCategories()`、`getLinks()` 和 `getSettings()`;
2. service 层复制、排序和筛选数据;
3. 页面不直接 import JSON;
4. 首页只把所需的分类、全部搜索候选、8 条常用链接和设置传给客户端;
5. 未来接 Go API 时保持 service 的对外职责不变,只替换其内部数据来源。
### 7.3 数据校验
Vitest 必须校验:
- 必填字段类型;
- 分类和链接 ID 唯一;
- URL 合法;
- `categoryId` 必须指向现有分类;
- `sort` 与可选 `pinnedSort` 合法;
- Provider 配置有效;
- `pinned` 数量为 8,且每条 pinned 链接都有唯一 `pinnedSort`。
## 8. 视觉系统
### 8.1 背景
使用多层 CSS 渐变和柔和光斑模拟晨雾、天空和水面氛围,不把 `Polaris页面设计图.png` 作为运行时背景。
增加浅色遮罩和适度模糊,保证未来替换背景时仍具有可读性。
### 8.2 布局
- 侧栏外边距:`20px`;
- 展开宽度:`240px`;
- 折叠宽度:`72px`;
- 主内容视觉重心位于中上方;
- 使用 `minmax()` 与 `clamp()` 适配 1024px 以上桌面窗口;
- 搜索框最大宽度约 `760px`、高度约 `68px`;
- 常用链接最大四列、共两行,缩窄窗口时允许减少列数;
- 第一版不实现移动端抽屉侧栏。
### 8.3 Design tokens
在 `globals.css` 中定义 Polaris 语义变量,并通过 Tailwind v4 `@theme inline` 映射:
- 主文本、次级文本、弱文本;
- 主色、hover 色、柔和主色;
- 侧栏、搜索框、卡片表面;
- 玻璃边框;
- 软阴影、中等阴影;
- 圆角与模糊强度。
第一版只实现浅色值,但变量命名允许以后通过主题选择器覆盖。
### 8.4 动效与无障碍
- hover、focus 和折叠过渡约为 180ms;
- 不使用夸张缩放或弹跳;
- 尊重 `prefers-reduced-motion`;
- 所有图标按钮有可访问名称;
- 键盘焦点清晰;
- 搜索输入与结果使用合理的 combobox/listbox 语义;
- 禁用分类项使用 `aria-disabled` 并提供“分类页将在下一阶段提供”的轻量提示。
## 9. 首页交互
### 9.1 时钟与问候
问候规则:
```text
05:00 - 11:59 早上好
12:00 - 13:59 中午好
14:00 - 17:59 下午好
18:00 - 23:59 晚上好
00:00 - 04:59 夜深了
```
客户端按分钟更新;跨午夜后日期、星期和问候语同步变化。
### 9.2 Provider 状态
- 初始值来自 `settings.search.defaultProvider`;
- 用户切换后保存到 `localStorage`;
- 无效值或存储不可用时回退到设置默认值;
- 外部 Provider 与 Local 模式严格分离。
### 9.3 外部搜索
Google、Bing、GitHub:
- 输入先 `trim()`;
- 空内容不提交;
- 使用 `URLSearchParams` 编码;
- 在当前标签页跳转;
- 不显示 Local 结果下拉。
### 9.4 Local 搜索
匹配字段:
- 链接标题;
- 关键词;
- 描述;
- 分类名称;
- 分类 ID。
优先级:
```text
标题完全匹配
标题前缀或包含
关键词匹配
描述匹配
分类匹配
```
同分时按链接 `sort` 和标题稳定排序,最多返回 8 条。
交互:
- 默认选中第一项;
- `↑` / `↓` 移动;
- `Enter` 打开选中项;
- 鼠标点击直接打开;
- 结果在新标签页打开;
- `Esc` 清空输入并关闭面板;
- 无结果显示“没有匹配的网站”。
### 9.5 快捷键
- 首页加载后自动聚焦搜索框;
- Windows/Linux:`Ctrl+K`;
- macOS:`⌘K`;
- 快捷键阻止浏览器默认行为后聚焦;
- 监听器只存在于首页生命周期内。
### 9.6 侧栏
- 折叠状态保存到 `localStorage`;
- 存储不可用或值损坏时回退到设置默认值;
- hydration 后同步持久化值;
- 同步完成前禁用布局过渡,减少闪动;
- 首页项为当前选中态;
- 分类项本次显示但禁用,不进入 404。
### 9.7 常用链接
- 对 `pinned` 链接按 `pinnedSort` 排序取前 8 条;
- `pinned: true` 时必须提供唯一的 `pinnedSort`;
- 使用真实 `<a>`;
- `target="_blank"`;
- `rel="noopener noreferrer"`;
- 卡片仅显示图标与名称。
## 10. 图标策略
运行时优先级:
```text
自定义本地图标
明确映射的 Simple Icons
favicon
标题首字符文字占位
```
当前 20 条数据尽量命中显式 Simple Icons 映射,避免正常页面依赖远程 favicon。远程图标失败时不得显示破图。
侧栏分类图标使用 lucide-react,不参与站点图标 fallback。
## 11. 状态与故障处理
- 静态 JSON 无 Loading UI;
- 数据结构错误由测试和构建阶段阻止;
- `localStorage` 读写使用容错封装;
- favicon 失败显示文字占位;
- 空搜索不跳转;
- Local 无结果显示轻量空状态;
- 非法 Provider 回退到配置默认值;
- Server 初始值与 Client 首次渲染保持一致,避免 hydration error。
## 12. 注释规范
必须为以下非显然逻辑添加简洁中文注释,解释“为什么”:
- Local 搜索权重及稳定排序;
- Server/Client 边界选择;
- 图标 fallback 顺序;
- hydration 后同步 `localStorage`;
- 分类导航临时禁用;
- 数据校验中的跨分类引用规则。
普通 JSX、显然的变量赋值和框架惯用代码不添加复述式注释。
## 13. 测试设计
### 13.1 纯函数
- 问候语全部时间边界;
- 日期格式;
- 搜索 URL 与特殊字符编码;
- Local 搜索字段、权重、排序和数量上限;
- 图标引用解析;
- 数据结构及交叉引用。
### 13.2 组件交互
- 自动聚焦;
- Provider 严格分离;
- Provider 持久化与非法值回退;
- Local 键盘导航、Enter、Esc、点击和无结果;
- `Ctrl+K` / `⌘K`;
- 侧栏折叠、恢复和异常存储回退;
- 新标签页链接安全属性;
- 分类项禁用状态。
## 14. 验证与视觉验收
完成后必须运行:
```text
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
随后实际启动页面并检查:
- `1440 × 900`;
- `1920 × 1080`;
- `1024 × 768`。
检查项:
- 无横向溢出;
- 背景渐变层次和文本对比度;
- 玻璃透明度、边框与阴影一致;
- 侧栏展开/折叠比例;
- 搜索聚焦与 Provider 交互;
- Local 结果定位和键盘交互;
- 卡片排列、hover 和 focus;
- `prefers-reduced-motion`;
- 控制台无 hydration error 或运行时错误。
设计图用于构图和氛围参照,不要求像素级复制图片背景。
## 15. 完成定义
只有以下条件全部满足,`feat/polaris-homepage` 才完成:
1. 推荐目录和工程配置建立;
2. 文档及资产迁移完成,内部链接有效;
3. 完整可交互首页实现;
4. 自动化检查全部通过;
5. 真实页面完成三个尺寸的视觉检查;
6. 实际结果、视觉观察和已知偏差如实报告;
7. 未提前实现正式分类页;
8. 完成后再执行提交操作。
@@ -194,8 +194,8 @@ AI 工具
推荐样式: 推荐样式:
```css ```css
background: rgba(99, 102, 241, 0.10); background: rgba(99, 102, 241, 0.1);
color: #2563EB; color: #2563eb;
``` ```
图标和文字同时高亮。 图标和文字同时高亮。
@@ -276,7 +276,7 @@ background: rgba(255, 255, 255, 0.56);
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.48); border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 24px; border-radius: 24px;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.10); box-shadow: 0 24px 80px rgba(15, 23, 42, 0.1);
``` ```
容器与页面边距: 容器与页面边距:
@@ -328,7 +328,7 @@ padding: 32px 24px;
```css ```css
font-size: 40px; font-size: 40px;
font-weight: 700; font-weight: 700;
color: #0F172A; color: #0f172a;
letter-spacing: -0.02em; letter-spacing: -0.02em;
``` ```
@@ -355,7 +355,7 @@ font-size: 44px;
```css ```css
font-size: 16px; font-size: 16px;
line-height: 1.7; line-height: 1.7;
color: #64748B; color: #64748b;
margin-top: 12px; margin-top: 12px;
``` ```
@@ -402,12 +402,12 @@ AI 工具:精选常用 AI 工具与服务,辅助创作、开发和效率提
.section-title { .section-title {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: #0F172A; color: #0f172a;
} }
.section-count { .section-count {
font-size: 14px; font-size: 14px;
color: #94A3B8; color: #94a3b8;
background: rgba(255, 255, 255, 0.42); background: rgba(255, 255, 255, 0.42);
border-radius: 999px; border-radius: 999px;
padding: 4px 10px; padding: 4px 10px;
@@ -640,7 +640,7 @@ height: 44px;
```css ```css
font-size: 17px; font-size: 17px;
font-weight: 700; font-weight: 700;
color: #0F172A; color: #0f172a;
``` ```
工具描述: 工具描述:
@@ -648,7 +648,7 @@ color: #0F172A;
```css ```css
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
color: #64748B; color: #64748b;
margin-top: 4px; margin-top: 4px;
``` ```
@@ -704,6 +704,7 @@ JavaScript 包管理器
"icon": "si:github", "icon": "si:github",
"categoryId": "dev", "categoryId": "dev",
"pinned": true, "pinned": true,
"pinnedSort": 10,
"keywords": ["代码", "仓库", "开源", "git"], "keywords": ["代码", "仓库", "开源", "git"],
"sort": 10 "sort": 10
}, },
@@ -803,6 +804,7 @@ JavaScript 包管理器
"icon": "si:vercel", "icon": "si:vercel",
"categoryId": "dev", "categoryId": "dev",
"pinned": true, "pinned": true,
"pinnedSort": 70,
"keywords": ["部署", "前端", "托管"], "keywords": ["部署", "前端", "托管"],
"sort": 100 "sort": 100
}, },
@@ -858,6 +860,7 @@ JavaScript 包管理器
"icon": "si:figma", "icon": "si:figma",
"categoryId": "dev", "categoryId": "dev",
"pinned": true, "pinned": true,
"pinnedSort": 50,
"keywords": ["设计", "ui", "协作"], "keywords": ["设计", "ui", "协作"],
"sort": 150 "sort": 150
} }
@@ -897,14 +900,14 @@ window.open(url, "_blank", "noopener,noreferrer")
```css ```css
width: 16px; width: 16px;
height: 16px; height: 16px;
color: #64748B; color: #64748b;
opacity: 0.75; opacity: 0.75;
``` ```
Hover 时: Hover 时:
```css ```css
color: #2563EB; color: #2563eb;
opacity: 1; opacity: 1;
``` ```
@@ -992,7 +995,7 @@ height: 280px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #64748B; color: #64748b;
``` ```
--- ---
@@ -1147,11 +1150,12 @@ export type Category = {
export type NavLink = { export type NavLink = {
id: string id: string
title: string title: string
description?: string description: string
url: string url: string
icon: string icon?: string
categoryId: string categoryId: string
pinned: boolean pinned: boolean
pinnedSort?: number
keywords: string[] keywords: string[]
sort: number sort: number
} }
@@ -4,7 +4,7 @@
> [Polaris 首页设计文档](./Polaris%20首页设计文档.md) —— 首页布局、搜索交互、视觉规范 > [Polaris 首页设计文档](./Polaris%20首页设计文档.md) —— 首页布局、搜索交互、视觉规范
> [Polaris 分类页设计文档](./Polaris%20分类页设计文档.md) —— 分类页布局、卡片、状态设计 > [Polaris 分类页设计文档](./Polaris%20分类页设计文档.md) —— 分类页布局、卡片、状态设计
> >
> 配套资产:`logo.svg`(占位 Logo,后续替换)、`Polaris页面设计图.png` > 配套资产:[占位 Logo](../../public/brand/logo.svg)(后续替换)、[Polaris 页面设计图](../assets/Polaris页面设计图.png)
## 1. 项目名称 ## 1. 项目名称
@@ -55,7 +55,7 @@ Polaris 不是后台管理系统,也不是信息密集型 Dashboard。
以下决策已于 2026-08-21 确认,作为实施基线: 以下决策已于 2026-08-21 确认,作为实施基线:
| # | 决策项 | 结论 | | # | 决策项 | 结论 |
|---|--------|------| | --- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| 1 | 技术栈 | 以本文档为准:Next.js + TypeScript + Tailwind CSS + shadcn/ui + lucide-react(早期 README 中 Astro 设想作废) | | 1 | 技术栈 | 以本文档为准:Next.js + TypeScript + Tailwind CSS + shadcn/ui + lucide-react(早期 README 中 Astro 设想作废) |
| 2 | 渲染策略 | 静态导出(`output: 'export'`),时钟/搜索/侧边栏折叠为 client 岛屿 | | 2 | 渲染策略 | 静态导出(`output: 'export'`),时钟/搜索/侧边栏折叠为 client 岛屿 |
| 3 | 包管理器 | pnpm | | 3 | 包管理器 | pnpm |
@@ -189,6 +189,7 @@ sort: 排序值
icon: "si:github", icon: "si:github",
categoryId: "dev", categoryId: "dev",
pinned: true, pinned: true,
pinnedSort: 10,
keywords: ["代码", "仓库", "开源", "git"], keywords: ["代码", "仓库", "开源", "git"],
sort: 10 sort: 10
} }
@@ -204,8 +205,9 @@ url: 链接地址
icon: 图标引用,格式见 6.4 icon: 图标引用,格式见 6.4
categoryId: 所属分类 categoryId: 所属分类
pinned: 是否展示在首页常用链接区域 pinned: 是否展示在首页常用链接区域
pinnedSort: 首页常用链接顺序(仅 pinned 为 true 时必填)
keywords: 本地搜索关键词 keywords: 本地搜索关键词
sort: 排序值 sort: 分类内排序值
``` ```
### 6.3 设置数据 ### 6.3 设置数据
@@ -309,6 +311,7 @@ CREATE TABLE links (
icon TEXT, icon TEXT,
category_id TEXT NOT NULL, category_id TEXT NOT NULL,
pinned BOOLEAN NOT NULL DEFAULT FALSE, pinned BOOLEAN NOT NULL DEFAULT FALSE,
pinned_sort INTEGER,
keywords TEXT NOT NULL DEFAULT '[]', -- JSON 数组字符串 keywords TEXT NOT NULL DEFAULT '[]', -- JSON 数组字符串
sort INTEGER NOT NULL DEFAULT 0, sort INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -4,7 +4,7 @@
> [Polaris 项目说明文档](./Polaris%20项目说明文档.md) —— 项目定位、技术选型、数据结构、开发阶段 > [Polaris 项目说明文档](./Polaris%20项目说明文档.md) —— 项目定位、技术选型、数据结构、开发阶段
> [Polaris 分类页设计文档](./Polaris%20分类页设计文档.md) —— 分类页布局与交互 > [Polaris 分类页设计文档](./Polaris%20分类页设计文档.md) —— 分类页布局与交互
> >
> 配套设计图:`Polaris页面设计图.png`(示意图,视觉细节以本文档文字规范为准) > 配套设计图:[Polaris 页面设计图](../assets/Polaris页面设计图.png)(示意图,视觉细节以本文档文字规范为准)
## 1. 页面定位 ## 1. 页面定位
@@ -200,7 +200,7 @@ AI 工具
background: rgba(255, 255, 255, 0.68); background: rgba(255, 255, 255, 0.68);
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.45); border: 1px solid rgba(255, 255, 255, 0.45);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.10); box-shadow: 0 24px 80px rgba(15, 23, 42, 0.1);
border-radius: 24px; border-radius: 24px;
``` ```
@@ -242,14 +242,14 @@ bottom: 20px;
```css ```css
font-size: 56px; font-size: 56px;
font-weight: 700; font-weight: 700;
color: #0F172A; color: #0f172a;
``` ```
日期样式: 日期样式:
```css ```css
font-size: 16px; font-size: 16px;
color: #64748B; color: #64748b;
``` ```
时间为客户端实时时钟,每分钟更新。 时间为客户端实时时钟,每分钟更新。
@@ -525,7 +525,7 @@ Bilibili
实际项目中根据个人习惯调整。 实际项目中根据个人习惯调整。
数据来源:`pinned: true` 的链接,按 `sort` 排序取前 8 个;超出 8 个时按 4 列网格换行展示。 数据来源:`pinned: true` 的链接,按 `pinnedSort` 排序取前 8 个;超出 8 个时按 4 列网格换行展示。`sort` 仅用于分类内排序,避免首页顺序与分类页顺序相互约束。
第一版数据构成(8 条 pinned 分布于各分类,归入真实分类): 第一版数据构成(8 条 pinned 分布于各分类,归入真实分类):
@@ -573,7 +573,7 @@ background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.56); border: 1px solid rgba(255, 255, 255, 0.56);
border-radius: 20px; border-radius: 20px;
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.10); box-shadow: 0 14px 40px rgba(15, 23, 42, 0.1);
``` ```
推荐尺寸: 推荐尺寸:
@@ -674,13 +674,13 @@ backdrop-filter: blur(8px);
```css ```css
:root { :root {
--color-text-primary: #0F172A; --color-text-primary: #0f172a;
--color-text-secondary: #64748B; --color-text-secondary: #64748b;
--color-text-muted: #94A3B8; --color-text-muted: #94a3b8;
--color-primary: #3B82F6; --color-primary: #3b82f6;
--color-primary-hover: #2563EB; --color-primary-hover: #2563eb;
--color-primary-soft: rgba(59, 130, 246, 0.10); --color-primary-soft: rgba(59, 130, 246, 0.1);
--color-surface-sidebar: rgba(255, 255, 255, 0.68); --color-surface-sidebar: rgba(255, 255, 255, 0.68);
--color-surface-card: rgba(255, 255, 255, 0.62); --color-surface-card: rgba(255, 255, 255, 0.62);
@@ -689,7 +689,7 @@ backdrop-filter: blur(8px);
--color-border-light: rgba(255, 255, 255, 0.56); --color-border-light: rgba(255, 255, 255, 0.56);
--color-border-subtle: rgba(15, 23, 42, 0.08); --color-border-subtle: rgba(15, 23, 42, 0.08);
--shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.10); --shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.1);
--shadow-medium: 0 20px 60px rgba(15, 23, 42, 0.12); --shadow-medium: 0 20px 60px rgba(15, 23, 42, 0.12);
} }
``` ```
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig, globalIgnores } from "eslint/config"
import nextVitals from "eslint-config-next/core-web-vitals"
import nextTypeScript from "eslint-config-next/typescript"
export default defineConfig([
...nextVitals,
...nextTypeScript,
globalIgnores([".next/**", "out/**", "coverage/**", "next-env.d.ts"]),
])
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
}
export default nextConfig
+52
View File
@@ -0,0 +1,52 @@
{
"name": "polaris",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.24.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"engines": {
"node": ">=22.22.2"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tooltip": "^1.2.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.34.0",
"next": "^16.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"simple-icons": "^16.28.0",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.6",
"@types/node": "^26.3.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"eslint": "^9.39.5",
"eslint-config-next": "^16.3.3",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
packages:
- "."
allowBuilds:
unrs-resolver: true
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config
@@ -0,0 +1,9 @@
/** @type {import("prettier").Config} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
semi: false,
singleQuote: false,
trailingComma: "all",
}
export default config

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

+635
View File
@@ -0,0 +1,635 @@
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--text-primary);
--color-primary: var(--primary);
--color-primary-foreground: #ffffff;
--color-muted-foreground: var(--text-secondary);
--color-border: var(--border-subtle);
--radius-lg: var(--radius-card);
--radius-xl: var(--radius-panel);
}
:root {
color-scheme: light;
--background: #dcebf5;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-soft: rgba(59, 130, 246, 0.1);
--surface-sidebar: rgba(255, 255, 255, 0.68);
--surface-search: rgba(255, 255, 255, 0.72);
--surface-card: rgba(255, 255, 255, 0.62);
--surface-card-hover: rgba(255, 255, 255, 0.78);
--border-glass: rgba(255, 255, 255, 0.58);
--border-subtle: rgba(15, 23, 42, 0.08);
--shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.1);
--shadow-medium: 0 20px 60px rgba(15, 23, 42, 0.12);
--shadow-panel: 0 24px 80px rgba(15, 23, 42, 0.1);
--radius-card: 20px;
--radius-panel: 24px;
--sidebar-expanded: 240px;
--sidebar-collapsed: 72px;
--layout-gap: 20px;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
isolation: isolate;
margin: 0;
overflow-x: hidden;
color: var(--text-primary);
background:
radial-gradient(
circle at 12% 15%,
rgba(255, 245, 239, 0.92) 0,
transparent 34%
),
radial-gradient(
circle at 82% 14%,
rgba(194, 222, 246, 0.92) 0,
transparent 38%
),
radial-gradient(
circle at 48% 82%,
rgba(226, 239, 247, 0.72) 0,
transparent 44%
),
linear-gradient(135deg, #eef5fa 0%, #dceaf4 48%, #cbdff0 100%);
font-family:
Inter,
"SF Pro Display",
"SF Pro Text",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
sans-serif;
}
body::before {
position: fixed;
inset: 0;
z-index: -1;
content: "";
pointer-events: none;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.08)
),
repeating-linear-gradient(
165deg,
transparent 0 84px,
rgba(255, 255, 255, 0.08) 85px 86px
);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
button,
input,
a {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.34);
outline-offset: 3px;
}
.glass-panel {
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-card);
box-shadow: var(--shadow-soft);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
}
.app-shell {
--sidebar-width: var(--sidebar-expanded);
min-height: 100svh;
}
.app-shell[data-sidebar-collapsed="true"] {
--sidebar-width: var(--sidebar-collapsed);
}
.app-shell:not([data-hydrated="true"]) *,
.app-shell:not([data-hydrated="true"]) *::before,
.app-shell:not([data-hydrated="true"]) *::after {
transition: none !important;
}
.sidebar {
position: fixed;
inset: 20px auto 20px 20px;
z-index: 20;
display: flex;
width: var(--sidebar-width);
overflow: visible;
flex-direction: column;
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-sidebar);
box-shadow: var(--shadow-panel);
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
transition: width 180ms ease;
}
.sidebar__header {
position: relative;
display: flex;
min-height: 72px;
align-items: center;
gap: 10px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.46);
}
.sidebar__brand {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 12px;
color: var(--text-primary);
text-decoration: none;
}
.sidebar__brand img {
display: block;
flex: none;
}
.sidebar__site-name {
overflow: hidden;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.025em;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar__header > button {
flex: none;
color: var(--text-secondary);
border-radius: 12px;
transition:
color 180ms ease,
background 180ms ease;
}
.sidebar__header > button:hover {
color: var(--primary-hover);
background: var(--primary-soft);
}
.sidebar__nav {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding: 12px 12px 16px;
}
.sidebar__categories {
display: flex;
flex-direction: column;
gap: 6px;
}
.sidebar__nav-item {
display: flex;
width: 100%;
height: 48px;
min-height: 48px;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 0 14px;
color: #334155;
text-decoration: none;
border: 0;
border-radius: 16px;
background: transparent;
transition:
color 180ms ease,
background 180ms ease;
}
.sidebar__nav-item svg {
width: 20px;
height: 20px;
flex: none;
}
.sidebar__nav-item--active {
color: var(--primary-hover);
background: var(--primary-soft);
font-weight: 650;
}
.sidebar__nav-item[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.82;
}
.sidebar__nav-item[aria-disabled="true"]:hover {
color: #334155;
background: rgba(255, 255, 255, 0.28);
}
.sidebar__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__header {
justify-content: center;
padding-inline: 10px;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__brand {
justify-content: center;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__header > button {
position: absolute;
top: 22px;
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 {
padding-inline: 10px;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__nav-item {
justify-content: center;
padding-inline: 0;
}
.app-shell__content {
min-height: 100svh;
margin-left: calc(var(--sidebar-width) + 40px);
transition: margin-left 180ms ease;
}
.home-content {
display: grid;
min-height: 100svh;
place-items: start center;
padding: clamp(108px, 14vh, 156px) 40px 64px;
}
.home-content__inner {
width: min(100%, 880px);
}
.clock-greeting {
text-align: center;
}
.clock-greeting__time {
min-height: 1.2em;
margin: 0;
color: var(--text-primary);
font-size: clamp(44px, 4.2vw, 56px);
font-weight: 700;
line-height: 1.16;
letter-spacing: -0.035em;
text-wrap: balance;
}
.clock-greeting__date {
margin: 12px 0 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.6;
}
.search-box {
position: relative;
z-index: 10;
width: min(100%, 760px);
margin: 40px auto 0;
}
.search-box__form {
display: flex;
height: 68px;
align-items: center;
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-search);
box-shadow: var(--shadow-medium);
-webkit-backdrop-filter: blur(22px);
backdrop-filter: blur(22px);
transition:
border-color 180ms ease,
box-shadow 180ms ease,
background 180ms ease;
}
.search-box__form:focus-within {
border-color: rgba(59, 130, 246, 0.26);
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 0 0 4px rgba(59, 130, 246, 0.09),
var(--shadow-medium);
}
.search-provider-menu__trigger {
min-width: 126px;
height: 48px;
justify-content: flex-start;
margin-left: 10px;
padding-inline: 14px;
color: #334155;
border-radius: 16px;
}
.search-provider-menu__trigger:hover,
.search-provider-menu__trigger[aria-expanded="true"] {
color: var(--primary-hover);
background: var(--primary-soft);
}
.search-box__icon {
width: 20px;
height: 20px;
flex: none;
margin-left: 10px;
color: var(--text-muted);
}
.search-box__input {
min-width: 0;
height: 100%;
flex: 1;
padding: 0 14px;
color: var(--text-primary);
font-size: 16px;
line-height: 1.5;
border: 0;
outline: 0;
background: transparent;
}
.search-box__input::placeholder {
color: var(--text-muted);
opacity: 1;
}
.search-box__shortcut {
display: inline-flex;
min-width: 50px;
height: 30px;
flex: none;
align-items: center;
justify-content: center;
margin-right: 16px;
padding-inline: 9px;
color: var(--text-muted);
font-family: inherit;
font-size: 12px;
line-height: 1;
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 9px;
background: rgba(255, 255, 255, 0.42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.68);
}
.local-search-results {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 30;
width: 100%;
max-height: min(520px, calc(100vh - 220px));
overflow-y: auto;
padding: 8px;
border: 1px solid var(--border-glass);
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow-medium);
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
}
.local-search-results__option {
display: flex;
min-height: 58px;
align-items: center;
gap: 12px;
padding: 9px 12px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid transparent;
border-radius: 14px;
transition:
color 180ms ease,
background 180ms ease,
border-color 180ms ease;
}
.local-search-results__option[aria-selected="true"] {
color: var(--primary-hover);
border-color: rgba(59, 130, 246, 0.08);
background: var(--primary-soft);
}
.local-search-results__copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 3px;
}
.local-search-results__title,
.local-search-results__category {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.local-search-results__title {
font-size: 14px;
font-weight: 650;
}
.local-search-results__category {
color: var(--text-secondary);
font-size: 12px;
}
.local-search-results__empty {
display: grid;
min-height: 96px;
place-items: center;
margin: 0;
color: var(--text-secondary);
font-size: 14px;
}
.quick-links {
width: min(100%, 880px);
margin-top: 44px;
}
.quick-links__title {
margin: 0 0 18px;
color: var(--text-primary);
font-size: 16px;
font-weight: 650;
}
.quick-links__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px 18px;
}
.quick-link-card {
display: flex;
min-width: 0;
height: 76px;
align-items: center;
gap: 14px;
padding: 0 20px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid var(--border-glass);
border-radius: var(--radius-card);
background: var(--surface-card);
box-shadow: var(--shadow-soft);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
transition:
transform 180ms ease,
background 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
}
.quick-link-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.76);
background: var(--surface-card-hover);
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
}
.quick-link-card > span:last-child {
min-width: 0;
overflow: hidden;
font-size: 15px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-icon {
position: relative;
display: inline-flex;
overflow: hidden;
flex: none;
align-items: center;
justify-content: center;
border-radius: 11px;
}
.site-icon svg,
.site-icon img {
display: block;
max-width: 100%;
max-height: 100%;
}
.site-icon img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 10px;
}
.site-icon__image {
position: absolute;
inset: 0;
}
.site-icon__image--loading {
opacity: 0;
}
.site-icon--initial::before {
content: attr(data-initial);
}
.site-icon--initial {
color: var(--primary-hover);
font-size: 15px;
font-weight: 700;
background: rgba(219, 234, 254, 0.7);
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.08);
}
.local-search-results,
.sidebar__nav {
scrollbar-color: rgba(100, 116, 139, 0.32) transparent;
scrollbar-width: thin;
}
@media (max-width: 1199px) {
:root {
--sidebar-expanded: 220px;
}
.home-content {
padding-inline: 28px;
}
.quick-links__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata, Viewport } from "next"
import "./globals.css"
export const metadata: Metadata = {
title: "Polaris — 个人导航起始页",
description: "快速搜索并访问常用网站的个人导航起始页。",
icons: {
icon: "/brand/logo.svg",
},
}
export const viewport: Viewport = {
colorScheme: "light",
themeColor: "#dcebf5",
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { HomeContent } from "@/features/home/components/home-content"
import { AppShell } from "@/features/navigation/components/app-shell"
import { getCategories } from "@/services/categories"
import { getLinks, getPinnedLinks } from "@/services/links"
import { getSettings } from "@/services/settings"
export default function HomePage() {
const categories = getCategories()
const links = getLinks()
const pinnedLinks = getPinnedLinks()
const settings = getSettings()
return (
<AppShell
categories={categories}
siteName={settings.siteName}
defaultCollapsed={settings.sidebar.defaultCollapsed}
>
<HomeContent
categories={categories}
links={links}
pinnedLinks={pinnedLinks}
settings={settings}
/>
</AppShell>
)
}
@@ -0,0 +1,18 @@
import { createElement, type ComponentPropsWithoutRef } from "react"
import { cn } from "@/lib/utils"
type GlassPanelProps = ComponentPropsWithoutRef<"div"> & {
as?: "div" | "section" | "aside"
}
export function GlassPanel({
as = "div",
className,
...props
}: GlassPanelProps) {
return createElement(as, {
className: cn("glass-panel", className),
...props,
})
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest"
import type { NavLink } from "@/types/navigation"
import { getIconCandidates } from "./icon-candidates"
const baseLink: NavLink = {
id: "example",
title: "Example",
description: "Example site",
url: "https://example.com/path",
categoryId: "dev",
pinned: false,
keywords: [],
sort: 10,
}
describe("站点图标候选链", () => {
it("local 图标后依次回退 favicon 和文字", () => {
expect(getIconCandidates({ ...baseLink, icon: "local:custom" })).toEqual([
{ kind: "image", src: "/icons/custom.svg" },
{ kind: "image", src: "https://example.com/favicon.ico" },
{ kind: "initial", value: "E" },
])
})
it("不允许 local 图标名越出 icons 目录", () => {
expect(
getIconCandidates({ ...baseLink, icon: "local:../brand/logo" })[0],
).toEqual({
kind: "image",
src: "https://example.com/favicon.ico",
})
})
it("已知 simple icon 优先使用本地 SVG 数据", () => {
expect(
getIconCandidates({ ...baseLink, icon: "si:github" })[0],
).toMatchObject({
kind: "simple",
slug: "github",
})
})
it("未知 simple icon 跳过无效映射", () => {
expect(getIconCandidates({ ...baseLink, icon: "si:not-real" })[0]).toEqual({
kind: "image",
src: "https://example.com/favicon.ico",
})
})
it("去重写法不同但语义相同的 favicon URL", () => {
expect(
getIconCandidates({
...baseLink,
icon: "url:https://example.com:443/favicon.ico",
}),
).toEqual([
{ kind: "image", src: "https://example.com/favicon.ico" },
{ kind: "initial", value: "E" },
])
})
it("显式 URL 图标失败后仍回退 favicon 和文字", () => {
expect(
getIconCandidates({
...baseLink,
icon: "url:https://cdn.example.com/icon.png",
}),
).toEqual([
{ kind: "image", src: "https://cdn.example.com/icon.png" },
{ kind: "image", src: "https://example.com/favicon.ico" },
{ kind: "initial", value: "E" },
])
})
})
@@ -0,0 +1,62 @@
import { SIMPLE_ICON_MAP } from "@/components/shared/simple-icon-map"
import type { NavLink } from "@/types/navigation"
const LOCAL_ICON_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
export type IconCandidate =
| { kind: "simple"; slug: string }
| { kind: "image"; src: string }
| { kind: "initial"; value: string }
function normalizeImageUrl(value: string): string | null {
try {
return new URL(value).toString()
} catch {
return null
}
}
function getFaviconUrl(url: string): string | null {
try {
return new URL("/favicon.ico", url).toString()
} catch {
return null
}
}
export function getIconCandidates(
link: Pick<NavLink, "title" | "url" | "icon">,
): IconCandidate[] {
const candidates: IconCandidate[] = []
const icon = link.icon?.trim()
if (icon?.startsWith("local:")) {
const name = icon.slice("local:".length)
// 本地图标只接受单个文件名,避免路径段将请求规范化到 /icons/ 之外。
if (LOCAL_ICON_NAME_PATTERN.test(name)) {
candidates.push({ kind: "image", src: `/icons/${name}.svg` })
}
} else if (icon?.startsWith("si:")) {
const slug = icon.slice("si:".length).toLowerCase()
if (SIMPLE_ICON_MAP[slug]) candidates.push({ kind: "simple", slug })
} else if (icon?.startsWith("url:")) {
const src = normalizeImageUrl(icon.slice("url:".length))
if (src) candidates.push({ kind: "image", src })
}
const favicon = getFaviconUrl(link.url)
if (
favicon &&
!candidates.some(
(candidate) => candidate.kind === "image" && candidate.src === favicon,
)
) {
candidates.push({ kind: "image", src: favicon })
}
candidates.push({
kind: "initial",
value: link.title.trim().charAt(0).toUpperCase() || "?",
})
return candidates
}
@@ -0,0 +1,44 @@
import {
siBilibili,
siBitbucket,
siDocker,
siFigma,
siGithub,
siGitlab,
siGmail,
siGoland,
siJetbrains,
siKubernetes,
siNetlify,
siNotion,
siNpm,
siPnpm,
siPostman,
siRender,
siVercel,
siYoutube,
type SimpleIcon,
} from "simple-icons"
export const SIMPLE_ICON_MAP: Readonly<Record<string, SimpleIcon>> = {
bilibili: siBilibili,
bitbucket: siBitbucket,
docker: siDocker,
figma: siFigma,
github: siGithub,
gitlab: siGitlab,
gmail: siGmail,
goland: siGoland,
jetbrains: siJetbrains,
kubernetes: siKubernetes,
netlify: siNetlify,
notion: siNotion,
npm: siNpm,
pnpm: siPnpm,
postman: siPostman,
render: siRender,
vercel: siVercel,
youtube: siYoutube,
}
// 上游 v16 不再提供 OpenAI 与 VS Code;这两项按统一候选链回退到 favicon 和文字。
@@ -0,0 +1,84 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { SiteIcon } from "./site-icon"
describe("SiteIcon", () => {
it("渲染已知 Simple Icon", () => {
const { container } = render(
<SiteIcon
link={{ title: "GitHub", url: "https://github.com", icon: "si:github" }}
size={28}
/>,
)
const icon = container.querySelector("svg")
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute("width", "28")
expect(icon).toHaveAttribute("height", "28")
})
it("远程图片加载完成前显示首字符占位", () => {
const { container } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
const wrapper = container.querySelector(".site-icon")
const image = container.querySelector("img")
expect(wrapper).toHaveAttribute("data-initial", "E")
expect(image).toHaveClass("site-icon__image--loading")
fireEvent.load(image!)
expect(wrapper).not.toHaveAttribute("data-initial")
expect(image).not.toHaveClass("site-icon__image--loading")
})
it("链接变化后从新候选链的首项重新开始", () => {
const { container, rerender } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
fireEvent.error(container.querySelector("img")!)
rerender(
<SiteIcon
link={{
title: "Other",
url: "https://other.example.com",
icon: "url:https://cdn.example.com/other.png",
}}
/>,
)
expect(container.querySelector("img")).toHaveAttribute(
"src",
"https://cdn.example.com/other.png",
)
})
it("远程图片连续失败后显示首字符", () => {
const { container } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
fireEvent.error(container.querySelector("img")!)
fireEvent.error(container.querySelector("img")!)
expect(screen.getByText("E")).toBeInTheDocument()
})
})
@@ -0,0 +1,111 @@
"use client"
/* eslint-disable @next/next/no-img-element -- favicon 来源由数据动态决定,静态 Image 配置无法预枚举域名。 */
import { useState } from "react"
import {
getIconCandidates,
type IconCandidate,
} from "@/components/shared/icon-candidates"
import { SIMPLE_ICON_MAP } from "@/components/shared/simple-icon-map"
import { cn } from "@/lib/utils"
import type { NavLink } from "@/types/navigation"
type SiteIconProps = {
link: Pick<NavLink, "title" | "url" | "icon">
size?: number
className?: string
}
type SiteIconContentProps = SiteIconProps & {
candidates: IconCandidate[]
}
function SiteIconContent({
link,
candidates,
size = 36,
className,
}: SiteIconContentProps) {
const [candidateIndex, setCandidateIndex] = useState(0)
const [loadedImageSrc, setLoadedImageSrc] = useState<string | null>(null)
const candidate = candidates[candidateIndex]
const initialCandidate = candidates.at(-1)
const initialValue =
initialCandidate?.kind === "initial" ? initialCandidate.value : "?"
const wrapperClassName = cn("site-icon", className)
const style = { width: size, height: size }
if (candidate.kind === "simple") {
const icon = SIMPLE_ICON_MAP[candidate.slug]
return (
<span className={wrapperClassName} style={style} aria-hidden="true">
<svg
viewBox="0 0 24 24"
role="presentation"
width={size}
height={size}
style={{ color: `#${icon.hex}` }}
>
<path d={icon.path} fill="currentColor" />
</svg>
</span>
)
}
if (candidate.kind === "image") {
const imageLoaded = loadedImageSrc === candidate.src
return (
<span
className={cn(wrapperClassName, !imageLoaded && "site-icon--initial")}
style={style}
data-initial={!imageLoaded ? initialValue : undefined}
aria-hidden="true"
>
<img
className={cn(
"site-icon__image",
!imageLoaded && "site-icon__image--loading",
)}
src={candidate.src}
alt={`${link.title} 图标`}
width={size}
height={size}
onLoad={() => setLoadedImageSrc(candidate.src)}
onError={() =>
setCandidateIndex((index) =>
Math.min(index + 1, candidates.length - 1),
)
}
/>
</span>
)
}
return (
<span
className={cn(wrapperClassName, "site-icon--initial")}
style={style}
aria-hidden="true"
>
{candidate.value}
</span>
)
}
export function SiteIcon({ link, size = 36, className }: SiteIconProps) {
const candidates = getIconCandidates(link)
const candidateKey = JSON.stringify([link.icon ?? "", link.title, link.url])
// 链接变化时通过 key 重建候选状态,避免 effect 中同步 setState 和旧链接的失败记录泄漏。
return (
<SiteIconContent
key={candidateKey}
link={link}
candidates={candidates}
size={size}
className={className}
/>
)
}
@@ -0,0 +1,50 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
ghost: "hover:bg-primary/10 hover:text-primary",
outline:
"border border-border bg-transparent hover:bg-primary/10 hover:text-primary",
},
size: {
default: "h-9 px-4 py-2",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,194 @@
"use client"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 min-w-40 overflow-hidden rounded-xl border border-white/60 bg-white/90 p-1.5 text-slate-900 shadow-xl backdrop-blur-xl",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuItem({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none select-none focus:bg-blue-50 focus:text-blue-700 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
className,
)}
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg py-2 pr-2.5 pl-8 text-sm outline-none select-none focus:bg-blue-50 focus:text-blue-700 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2.5 py-1.5 text-xs font-medium text-slate-500 data-[inset]:pl-8",
className,
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-slate-200/80", className)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg py-2 pr-2.5 pl-8 text-sm outline-none select-none focus:bg-blue-50 focus:text-blue-700",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center rounded-lg px-2.5 py-2 text-sm outline-none select-none focus:bg-blue-50 data-[inset]:pl-8 data-[state=open]:bg-blue-50",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-40 overflow-hidden rounded-xl border border-white/60 bg-white/90 p-1.5 text-slate-900 shadow-xl backdrop-blur-xl",
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
@@ -0,0 +1,48 @@
"use client"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 240,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
function TooltipContent({
className,
sideOffset = 8,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 rounded-lg bg-slate-900 px-3 py-1.5 text-xs text-white shadow-lg",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="fill-slate-900" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
@@ -0,0 +1,51 @@
[
{
"id": "ai",
"title": "AI 工具",
"description": "精选常用 AI 工具与服务,辅助创作、开发和效率提升。",
"icon": "sparkles",
"sort": 10
},
{
"id": "dev",
"title": "开发工具",
"description": "高效的开发工具和资源集合,提升你的开发体验和效率。",
"icon": "code",
"sort": 20
},
{
"id": "design",
"title": "设计资源",
"description": "常用设计工具、素材与灵感资源集合。",
"icon": "palette",
"sort": 30
},
{
"id": "learning",
"title": "学习资料",
"description": "整理常用学习平台、文档、课程和知识资源。",
"icon": "book-open",
"sort": 40
},
{
"id": "efficiency",
"title": "效率工具",
"description": "提升日常工作流效率的工具入口。",
"icon": "zap",
"sort": 50
},
{
"id": "media",
"title": "娱乐媒体",
"description": "常用影音、社区和内容平台。",
"icon": "circle-play",
"sort": 60
},
{
"id": "life",
"title": "生活服务",
"description": "日常生活、服务查询和实用网站入口。",
"icon": "coffee",
"sort": 70
}
]
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest"
import categories from "@/data/categories.json"
import links from "@/data/links.json"
import settings from "@/data/settings.json"
import { SEARCH_PROVIDERS } from "@/types/search"
const hasUniqueValues = (values: string[]) =>
new Set(values).size === values.length
describe("Polaris 静态数据", () => {
it("包含 7 个唯一且结构完整的分类", () => {
expect(categories).toHaveLength(7)
expect(hasUniqueValues(categories.map(({ id }) => id))).toBe(true)
for (const category of categories) {
expect(category).toEqual({
id: expect.any(String),
title: expect.any(String),
description: expect.any(String),
icon: expect.any(String),
sort: expect.any(Number),
})
expect(category.id).not.toBe("")
expect(category.title).not.toBe("")
expect(Number.isFinite(category.sort)).toBe(true)
}
})
it("包含 20 条合法链接且分类引用均存在", () => {
const categoryIds = new Set(categories.map(({ id }) => id))
expect(links).toHaveLength(20)
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
for (const link of links) {
expect(link.id).toEqual(expect.any(String))
expect(link.title).toEqual(expect.any(String))
expect(link.description).toEqual(expect.any(String))
expect(link.url).toEqual(expect.any(String))
expect(link.categoryId).toEqual(expect.any(String))
expect(link.pinned).toEqual(expect.any(Boolean))
expect(link.keywords).toEqual(expect.any(Array))
expect(link.sort).toEqual(expect.any(Number))
expect(link.id).not.toBe("")
expect(link.title).not.toBe("")
expect(() => new URL(link.url)).not.toThrow()
// 跨分类常用链接仍必须归属于真实分类,避免未来 API 出现悬空外键。
expect(categoryIds.has(link.categoryId)).toBe(true)
expect(
link.keywords.every((keyword) => typeof keyword === "string"),
).toBe(true)
expect(Number.isFinite(link.sort)).toBe(true)
if (link.icon !== undefined) expect(typeof link.icon).toBe("string")
if (!link.pinned) expect(link.pinnedSort).toBeUndefined()
}
})
it("8 条常用链接拥有唯一 pinnedSort", () => {
const pinned = links.filter(({ pinned }) => pinned)
const pinnedSorts = pinned.map(({ pinnedSort }) => pinnedSort)
expect(pinned).toHaveLength(8)
expect(pinnedSorts.every(Number.isFinite)).toBe(true)
expect(new Set(pinnedSorts).size).toBe(8)
})
it("设置包含完整且合法的嵌套契约", () => {
expect(settings).toEqual({
siteName: "Polaris",
theme: "light-glass",
search: {
defaultProvider: "google",
enabledProviders: [...SEARCH_PROVIDERS],
},
background: {
type: "gradient",
url: null,
blur: expect.any(Number),
overlay: expect.any(Number),
},
sidebar: {
defaultCollapsed: expect.any(Boolean),
},
})
expect(SEARCH_PROVIDERS).toContain(settings.search.defaultProvider)
expect(Number.isFinite(settings.background.blur)).toBe(true)
expect(Number.isFinite(settings.background.overlay)).toBe(true)
})
})
+230
View File
@@ -0,0 +1,230 @@
[
{
"id": "github",
"title": "GitHub",
"description": "全球最大的代码托管平台",
"url": "https://github.com",
"icon": "si:github",
"categoryId": "dev",
"pinned": true,
"pinnedSort": 10,
"keywords": ["代码", "仓库", "开源", "git"],
"sort": 10
},
{
"id": "gitlab",
"title": "GitLab",
"description": "一体化 DevOps 平台",
"url": "https://gitlab.com",
"icon": "si:gitlab",
"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",
"description": "容器化平台",
"url": "https://www.docker.com",
"icon": "si:docker",
"categoryId": "dev",
"pinned": false,
"keywords": ["容器", "部署", "镜像"],
"sort": 80
},
{
"id": "kubernetes",
"title": "Kubernetes",
"description": "容器编排平台",
"url": "https://kubernetes.io",
"icon": "si:kubernetes",
"categoryId": "dev",
"pinned": false,
"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",
"description": "JavaScript 包管理器",
"url": "https://www.npmjs.com",
"icon": "si:npm",
"categoryId": "dev",
"pinned": false,
"keywords": ["javascript", "node", "包管理"],
"sort": 130
},
{
"id": "pnpm",
"title": "pnpm",
"description": "快速的包管理器",
"url": "https://pnpm.io",
"icon": "si:pnpm",
"categoryId": "dev",
"pinned": false,
"keywords": ["javascript", "node", "包管理"],
"sort": 140
},
{
"id": "figma",
"title": "Figma",
"description": "界面设计协作工具",
"url": "https://www.figma.com",
"icon": "si:figma",
"categoryId": "dev",
"pinned": true,
"pinnedSort": 50,
"keywords": ["设计", "ui", "协作"],
"sort": 150
},
{
"id": "chatgpt",
"title": "ChatGPT",
"description": "智能对话与创作助手",
"url": "https://chatgpt.com",
"icon": "si:openai",
"categoryId": "ai",
"pinned": true,
"pinnedSort": 20,
"keywords": ["ai", "人工智能", "对话", "写作"],
"sort": 10
},
{
"id": "gmail",
"title": "Gmail",
"description": "Google 电子邮件服务",
"url": "https://mail.google.com",
"icon": "si:gmail",
"categoryId": "efficiency",
"pinned": true,
"pinnedSort": 30,
"keywords": ["邮件", "邮箱", "google", "mail"],
"sort": 10
},
{
"id": "notion",
"title": "Notion",
"description": "一体化知识与协作空间",
"url": "https://www.notion.so",
"icon": "si:notion",
"categoryId": "efficiency",
"pinned": true,
"pinnedSort": 40,
"keywords": ["笔记", "知识库", "协作", "文档"],
"sort": 20
},
{
"id": "youtube",
"title": "YouTube",
"description": "全球视频内容平台",
"url": "https://www.youtube.com",
"icon": "si:youtube",
"categoryId": "media",
"pinned": true,
"pinnedSort": 60,
"keywords": ["视频", "影音", "直播", "google"],
"sort": 10
},
{
"id": "bilibili",
"title": "Bilibili",
"description": "年轻人的视频社区",
"url": "https://www.bilibili.com",
"icon": "si:bilibili",
"categoryId": "media",
"pinned": true,
"pinnedSort": 80,
"keywords": ["哔哩哔哩", "视频", "动漫", "社区"],
"sort": 20
}
]
@@ -0,0 +1,17 @@
{
"siteName": "Polaris",
"theme": "light-glass",
"search": {
"defaultProvider": "google",
"enabledProviders": ["google", "bing", "github", "local"]
},
"background": {
"type": "gradient",
"url": null,
"blur": 8,
"overlay": 0.32
},
"sidebar": {
"defaultCollapsed": false
}
}
@@ -0,0 +1,60 @@
import { act, render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ClockGreeting } from "./clock-greeting"
describe("ClockGreeting", () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 7, 20, 21, 56, 30))
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it("挂载后显示当前问候、时间和日期", () => {
render(<ClockGreeting />)
expect(screen.getByRole("timer")).toHaveTextContent("晚上好,21:56")
expect(screen.getByText("2026 年 8 月 20 日 星期四")).toBeInTheDocument()
})
it("在下一分钟边界刷新", () => {
render(<ClockGreeting />)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(screen.getByRole("timer")).toHaveTextContent("21:57")
})
it("延迟执行后重新对齐下一个真实分钟边界", () => {
const setTimeoutSpy = vi.spyOn(window, "setTimeout")
vi.setSystemTime(new Date(2026, 7, 20, 9, 6, 30))
render(<ClockGreeting />)
const delayedTick = setTimeoutSpy.mock.calls[0]?.[0]
expect(typeof delayedTick).toBe("function")
if (typeof delayedTick !== "function") throw new Error("Missing clock tick")
vi.setSystemTime(new Date(2026, 7, 20, 9, 7, 12))
act(() => delayedTick())
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 48_000)
})
it("跨午夜同步更新日期与问候语", () => {
vi.setSystemTime(new Date(2026, 7, 20, 23, 59, 30))
render(<ClockGreeting />)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(screen.getByRole("timer")).toHaveTextContent("夜深了,00:00")
expect(screen.getByText("2026 年 8 月 21 日 星期五")).toBeInTheDocument()
})
})
@@ -0,0 +1,34 @@
"use client"
import { useClock } from "@/features/home/hooks/use-clock"
import {
formatDate,
formatTime,
getGreeting,
} from "@/features/home/lib/date-time"
export function ClockGreeting() {
const now = useClock()
if (!now) {
return (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer">
正在同步时间…
</p>
<p className="clock-greeting__date" aria-hidden="true">
&nbsp;
</p>
</section>
)
}
return (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer" aria-live="off">
{getGreeting(now.getHours())},{formatTime(now)}
</p>
<p className="clock-greeting__date">{formatDate(now)}</p>
</section>
)
}
@@ -0,0 +1,34 @@
import { ClockGreeting } from "@/features/home/components/clock-greeting"
import { QuickLinks } from "@/features/home/components/quick-links"
import { SearchBox } from "@/features/home/components/search-box"
import type { Category, NavLink } from "@/types/navigation"
import type { AppSettings } from "@/types/settings"
type HomeContentProps = {
categories: Category[]
links: NavLink[]
pinnedLinks: NavLink[]
settings: AppSettings
}
export function HomeContent({
categories,
links,
pinnedLinks,
settings,
}: HomeContentProps) {
return (
<main className="home-content">
<div className="home-content__inner">
<ClockGreeting />
<SearchBox
links={links}
categories={categories}
defaultProvider={settings.search.defaultProvider}
enabledProviders={settings.search.enabledProviders}
/>
<QuickLinks links={pinnedLinks} />
</div>
</main>
)
}
@@ -0,0 +1,53 @@
import type { MutableRefObject } from "react"
import { SiteIcon } from "@/components/shared/site-icon"
import type { LocalSearchResult } from "@/types/search"
type LocalSearchResultsProps = {
listboxId: string
results: LocalSearchResult[]
activeIndex: number
optionRefs: MutableRefObject<Array<HTMLAnchorElement | null>>
onActiveIndexChange: (index: number) => void
}
export function LocalSearchResults({
listboxId,
results,
activeIndex,
optionRefs,
onActiveIndexChange,
}: LocalSearchResultsProps) {
return (
<div className="local-search-results" role="listbox" id={listboxId}>
{results.length === 0 ? (
<p className="local-search-results__empty">没有匹配的网站</p>
) : (
results.map(({ link, categoryTitle }, index) => (
<a
key={link.id}
ref={(element) => {
optionRefs.current[index] = element
}}
id={`${listboxId}-option-${index}`}
className="local-search-results__option"
role="option"
aria-selected={index === activeIndex}
href={link.url}
target="_blank"
rel="noopener noreferrer"
onMouseMove={() => onActiveIndexChange(index)}
>
<SiteIcon link={link} size={32} />
<span className="local-search-results__copy">
<span className="local-search-results__title">{link.title}</span>
<span className="local-search-results__category">
{categoryTitle}
</span>
</span>
</a>
))
)}
</div>
)
}
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import type { NavLink } from "@/types/navigation"
import { QuickLinks } from "./quick-links"
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: [],
sort: 10,
},
{
id: "chatgpt",
title: "ChatGPT",
description: "AI 助手",
url: "https://chatgpt.com",
icon: "si:openai",
categoryId: "ai",
pinned: true,
pinnedSort: 20,
keywords: [],
sort: 10,
},
]
describe("QuickLinks", () => {
it("按输入顺序渲染安全的新标签页链接", () => {
render(<QuickLinks links={links} />)
const renderedLinks = screen.getAllByRole("link")
expect(renderedLinks.map((link) => link.textContent)).toEqual([
"GitHub",
"ChatGPT",
])
for (const link of renderedLinks) {
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
}
})
it("只展示图标和名称,不展示描述", () => {
render(<QuickLinks links={links} />)
expect(screen.queryByText("代码托管")).not.toBeInTheDocument()
expect(screen.queryByText("AI 助手")).not.toBeInTheDocument()
})
})
@@ -0,0 +1,26 @@
import { SiteIcon } from "@/components/shared/site-icon"
import type { NavLink } from "@/types/navigation"
export function QuickLinks({ links }: { links: NavLink[] }) {
return (
<section className="quick-links" aria-labelledby="quick-links-title">
<h2 id="quick-links-title" className="quick-links__title">
常用链接
</h2>
<div className="quick-links__grid">
{links.map((link) => (
<a
key={link.id}
className="quick-link-card"
href={link.url}
target="_blank"
rel="noopener noreferrer"
>
<SiteIcon link={link} size={38} />
<span>{link.title}</span>
</a>
))}
</div>
</section>
)
}
@@ -0,0 +1,172 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import type { ComponentProps } from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { Category, NavLink } from "@/types/navigation"
import { SearchBox } from "./search-box"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 },
]
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["git", "仓库"],
sort: 10,
},
{
id: "gitlab",
title: "GitLab",
description: "DevOps 平台",
url: "https://gitlab.com",
icon: "si:gitlab",
categoryId: "dev",
pinned: false,
keywords: ["git", "ci"],
sort: 20,
},
]
function renderSearchBox(
overrides: Partial<ComponentProps<typeof SearchBox>> = {},
) {
const onExternalNavigate = vi.fn()
const renderResult = render(
<SearchBox
links={links}
categories={categories}
defaultProvider="google"
enabledProviders={["google", "bing", "github", "local"]}
onExternalNavigate={onExternalNavigate}
{...overrides}
/>,
)
return { onExternalNavigate, ...renderResult }
}
describe("SearchBox", () => {
beforeEach(() => window.localStorage.clear())
afterEach(() => vi.restoreAllMocks())
it("挂载后自动聚焦,并响应 Ctrl+K 与 Meta+K", async () => {
renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await waitFor(() => expect(input).toHaveFocus())
input.blur()
fireEvent.keyDown(window, { key: "k", ctrlKey: true })
expect(input).toHaveFocus()
input.blur()
fireEvent.keyDown(window, { key: "k", metaKey: true })
expect(input).toHaveFocus()
})
it("Google 模式回车只执行当前页外部导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await user.type(input, "Next.js 中文{Enter}")
const url = new URL(onExternalNavigate.mock.calls[0][0])
expect(url.hostname).toBe("www.google.com")
expect(url.searchParams.get("q")).toBe("Next.js 中文")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("空白查询不导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
await user.type(screen.getByRole("combobox"), " {Enter}")
expect(onExternalNavigate).not.toHaveBeenCalled()
})
it("切换 Local 后实时显示结果并持久化 Provider", async () => {
const user = userEvent.setup()
renderSearchBox()
await user.click(
screen.getByRole("button", { name: "选择搜索方式,当前为 Google" }),
)
await user.click(screen.getByRole("menuitemradio", { name: "本地" }))
await user.type(screen.getByRole("combobox"), "git")
expect(screen.getByRole("listbox")).toBeInTheDocument()
expect(screen.getAllByRole("option")).toHaveLength(2)
expect(window.localStorage.getItem("polaris.search.provider")).toBe("local")
})
it("Local 模式支持上下选择、Enter 与 Esc", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
const input = screen.getByRole("combobox")
await user.type(input, "git")
const options = screen.getAllByRole("option")
expect(options[0]).toHaveAttribute("aria-selected", "true")
expect(options[0]).toHaveAttribute("target", "_blank")
expect(options[0]).toHaveAttribute("rel", "noopener noreferrer")
await user.keyboard("{ArrowUp}")
expect(options[1]).toHaveAttribute("aria-selected", "true")
await user.keyboard("{ArrowDown}")
expect(options[0]).toHaveAttribute("aria-selected", "true")
fireEvent.mouseMove(options[1])
expect(options[1]).toHaveAttribute("aria-selected", "true")
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {})
await user.keyboard("{Enter}")
expect(clickSpy).toHaveBeenCalledOnce()
await user.keyboard("{Escape}")
expect(input).toHaveValue("")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("Local 无结果显示空状态", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
await user.type(screen.getByRole("combobox"), "不存在的网站")
expect(screen.getByText("没有匹配的网站")).toBeInTheDocument()
})
it("恢复合法 Provider,非法或未启用值回退默认 Provider", async () => {
window.localStorage.setItem("polaris.search.provider", "local")
const { unmount } = renderSearchBox()
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 本地/ }),
).toBeInTheDocument(),
)
unmount()
window.localStorage.setItem("polaris.search.provider", "invalid")
const secondRender = renderSearchBox()
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 Google/ }),
).toBeInTheDocument(),
)
secondRender.unmount()
window.localStorage.setItem("polaris.search.provider", "local")
renderSearchBox({ enabledProviders: ["google", "bing"] })
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 Google/ }),
).toBeInTheDocument(),
)
})
})
@@ -0,0 +1,217 @@
"use client"
import { Search } from "lucide-react"
import {
useEffect,
useId,
useMemo,
useRef,
useState,
useSyncExternalStore,
type FormEvent,
type KeyboardEvent,
} from "react"
import { LocalSearchResults } from "@/features/home/components/local-search-results"
import { SearchProviderMenu } from "@/features/home/components/search-provider-menu"
import { searchLocalLinks } from "@/features/home/lib/local-search"
import {
parseStoredProvider,
SEARCH_PROVIDER_STORAGE_KEY,
} from "@/features/home/lib/provider-preference"
import { buildSearchUrl } from "@/features/home/lib/search-providers"
import type { Category, NavLink } from "@/types/navigation"
import type { SearchProvider } from "@/types/search"
type SearchBoxProps = {
links: NavLink[]
categories: Category[]
defaultProvider: SearchProvider
enabledProviders: SearchProvider[]
onExternalNavigate?: (url: string) => void
}
function subscribeHydration() {
return () => undefined
}
function getClientHydrationSnapshot() {
return true
}
function getServerHydrationSnapshot() {
return false
}
function readStoredProvider(
defaultProvider: SearchProvider,
enabledProviders: SearchProvider[],
): SearchProvider {
try {
const storedProvider = parseStoredProvider(
window.localStorage.getItem(SEARCH_PROVIDER_STORAGE_KEY),
defaultProvider,
)
return enabledProviders.includes(storedProvider)
? storedProvider
: defaultProvider
} catch {
return defaultProvider
}
}
export function SearchBox({
links,
categories,
defaultProvider,
enabledProviders,
onExternalNavigate,
}: SearchBoxProps) {
const inputRef = useRef<HTMLInputElement>(null)
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([])
const listboxId = useId()
const [query, setQuery] = useState("")
const [selectedProvider, setSelectedProvider] =
useState<SearchProvider | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const hydrated = useSyncExternalStore(
subscribeHydration,
getClientHydrationSnapshot,
getServerHydrationSnapshot,
)
// Server 与 hydration 首帧保持默认 Provider;hydration 后再容错读取浏览器偏好。
const restoredProvider = hydrated
? readStoredProvider(defaultProvider, enabledProviders)
: defaultProvider
const provider = selectedProvider ?? restoredProvider
const results = useMemo(
() =>
provider === "local" ? searchLocalLinks(query, links, categories, 8) : [],
[categories, links, provider, query],
)
const panelOpen = provider === "local" && query.trim().length > 0
const resolvedActiveIndex =
results.length > 0 ? Math.min(activeIndex, results.length - 1) : 0
const activeOptionId =
panelOpen && results.length > 0
? `${listboxId}-option-${resolvedActiveIndex}`
: undefined
const shortcutHint =
hydrated && navigator.userAgent.includes("Mac") ? "⌘K" : "Ctrl K"
useEffect(() => {
inputRef.current?.focus()
const focusSearch = (event: globalThis.KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener("keydown", focusSearch)
return () => window.removeEventListener("keydown", focusSearch)
}, [])
const changeProvider = (nextProvider: SearchProvider) => {
setSelectedProvider(nextProvider)
setActiveIndex(0)
try {
window.localStorage.setItem(SEARCH_PROVIDER_STORAGE_KEY, nextProvider)
} catch {
// Provider 已保留在当前组件状态,存储受限时不影响本次交互。
}
}
const openActiveLocalResult = () => {
if (results.length === 0) return
// 复用真实 anchor 的导航语义,键盘 Enter 不另造 click-only 跳转路径。
optionRefs.current[resolvedActiveIndex]?.click()
}
const submitSearch = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
// Local 只打开站内结果;外部 Provider 只构建搜索 URL,两条路径严格分离。
if (provider === "local") {
openActiveLocalResult()
return
}
const url = buildSearchUrl(provider, query)
if (!url) return
if (onExternalNavigate) onExternalNavigate(url)
else window.location.assign(url)
}
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
event.preventDefault()
setQuery("")
setActiveIndex(0)
return
}
if (provider !== "local") return
if (event.key === "Enter") {
event.preventDefault()
openActiveLocalResult()
return
}
if (results.length === 0) return
if (event.key === "ArrowDown") {
event.preventDefault()
setActiveIndex((index) => (index + 1) % results.length)
} else if (event.key === "ArrowUp") {
event.preventDefault()
setActiveIndex((index) => (index - 1 + results.length) % results.length)
}
}
return (
<div className="search-box">
<form className="search-box__form" role="search" onSubmit={submitSearch}>
<SearchProviderMenu
provider={provider}
enabledProviders={enabledProviders}
onProviderChange={changeProvider}
/>
<Search className="search-box__icon" aria-hidden="true" />
<input
ref={inputRef}
className="search-box__input"
role="combobox"
aria-label="搜索网站、书签或关键词"
aria-autocomplete="list"
aria-expanded={panelOpen}
aria-controls={panelOpen ? listboxId : undefined}
aria-activedescendant={activeOptionId}
placeholder="搜索网站、书签或关键词..."
value={query}
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
}}
onKeyDown={handleInputKeyDown}
/>
<kbd className="search-box__shortcut" aria-hidden="true">
{shortcutHint}
</kbd>
</form>
{panelOpen ? (
<LocalSearchResults
listboxId={listboxId}
results={results}
activeIndex={resolvedActiveIndex}
optionRefs={optionRefs}
onActiveIndexChange={setActiveIndex}
/>
) : null}
</div>
)
}
@@ -0,0 +1,85 @@
"use client"
import {
Database,
GitFork,
Globe2,
Search,
type LucideIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { isSearchProvider } from "@/features/home/lib/search-providers"
import type { SearchProvider } from "@/types/search"
const PROVIDER_LABELS: Record<SearchProvider, string> = {
google: "Google",
bing: "Bing",
github: "GitHub",
local: "本地",
}
const PROVIDER_ICONS: Record<SearchProvider, LucideIcon> = {
google: Globe2,
bing: Search,
github: GitFork,
local: Database,
}
type SearchProviderMenuProps = {
provider: SearchProvider
enabledProviders: SearchProvider[]
onProviderChange: (provider: SearchProvider) => void
}
export function SearchProviderMenu({
provider,
enabledProviders,
onProviderChange,
}: SearchProviderMenuProps) {
const CurrentIcon = PROVIDER_ICONS[provider]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="search-provider-menu__trigger"
aria-label={`选择搜索方式,当前为 ${PROVIDER_LABELS[provider]}`}
>
<CurrentIcon aria-hidden="true" />
<span>{PROVIDER_LABELS[provider]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={provider}
onValueChange={(value) => {
if (isSearchProvider(value)) onProviderChange(value)
}}
>
{enabledProviders.map((enabledProvider) => {
const Icon = PROVIDER_ICONS[enabledProvider]
return (
<DropdownMenuRadioItem
key={enabledProvider}
value={enabledProvider}
>
<Icon aria-hidden="true" />
{PROVIDER_LABELS[enabledProvider]}
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,61 @@
"use client"
import { useSyncExternalStore } from "react"
import { getDelayUntilNextMinute } from "@/features/home/lib/date-time"
type Listener = () => void
const listeners = new Set<Listener>()
let currentTime: Date | null = null
let timeoutId: number | undefined
function emitCurrentTime(): Date {
const nextTime = new Date()
currentTime = nextTime
listeners.forEach((listener) => listener())
return nextTime
}
function scheduleNextTick() {
const observedTime = emitCurrentTime()
// 每次 tick 都重新对齐;标签页休眠或主线程阻塞后不会把延迟永久带入后续分钟。
timeoutId = window.setTimeout(
scheduleNextTick,
getDelayUntilNextMinute(observedTime),
)
}
function startClock() {
scheduleNextTick()
}
function stopClock() {
if (timeoutId !== undefined) window.clearTimeout(timeoutId)
timeoutId = undefined
currentTime = null
}
function subscribe(listener: Listener) {
listeners.add(listener)
if (listeners.size === 1) startClock()
return () => {
listeners.delete(listener)
if (listeners.size === 0) stopClock()
}
}
function getSnapshot() {
return currentTime
}
function getServerSnapshot() {
return null
}
export function useClock(): Date | null {
// 服务端与 hydration 首帧固定为空;订阅建立后再读取浏览器时间,避免首屏不一致。
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import {
formatDate,
formatTime,
getDelayUntilNextMinute,
getGreeting,
} from "./date-time"
describe("首页日期与问候", () => {
it.each([
[0, "夜深了"],
[4, "夜深了"],
[5, "早上好"],
[11, "早上好"],
[12, "中午好"],
[13, "中午好"],
[14, "下午好"],
[17, "下午好"],
[18, "晚上好"],
[23, "晚上好"],
])("%i 时显示 %s", (hour, expected) => {
expect(getGreeting(hour)).toBe(expected)
})
it("拒绝无效小时", () => {
expect(() => getGreeting(-1)).toThrow("hour must be between 0 and 23")
expect(() => getGreeting(24)).toThrow("hour must be between 0 and 23")
})
it("格式化中文日期与两位时间", () => {
const date = new Date(2026, 7, 20, 9, 6, 30)
expect(formatTime(date)).toBe("09:06")
expect(formatDate(date)).toBe("2026 年 8 月 20 日 星期四")
})
it("计算到下一分钟边界的延迟", () => {
expect(getDelayUntilNextMinute(new Date(2026, 7, 20, 9, 6, 30, 250))).toBe(
29_750,
)
})
})
@@ -0,0 +1,34 @@
const WEEKDAYS = [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
] as const
export function getGreeting(hour: number): string {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new RangeError("hour must be between 0 and 23")
}
if (hour < 5) return "夜深了"
if (hour < 12) return "早上好"
if (hour < 14) return "中午好"
if (hour < 18) return "下午好"
return "晚上好"
}
export function formatTime(date: Date): string {
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
return `${hours}:${minutes}`
}
export function formatDate(date: Date): string {
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${WEEKDAYS[date.getDay()]}`
}
export function getDelayUntilNextMinute(date: Date): number {
return 60_000 - (date.getSeconds() * 1_000 + date.getMilliseconds())
}
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest"
import type { Category, NavLink } from "@/types/navigation"
import { searchLocalLinks } from "./local-search"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 },
{ id: "ai", title: "AI 工具", description: "", icon: "sparkles", sort: 20 },
]
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["git", "仓库"],
sort: 20,
},
{
id: "gitlab",
title: "GitLab",
description: "DevOps 平台",
url: "https://gitlab.com",
icon: "si:gitlab",
categoryId: "dev",
pinned: false,
keywords: ["git", "ci"],
sort: 10,
},
{
id: "chatgpt",
title: "ChatGPT",
description: "智能对话与写作",
url: "https://chatgpt.com",
icon: "si:openai",
categoryId: "ai",
pinned: true,
pinnedSort: 20,
keywords: ["人工智能", "写作"],
sort: 10,
},
]
describe("Local 搜索", () => {
it("标题完全匹配优先于标题包含和关键词", () => {
expect(searchLocalLinks("github", links, categories)[0].link.id).toBe(
"github",
)
})
it("匹配关键词、描述、分类标题和分类 ID", () => {
expect(searchLocalLinks("人工智能", links, categories)[0].link.id).toBe(
"chatgpt",
)
expect(searchLocalLinks("DevOps", links, categories)[0].link.id).toBe(
"gitlab",
)
expect(
searchLocalLinks("开发工具", links, categories).map(
({ link }) => link.id,
),
).toEqual(["gitlab", "github"])
expect(searchLocalLinks("ai", links, categories)[0].link.id).toBe("chatgpt")
})
it("同分时按 sort 再按标题稳定排序", () => {
expect(
searchLocalLinks("git", links, categories).map(({ link }) => link.id),
).toEqual(["gitlab", "github"])
const titleTies: NavLink[] = [
{
...links[0],
id: "beta",
title: "贝塔",
keywords: ["工具"],
sort: 10,
},
{
...links[0],
id: "alpha",
title: "阿尔法",
keywords: ["工具"],
sort: 10,
},
]
expect(
searchLocalLinks("工具", titleTies, categories).map(
({ link }) => link.id,
),
).toEqual(["alpha", "beta"])
})
it("规范化大小写、全角字符和首尾空白并限制结果数量", () => {
expect(
searchLocalLinks(" GitHub ", links, categories, 1).map(
({ link }) => link.id,
),
).toEqual(["github"])
})
it("空查询返回空数组", () => {
expect(searchLocalLinks(" ", links, categories)).toEqual([])
})
})
@@ -0,0 +1,74 @@
import type { Category, NavLink } from "@/types/navigation"
import type { LocalSearchResult } from "@/types/search"
function normalize(value: string): string {
return value.normalize("NFKC").trim().toLocaleLowerCase("zh-CN")
}
function textScore(
value: string,
query: string,
exact: number,
prefix: number,
contains: number,
): number {
if (value === query) return exact
if (value.startsWith(query)) return prefix
if (value.includes(query)) return contains
return 0
}
function scoreLink(
link: NavLink,
category: Category | undefined,
query: string,
): number {
const title = normalize(link.title)
const keywordScore = Math.max(
0,
...link.keywords.map((keyword) =>
textScore(normalize(keyword), query, 300, 260, 240),
),
)
// 各区间不重叠,保证“标题 > 关键词 > 描述 > 分类”的规则不会被字段数量反转。
return Math.max(
textScore(title, query, 500, 400, 350),
keywordScore,
normalize(link.description).includes(query) ? 180 : 0,
category ? textScore(normalize(category.title), query, 140, 130, 120) : 0,
category ? textScore(normalize(category.id), query, 100, 90, 80) : 0,
)
}
export function searchLocalLinks(
query: string,
links: NavLink[],
categories: Category[],
limit = 8,
): LocalSearchResult[] {
const normalizedQuery = normalize(query)
if (!normalizedQuery || limit <= 0) return []
const categoryById = new Map(
categories.map((category) => [category.id, category]),
)
return links
.map((link) => {
const category = categoryById.get(link.categoryId)
return {
link,
categoryTitle: category?.title ?? "未分类",
score: scoreLink(link, category, normalizedQuery),
}
})
.filter(({ score }) => score > 0)
.sort(
(a, b) =>
b.score - a.score ||
a.link.sort - b.link.sort ||
a.link.title.localeCompare(b.link.title, "zh-CN"),
)
.slice(0, limit)
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest"
import { parseStoredProvider } from "./provider-preference"
describe("Provider 持久化值", () => {
it("返回合法存储值", () => {
expect(parseStoredProvider("local", "google")).toBe("local")
})
it("非法或空值回退默认值", () => {
expect(parseStoredProvider("duckduckgo", "google")).toBe("google")
expect(parseStoredProvider(null, "bing")).toBe("bing")
})
})
@@ -0,0 +1,11 @@
import { isSearchProvider } from "@/features/home/lib/search-providers"
import type { SearchProvider } from "@/types/search"
export const SEARCH_PROVIDER_STORAGE_KEY = "polaris.search.provider"
export function parseStoredProvider(
value: string | null,
fallback: SearchProvider,
): SearchProvider {
return isSearchProvider(value) ? value : fallback
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest"
import { buildSearchUrl, isSearchProvider } from "./search-providers"
describe("外部搜索 Provider", () => {
it.each([
["google", "https://www.google.com/search"],
["bing", "https://www.bing.com/search"],
["github", "https://github.com/search"],
] as const)("为 %s 构建正确 URL", (provider, expectedBase) => {
const result = buildSearchUrl(provider, "Next.js 中文")
const url = new URL(result!)
expect(`${url.origin}${url.pathname}`).toBe(expectedBase)
expect(url.searchParams.get("q")).toBe("Next.js 中文")
})
it("对查询参数分隔符和百分号执行 URL 编码", () => {
const query = "a&next=1 %= #中文"
const url = new URL(buildSearchUrl("google", query)!)
expect(url.searchParams.get("q")).toBe(query)
expect([...url.searchParams.keys()]).toEqual(["q"])
})
it("空白查询不生成 URL", () => {
expect(buildSearchUrl("google", " ")).toBeNull()
})
it("识别合法 Provider", () => {
expect(isSearchProvider("local")).toBe(true)
expect(isSearchProvider("duckduckgo")).toBe(false)
expect(isSearchProvider(null)).toBe(false)
})
})
@@ -0,0 +1,30 @@
import {
SEARCH_PROVIDERS,
type ExternalSearchProvider,
type SearchProvider,
} from "@/types/search"
const SEARCH_ENDPOINTS: Record<ExternalSearchProvider, string> = {
google: "https://www.google.com/search",
bing: "https://www.bing.com/search",
github: "https://github.com/search",
}
export function isSearchProvider(value: unknown): value is SearchProvider {
return (
typeof value === "string" &&
SEARCH_PROVIDERS.includes(value as SearchProvider)
)
}
export function buildSearchUrl(
provider: ExternalSearchProvider,
query: string,
): string | null {
const normalizedQuery = query.trim()
if (!normalizedQuery) return null
const url = new URL(SEARCH_ENDPOINTS[provider])
url.searchParams.set("q", normalizedQuery)
return url.toString()
}
@@ -0,0 +1,162 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { Category } from "@/types/navigation"
import { AppShell } from "./app-shell"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "开发", icon: "code", sort: 10 },
]
describe("AppShell", () => {
beforeEach(() => window.localStorage.clear())
afterEach(() => vi.restoreAllMocks())
it("切换侧栏并持久化状态", () => {
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
const toggle = screen.getByRole("button", { name: "折叠侧边栏" })
fireEvent.click(toggle)
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
expect(window.localStorage.getItem("polaris.sidebar.collapsed")).toBe(
"true",
)
})
it("hydration 后恢复持久化状态", async () => {
window.localStorage.setItem("polaris.sidebar.collapsed", "true")
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
await waitFor(() => {
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-hydrated",
"true",
)
})
})
it("写入失败时内存偏好优先于旧持久化值", () => {
window.localStorage.setItem("polaris.sidebar.collapsed", "false")
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable")
})
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
fireEvent.click(screen.getByRole("button", { name: "折叠侧边栏" }))
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
})
it("忽略 sessionStorage 的同名 storage 事件", () => {
window.localStorage.setItem("polaris.sidebar.collapsed", "false")
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable")
})
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
fireEvent.click(screen.getByRole("button", { name: "折叠侧边栏" }))
fireEvent(
window,
new StorageEvent("storage", {
key: "polaris.sidebar.collapsed",
storageArea: window.sessionStorage,
}),
)
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
})
it("存储不可用时仍保留当前会话内交互", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("storage unavailable")
})
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable")
})
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
fireEvent.click(screen.getByRole("button", { name: "折叠侧边栏" }))
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
})
it("分类项可聚焦但保持禁用", () => {
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
expect(
screen.getByRole("button", {
name: "开发工具,分类页将在下一阶段提供",
}),
).toHaveAttribute("aria-disabled", "true")
expect(
screen.queryByRole("link", { name: /开发工具/ }),
).not.toBeInTheDocument()
})
})
@@ -0,0 +1,127 @@
"use client"
import { useCallback, useSyncExternalStore, type ReactNode } from "react"
import { Sidebar } from "@/features/navigation/components/sidebar"
import {
parseSidebarPreference,
SIDEBAR_STORAGE_KEY,
} from "@/features/navigation/lib/sidebar-preference"
import type { Category } from "@/types/navigation"
type AppShellProps = {
categories: Category[]
siteName: string
defaultCollapsed: boolean
children: ReactNode
}
type Listener = () => void
const listeners = new Set<Listener>()
let volatilePreference: boolean | undefined
function emitPreferenceChange() {
listeners.forEach((listener) => listener())
}
function handleStorage(event: StorageEvent) {
if (event.storageArea !== window.localStorage) return
if (event.key !== null && event.key !== SIDEBAR_STORAGE_KEY) return
volatilePreference = undefined
emitPreferenceChange()
}
function subscribe(listener: Listener) {
listeners.add(listener)
if (listeners.size === 1) window.addEventListener("storage", handleStorage)
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
window.removeEventListener("storage", handleStorage)
volatilePreference = undefined
}
}
}
function readPreference(fallback: boolean): boolean {
if (volatilePreference !== undefined) return volatilePreference
try {
return parseSidebarPreference(
window.localStorage.getItem(SIDEBAR_STORAGE_KEY),
fallback,
)
} catch {
return fallback
}
}
function writePreference(value: boolean) {
volatilePreference = value
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(value))
volatilePreference = undefined
} catch {
// 存储不可用时仍保留当前会话内交互,不让隐私模式破坏页面。
}
emitPreferenceChange()
}
function subscribeHydration() {
return () => undefined
}
function getClientHydrationSnapshot() {
return true
}
function getServerHydrationSnapshot() {
return false
}
export function AppShell({
categories,
siteName,
defaultCollapsed,
children,
}: AppShellProps) {
const getSnapshot = useCallback(
() => readPreference(defaultCollapsed),
[defaultCollapsed],
)
const getServerSnapshot = useCallback(
() => defaultCollapsed,
[defaultCollapsed],
)
const collapsed = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
)
const hydrated = useSyncExternalStore(
subscribeHydration,
getClientHydrationSnapshot,
getServerHydrationSnapshot,
)
const toggleSidebar = () => writePreference(!collapsed)
return (
<div
className="app-shell"
data-testid="app-shell"
data-hydrated={hydrated}
data-sidebar-collapsed={collapsed}
>
<Sidebar
categories={categories}
siteName={siteName}
collapsed={collapsed}
onToggle={toggleSidebar}
/>
<div className="app-shell__content">{children}</div>
</div>
)
}
@@ -0,0 +1,25 @@
import {
BookOpen,
CirclePlay,
Code2,
Coffee,
Palette,
Sparkles,
Zap,
type LucideIcon,
} from "lucide-react"
const CATEGORY_ICONS: Readonly<Record<string, LucideIcon>> = {
sparkles: Sparkles,
code: Code2,
palette: Palette,
"book-open": BookOpen,
zap: Zap,
"circle-play": CirclePlay,
coffee: Coffee,
}
export function CategoryIcon({ name }: { name: string }) {
const Icon = CATEGORY_ICONS[name] ?? Sparkles
return <Icon aria-hidden="true" strokeWidth={1.8} />
}
@@ -0,0 +1,105 @@
import { Home, PanelLeftClose, PanelLeftOpen } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { CategoryIcon } from "@/features/navigation/components/category-icon"
import { cn } from "@/lib/utils"
import type { Category } from "@/types/navigation"
type SidebarProps = {
categories: Category[]
siteName: string
collapsed: boolean
onToggle: () => void
}
export function Sidebar({
categories,
siteName,
collapsed,
onToggle,
}: SidebarProps) {
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose
const toggleLabel = collapsed ? "展开侧边栏" : "折叠侧边栏"
return (
<TooltipProvider>
<aside className="sidebar" aria-label="主导航">
<div className="sidebar__header">
<Link
className="sidebar__brand"
href="/"
aria-label={`${siteName} 首页`}
>
<Image
src="/brand/logo.svg"
alt=""
width={40}
height={40}
priority
/>
<span className={cn("sidebar__site-name", collapsed && "sr-only")}>
{siteName}
</span>
</Link>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={toggleLabel}
onClick={onToggle}
>
<ToggleIcon aria-hidden="true" />
</Button>
</div>
<nav className="sidebar__nav" aria-label="站点导航">
<Link
className="sidebar__nav-item sidebar__nav-item--active"
href="/"
aria-current="page"
>
<Home aria-hidden="true" />
<span className={cn("sidebar__label", collapsed && "sr-only")}>
首页
</span>
</Link>
<div className="sidebar__categories">
{/* 本次只验收首页;分类页完成前保持可见但禁用,避免用户进入 404。 */}
{categories.map((category) => (
<Tooltip key={category.id}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
className="sidebar__nav-item"
aria-disabled="true"
aria-label={`${category.title},分类页将在下一阶段提供`}
>
<CategoryIcon name={category.icon} />
<span
className={cn("sidebar__label", collapsed && "sr-only")}
>
{category.title}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
分类页将在下一阶段提供
</TooltipContent>
</Tooltip>
))}
</div>
</nav>
</aside>
</TooltipProvider>
)
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest"
import { parseSidebarPreference } from "./sidebar-preference"
describe("侧栏持久化值", () => {
it("解析显式布尔字符串", () => {
expect(parseSidebarPreference("true", false)).toBe(true)
expect(parseSidebarPreference("false", true)).toBe(false)
})
it("损坏或缺失值回退设置默认值", () => {
expect(parseSidebarPreference("collapsed", false)).toBe(false)
expect(parseSidebarPreference(null, true)).toBe(true)
})
})
@@ -0,0 +1,10 @@
export const SIDEBAR_STORAGE_KEY = "polaris.sidebar.collapsed"
export function parseSidebarPreference(
value: string | null,
fallback: boolean,
): boolean {
if (value === "true") return true
if (value === "false") return false
return fallback
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,10 @@
import categoriesData from "@/data/categories.json"
import type { Category } from "@/types/navigation"
const categories = categoriesData satisfies Category[]
export function getCategories(): Category[] {
return categories
.map((category) => ({ ...category }))
.sort((a, b) => a.sort - b.sort)
}
@@ -0,0 +1,25 @@
import linksData from "@/data/links.json"
import type { NavLink } from "@/types/navigation"
const links = linksData satisfies NavLink[]
function cloneLink(link: NavLink): NavLink {
return { ...link, keywords: [...link.keywords] }
}
export function getLinks(): NavLink[] {
return links.map(cloneLink)
}
export function getPinnedLinks(limit = 8): NavLink[] {
// 首页顺序必须独立于分类内 sort,否则 Vercel 与 Figma 无法同时满足两处设计。
return links
.filter((link) => link.pinned)
.map(cloneLink)
.sort(
(a, b) =>
(a.pinnedSort ?? Number.MAX_SAFE_INTEGER) -
(b.pinnedSort ?? Number.MAX_SAFE_INTEGER),
)
.slice(0, limit)
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest"
import { getCategories } from "@/services/categories"
import { getLinks, getPinnedLinks } from "@/services/links"
import { getSettings, parseSettings } from "@/services/settings"
describe("静态数据 service", () => {
it("按 sort 返回分类并提供新数组", () => {
const first = getCategories()
const second = getCategories()
expect(first.map(({ id }) => id)).toEqual([
"ai",
"dev",
"design",
"learning",
"efficiency",
"media",
"life",
])
expect(first).not.toBe(second)
})
it("按 pinnedSort 返回首页 8 个常用链接", () => {
expect(getPinnedLinks().map(({ id }) => id)).toEqual([
"github",
"chatgpt",
"gmail",
"notion",
"figma",
"youtube",
"vercel",
"bilibili",
])
})
it("限制常用链接数量且不修改原数据", () => {
const allLinks = getLinks()
expect(getPinnedLinks(3)).toHaveLength(3)
expect(getLinks()).toEqual(allLinks)
})
it("返回设置对象及嵌套集合的独立副本", () => {
const first = getSettings()
first.search.enabledProviders.pop()
expect(first).not.toBe(getSettings())
expect(getSettings().search.enabledProviders).toHaveLength(4)
})
it.each([
["非法主题", { ...getSettings(), theme: "dark" }],
[
"非法背景值",
{
...getSettings(),
background: { ...getSettings().background, blur: "8" },
},
],
[
"非法侧栏值",
{
...getSettings(),
sidebar: { defaultCollapsed: "false" },
},
],
[
"非法 Provider",
{
...getSettings(),
search: {
...getSettings().search,
defaultProvider: "duckduckgo",
},
},
],
["缺少站点名", { ...getSettings(), siteName: undefined }],
])("拒绝%s", (_label, value) => {
expect(() => parseSettings(value)).toThrow("Invalid Polaris settings")
})
})
@@ -0,0 +1,79 @@
import settingsData from "@/data/settings.json"
import { SEARCH_PROVIDERS, type SearchProvider } from "@/types/search"
import type { AppSettings } from "@/types/settings"
function isSearchProvider(value: unknown): value is SearchProvider {
return (
typeof value === "string" &&
SEARCH_PROVIDERS.includes(value as SearchProvider)
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function invalidSettings(): never {
throw new TypeError("Invalid Polaris settings")
}
export function parseSettings(value: unknown): AppSettings {
if (!isRecord(value)) invalidSettings()
const search = value.search
const background = value.background
const sidebar = value.sidebar
if (
typeof value.siteName !== "string" ||
value.theme !== "light-glass" ||
!isRecord(search) ||
!isSearchProvider(search.defaultProvider) ||
!Array.isArray(search.enabledProviders) ||
!search.enabledProviders.every(isSearchProvider) ||
!isRecord(background) ||
(background.type !== "gradient" && background.type !== "image") ||
(background.url !== null && typeof background.url !== "string") ||
typeof background.blur !== "number" ||
!Number.isFinite(background.blur) ||
typeof background.overlay !== "number" ||
!Number.isFinite(background.overlay) ||
!isRecord(sidebar) ||
typeof sidebar.defaultCollapsed !== "boolean"
) {
invalidSettings()
}
// JSON 是外部数据边界;在模块加载时完整校验,避免强制断言掩盖配置拼写或类型错误。
return {
siteName: value.siteName,
theme: value.theme,
search: {
defaultProvider: search.defaultProvider,
enabledProviders: [...search.enabledProviders],
},
background: {
type: background.type,
url: background.url,
blur: background.blur,
overlay: background.overlay,
},
sidebar: {
defaultCollapsed: sidebar.defaultCollapsed,
},
}
}
const settings = parseSettings(settingsData)
export function getSettings(): AppSettings {
return {
...settings,
search: {
...settings.search,
enabledProviders: [...settings.search.enabledProviders],
},
background: { ...settings.background },
sidebar: { ...settings.sidebar },
}
}
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest"
import { cleanup } from "@testing-library/react"
import { afterEach } from "vitest"
afterEach(() => {
cleanup()
})
@@ -0,0 +1,21 @@
export type Category = {
id: string
title: string
description: string
icon: string
sort: number
}
export type NavLink = {
id: string
title: string
description: string
url: string
icon?: string
categoryId: string
pinned: boolean
/** 首页顺序与分类内顺序职责不同,避免复用 sort 造成顺序冲突。 */
pinnedSort?: number
keywords: string[]
sort: number
}
+12
View File
@@ -0,0 +1,12 @@
import type { NavLink } from "@/types/navigation"
export const SEARCH_PROVIDERS = ["google", "bing", "github", "local"] as const
export type SearchProvider = (typeof SEARCH_PROVIDERS)[number]
export type ExternalSearchProvider = Exclude<SearchProvider, "local">
export type LocalSearchResult = {
link: NavLink
categoryTitle: string
score: number
}
@@ -0,0 +1,19 @@
import type { SearchProvider } from "@/types/search"
export type AppSettings = {
siteName: string
theme: "light-glass"
search: {
defaultProvider: SearchProvider
enabledProviders: SearchProvider[]
}
background: {
type: "gradient" | "image"
url: string | null
blur: number
overlay: number
}
sidebar: {
defaultCollapsed: boolean
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+15
View File
@@ -0,0 +1,15 @@
import react from "@vitejs/plugin-react"
import { defineConfig } from "vitest/config"
export default defineConfig({
plugins: [react()],
resolve: {
tsconfigPaths: true,
},
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
clearMocks: true,
css: true,
},
})