Merge branch 'main' of gitea: 汇合本地与 Gitea 双方提交
@@ -24,6 +24,14 @@ node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
*.tsbuildinfo
|
||||
.next/
|
||||
out/
|
||||
coverage/
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# ---- Claude Code ----
|
||||
.claude/worktrees/
|
||||
|
||||
# ---- IDE / 编辑器 ----
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.next/
|
||||
out/
|
||||
node_modules/
|
||||
coverage/
|
||||
next-env.d.ts
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
color: #2563EB;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #2563eb;
|
||||
```
|
||||
|
||||
图标和文字同时高亮。
|
||||
@@ -276,7 +276,7 @@ background: rgba(255, 255, 255, 0.56);
|
||||
backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.48);
|
||||
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
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: #0F172A;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
```
|
||||
|
||||
@@ -355,7 +355,7 @@ font-size: 44px;
|
||||
```css
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: #64748B;
|
||||
color: #64748b;
|
||||
margin-top: 12px;
|
||||
```
|
||||
|
||||
@@ -402,12 +402,12 @@ AI 工具:精选常用 AI 工具与服务,辅助创作、开发和效率提
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0F172A;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: 14px;
|
||||
color: #94A3B8;
|
||||
color: #94a3b8;
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
@@ -640,7 +640,7 @@ height: 44px;
|
||||
```css
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #0F172A;
|
||||
color: #0f172a;
|
||||
```
|
||||
|
||||
工具描述:
|
||||
@@ -648,7 +648,7 @@ color: #0F172A;
|
||||
```css
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #64748B;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
```
|
||||
|
||||
@@ -704,6 +704,7 @@ JavaScript 包管理器
|
||||
"icon": "si:github",
|
||||
"categoryId": "dev",
|
||||
"pinned": true,
|
||||
"pinnedSort": 10,
|
||||
"keywords": ["代码", "仓库", "开源", "git"],
|
||||
"sort": 10
|
||||
},
|
||||
@@ -803,6 +804,7 @@ JavaScript 包管理器
|
||||
"icon": "si:vercel",
|
||||
"categoryId": "dev",
|
||||
"pinned": true,
|
||||
"pinnedSort": 70,
|
||||
"keywords": ["部署", "前端", "托管"],
|
||||
"sort": 100
|
||||
},
|
||||
@@ -858,6 +860,7 @@ JavaScript 包管理器
|
||||
"icon": "si:figma",
|
||||
"categoryId": "dev",
|
||||
"pinned": true,
|
||||
"pinnedSort": 50,
|
||||
"keywords": ["设计", "ui", "协作"],
|
||||
"sort": 150
|
||||
}
|
||||
@@ -897,14 +900,14 @@ window.open(url, "_blank", "noopener,noreferrer")
|
||||
```css
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #64748B;
|
||||
color: #64748b;
|
||||
opacity: 0.75;
|
||||
```
|
||||
|
||||
Hover 时:
|
||||
|
||||
```css
|
||||
color: #2563EB;
|
||||
color: #2563eb;
|
||||
opacity: 1;
|
||||
```
|
||||
|
||||
@@ -992,7 +995,7 @@ height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748B;
|
||||
color: #64748b;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1147,11 +1150,12 @@ export type Category = {
|
||||
export type NavLink = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
description: string
|
||||
url: string
|
||||
icon: string
|
||||
icon?: string
|
||||
categoryId: string
|
||||
pinned: boolean
|
||||
pinnedSort?: number
|
||||
keywords: string[]
|
||||
sort: number
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
> [Polaris 首页设计文档](./Polaris%20首页设计文档.md) —— 首页布局、搜索交互、视觉规范
|
||||
> [Polaris 分类页设计文档](./Polaris%20分类页设计文档.md) —— 分类页布局、卡片、状态设计
|
||||
>
|
||||
> 配套资产:`logo.svg`(占位 Logo,后续替换)、`Polaris页面设计图.png`
|
||||
> 配套资产:[占位 Logo](../../public/brand/logo.svg)(后续替换)、[Polaris 页面设计图](../assets/Polaris页面设计图.png)
|
||||
|
||||
## 1. 项目名称
|
||||
|
||||
@@ -54,22 +54,22 @@ Polaris 不是后台管理系统,也不是信息密集型 Dashboard。
|
||||
|
||||
以下决策已于 2026-08-21 确认,作为实施基线:
|
||||
|
||||
| # | 决策项 | 结论 |
|
||||
|---|--------|------|
|
||||
| 1 | 技术栈 | 以本文档为准:Next.js + TypeScript + Tailwind CSS + shadcn/ui + lucide-react(早期 README 中 Astro 设想作废) |
|
||||
| 2 | 渲染策略 | 静态导出(`output: 'export'`),时钟/搜索/侧边栏折叠为 client 岛屿 |
|
||||
| 3 | 包管理器 | pnpm |
|
||||
| 4 | 项目位置 | `ts/web/nextjs/polaris/` |
|
||||
| 5 | Logo | 现有 `logo.svg` 仅作占位,后续替换,不投入设计精力 |
|
||||
| 6 | 数据载体 | 本地 JSON 数据文件(`links.json` / `categories.json` / `settings.json`),结构以 Vitest 校验测试兜底 |
|
||||
| 7 | 第一版数据范围 | 「开发工具」15 条 + 首页常用链接 8 条(跨分类条目归入真实分类,见首页设计文档 8.1);设计资源、学习资料、生活服务三个分类展示空状态 |
|
||||
| 8 | 默认背景 | CSS 低饱和青蓝渐变,不依赖图片资产;`settings.background.type` 预留 `image` |
|
||||
| 9 | 深色模式 | 第一版不做;CSS 变量按双主题友好组织,后续以 `[data-theme="dark"]` 覆盖 |
|
||||
| 10 | 移动端 | 第一版不适配,仅桌面端 |
|
||||
| 11 | 快捷键 | Ctrl+K(macOS ⌘K)聚焦搜索框,仅首页生效;提示按平台检测显示 |
|
||||
| 12 | 图标方案 | 四级 fallback:自定义图标 → Simple Icons / 本地图标库 → favicon 自动抓取 → 文字占位(见 6.4) |
|
||||
| 13 | 后端数据库 | PostgreSQL(后续阶段);表结构不锁死 PG 特有语法(JSON 存通用 TEXT) |
|
||||
| 14 | 搜索行为 | 按模式严格分离:选中哪个引擎就只用哪个引擎搜索(回车直跳);仅 Local 模式显示本地结果下拉 |
|
||||
| # | 决策项 | 结论 |
|
||||
| --- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | 技术栈 | 以本文档为准:Next.js + TypeScript + Tailwind CSS + shadcn/ui + lucide-react(早期 README 中 Astro 设想作废) |
|
||||
| 2 | 渲染策略 | 静态导出(`output: 'export'`),时钟/搜索/侧边栏折叠为 client 岛屿 |
|
||||
| 3 | 包管理器 | pnpm |
|
||||
| 4 | 项目位置 | `ts/web/nextjs/polaris/` |
|
||||
| 5 | Logo | 现有 `logo.svg` 仅作占位,后续替换,不投入设计精力 |
|
||||
| 6 | 数据载体 | 本地 JSON 数据文件(`links.json` / `categories.json` / `settings.json`),结构以 Vitest 校验测试兜底 |
|
||||
| 7 | 第一版数据范围 | 「开发工具」15 条 + 首页常用链接 8 条(跨分类条目归入真实分类,见首页设计文档 8.1);设计资源、学习资料、生活服务三个分类展示空状态 |
|
||||
| 8 | 默认背景 | CSS 低饱和青蓝渐变,不依赖图片资产;`settings.background.type` 预留 `image` |
|
||||
| 9 | 深色模式 | 第一版不做;CSS 变量按双主题友好组织,后续以 `[data-theme="dark"]` 覆盖 |
|
||||
| 10 | 移动端 | 第一版不适配,仅桌面端 |
|
||||
| 11 | 快捷键 | Ctrl+K(macOS ⌘K)聚焦搜索框,仅首页生效;提示按平台检测显示 |
|
||||
| 12 | 图标方案 | 四级 fallback:自定义图标 → Simple Icons / 本地图标库 → favicon 自动抓取 → 文字占位(见 6.4) |
|
||||
| 13 | 后端数据库 | PostgreSQL(后续阶段);表结构不锁死 PG 特有语法(JSON 存通用 TEXT) |
|
||||
| 14 | 搜索行为 | 按模式严格分离:选中哪个引擎就只用哪个引擎搜索(回车直跳);仅 Local 模式显示本地结果下拉 |
|
||||
|
||||
---
|
||||
|
||||
@@ -189,6 +189,7 @@ sort: 排序值
|
||||
icon: "si:github",
|
||||
categoryId: "dev",
|
||||
pinned: true,
|
||||
pinnedSort: 10,
|
||||
keywords: ["代码", "仓库", "开源", "git"],
|
||||
sort: 10
|
||||
}
|
||||
@@ -204,8 +205,9 @@ url: 链接地址
|
||||
icon: 图标引用,格式见 6.4
|
||||
categoryId: 所属分类
|
||||
pinned: 是否展示在首页常用链接区域
|
||||
pinnedSort: 首页常用链接顺序(仅 pinned 为 true 时必填)
|
||||
keywords: 本地搜索关键词
|
||||
sort: 排序值
|
||||
sort: 分类内排序值
|
||||
```
|
||||
|
||||
### 6.3 设置数据
|
||||
@@ -309,6 +311,7 @@ CREATE TABLE links (
|
||||
icon TEXT,
|
||||
category_id TEXT NOT NULL,
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
pinned_sort INTEGER,
|
||||
keywords TEXT NOT NULL DEFAULT '[]', -- JSON 数组字符串
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -4,7 +4,7 @@
|
||||
> [Polaris 项目说明文档](./Polaris%20项目说明文档.md) —— 项目定位、技术选型、数据结构、开发阶段
|
||||
> [Polaris 分类页设计文档](./Polaris%20分类页设计文档.md) —— 分类页布局与交互
|
||||
>
|
||||
> 配套设计图:`Polaris页面设计图.png`(示意图,视觉细节以本文档文字规范为准)
|
||||
> 配套设计图:[Polaris 页面设计图](../assets/Polaris页面设计图.png)(示意图,视觉细节以本文档文字规范为准)
|
||||
|
||||
## 1. 页面定位
|
||||
|
||||
@@ -200,7 +200,7 @@ AI 工具
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
backdrop-filter: blur(24px);
|
||||
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;
|
||||
```
|
||||
|
||||
@@ -242,14 +242,14 @@ bottom: 20px;
|
||||
```css
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
color: #0F172A;
|
||||
color: #0f172a;
|
||||
```
|
||||
|
||||
日期样式:
|
||||
|
||||
```css
|
||||
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 分布于各分类,归入真实分类):
|
||||
|
||||
@@ -573,7 +573,7 @@ background: rgba(255, 255, 255, 0.62);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.56);
|
||||
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
|
||||
:root {
|
||||
--color-text-primary: #0F172A;
|
||||
--color-text-secondary: #64748B;
|
||||
--color-text-muted: #94A3B8;
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-text-muted: #94a3b8;
|
||||
|
||||
--color-primary: #3B82F6;
|
||||
--color-primary-hover: #2563EB;
|
||||
--color-primary-soft: rgba(59, 130, 246, 0.10);
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-hover: #2563eb;
|
||||
--color-primary-soft: rgba(59, 130, 246, 0.1);
|
||||
|
||||
--color-surface-sidebar: rgba(255, 255, 255, 0.68);
|
||||
--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-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);
|
||||
}
|
||||
```
|
||||
@@ -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"]),
|
||||
])
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/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.
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "polaris",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.24.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"verify:export": "node scripts/verify-static-export.mjs",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Next.js Windows 静态导出补丁
|
||||
|
||||
## 原因与范围
|
||||
|
||||
`next@16.3.3` 的静态导出器将 `path.relative()` 产生的文件系统路径直接用于 RSC segment 文件名编码。Windows 的反斜杠不会被编码器转换为点号,导致浏览器请求扁平文件名、实际文件却落在嵌套目录。
|
||||
|
||||
- 上游问题:<https://github.com/vercel/next.js/issues/85374>
|
||||
- Windows 根因复现:<https://github.com/vercel/next.js/issues/92339>
|
||||
- 2026-09-13 核对:`16.3.5` 的相应导出逻辑仍未包含路径标准化。
|
||||
|
||||
补丁只修改导出器的 CommonJS / ESM 两份入口,在文件名编码前用系统路径分隔符拆分并以 `/` 连接。读取源文件仍使用原始文件系统路径。Linux/macOS 的 `/` 路径保持不变,浏览器运行时代码不变。
|
||||
|
||||
## 安装与验证
|
||||
|
||||
补丁通过 `pnpm-workspace.yaml` 的精确版本 `patchedDependencies` 和锁文件登记,正常安装会自动应用,无需手动编辑 `node_modules`。
|
||||
|
||||
```bash
|
||||
corepack pnpm install --frozen-lockfile
|
||||
corepack pnpm build
|
||||
corepack pnpm verify:export
|
||||
```
|
||||
|
||||
`verify:export` 只读取产物:检查首页、全部分类页和 404,并将每个源 RSC segment 与浏览器所需扁平路径下的导出文件逐字节比较。它不会复制、移动或重命名 `out/` 文件。部署前还需在真实静态服务器上验证预取和客户端导航。
|
||||
|
||||
## 后续升级
|
||||
|
||||
补丁仅绑定 `next@16.3.3`,不会自动迁移到其他版本。升级 Next.js 时必须重新检查上游导出实现、运行构建与 `verify:export`,再进行静态浏览器验收;不能仅以 `next build` 成功判断问题已修复。
|
||||
|
||||
上游修复并验收通过后,使用 pnpm 的补丁移除命令清理对应登记和补丁文件,更新锁文件,保留产物回归检查。
|
||||
@@ -0,0 +1,28 @@
|
||||
diff --git a/dist/esm/export/index.js b/dist/esm/export/index.js
|
||||
index 6c35f4f47f704caea869a4f89721e0fc71f7f458..4c4e25fec3d1aecb439cea4994f8747ea68df0fe 100644
|
||||
--- a/dist/esm/export/index.js
|
||||
+++ b/dist/esm/export/index.js
|
||||
@@ -717,7 +717,8 @@ async function exportAppImpl(dir, options, span, staticWorker) {
|
||||
const segmentsDirDest = join(outDir, unnormalizedRoute);
|
||||
const segmentPaths = await collectSegmentPaths(segmentsDir);
|
||||
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
|
||||
- const segmentPath = '/' + segmentFileSrc.slice(0, -RSC_SEGMENT_SUFFIX.length);
|
||||
+ // Windows 文件系统路径先转为 URL 路径,避免 RSC 文件被导出为嵌套目录。
|
||||
+ const segmentPath = '/' + segmentFileSrc.split(sep).join('/').slice(0, -RSC_SEGMENT_SUFFIX.length);
|
||||
const segmentFilename = convertSegmentPathToStaticExportFilename(segmentPath);
|
||||
const segmentFileDest = join(segmentsDirDest, segmentFilename);
|
||||
await fs.mkdir(dirname(segmentFileDest), {
|
||||
diff --git a/dist/export/index.js b/dist/export/index.js
|
||||
index a19941bec99b5c5673834872eaf8a95deb584a34..b7492b7ba76823380b0013bd1c08527388c82ed7 100644
|
||||
--- a/dist/export/index.js
|
||||
+++ b/dist/export/index.js
|
||||
@@ -785,7 +785,8 @@ async function exportAppImpl(dir, options, span, staticWorker) {
|
||||
const segmentsDirDest = (0, _path.join)(outDir, unnormalizedRoute);
|
||||
const segmentPaths = await collectSegmentPaths(segmentsDir);
|
||||
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
|
||||
- const segmentPath = '/' + segmentFileSrc.slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
|
||||
+ // Windows 文件系统路径先转为 URL 路径,避免 RSC 文件被导出为嵌套目录。
|
||||
+ const segmentPath = '/' + segmentFileSrc.split(_path.sep).join('/').slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
|
||||
const segmentFilename = (0, _segmentvalueencoding.convertSegmentPathToStaticExportFilename)(segmentPath);
|
||||
const segmentFileDest = (0, _path.join)(segmentsDirDest, segmentFilename);
|
||||
await _fs.promises.mkdir((0, _path.dirname)(segmentFileDest), {
|
||||
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
- "."
|
||||
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
|
||||
patchedDependencies:
|
||||
next@16.3.3: patches/next@16.3.3.patch
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788674931363" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1253" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M340.5824 70.109867L102.536533 0.682667v851.217066L340.650667 643.345067V70.109867zM102.536533 851.7632l238.045867 171.6224 580.881067-340.923733V411.784533L102.536533 851.831467z" fill="#409EFF" p-id="1254"></path><path d="M409.463467 255.3856l113.732266 238.933333 138.8544 56.866134 259.413334-139.400534-506.0608-156.330666z" fill="#409EFF" p-id="1255"></path></svg>
|
||||
|
After Width: | Height: | Size: 703 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788674911609" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1089" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M950.930286 512q0 143.433143-83.748571 257.974857t-216.283429 158.573714q-15.433143 2.852571-22.601143-4.022857t-7.168-17.115429l0-120.539429q0-55.442286-29.696-81.115429 32.548571-3.437714 58.587429-10.313143t53.686857-22.308571 46.299429-38.034286 30.281143-59.977143 11.702857-86.016q0-69.12-45.129143-117.686857 21.138286-52.004571-4.534857-116.589714-16.018286-5.12-46.299429 6.290286t-52.589714 25.161143l-21.723429 13.677714q-53.174857-14.848-109.714286-14.848t-109.714286 14.848q-9.142857-6.290286-24.283429-15.433143t-47.689143-22.016-49.152-7.68q-25.161143 64.585143-4.022857 116.589714-45.129143 48.566857-45.129143 117.686857 0 48.566857 11.702857 85.723429t29.988571 59.977143 46.006857 38.253714 53.686857 22.308571 58.587429 10.313143q-22.820571 20.553143-28.013714 58.88-11.995429 5.705143-25.746286 8.557714t-32.548571 2.852571-37.449143-12.288-31.744-35.693714q-10.825143-18.285714-27.721143-29.696t-28.306286-13.677714l-11.410286-1.682286q-11.995429 0-16.603429 2.56t-2.852571 6.582857 5.12 7.972571 7.460571 6.875429l4.022857 2.852571q12.580571 5.705143 24.868571 21.723429t17.993143 29.110857l5.705143 13.165714q7.460571 21.723429 25.161143 35.108571t38.253714 17.115429 39.716571 4.022857 31.744-1.974857l13.165714-2.267429q0 21.723429 0.292571 50.834286t0.292571 30.866286q0 10.313143-7.460571 17.115429t-22.820571 4.022857q-132.534857-44.032-216.283429-158.573714t-83.748571-257.974857q0-119.442286 58.88-220.306286t159.744-159.744 220.306286-58.88 220.306286 58.88 159.744 159.744 58.88 220.306286z" fill="#444444" p-id="1090"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788674935631" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1414" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M222.087529 515.915294c0-32.527059 5.481412-63.698824 15.179295-92.973176l-170.285177-128.90353A496.941176 496.941176 0 0 0 15.058824 515.945412c0 79.721412 18.672941 154.955294 51.802352 221.726117l170.255059-129.114353a293.767529 293.767529 0 0 1-15.028706-92.611764" fill="#FBBC05" p-id="1415"></path><path d="M521.246118 219.979294a294.460235 294.460235 0 0 1 186.337882 65.957647l147.245176-145.648941C765.108706 62.885647 650.089412 15.058824 521.246118 15.058824 321.204706 15.058824 149.293176 128.421647 66.981647 294.038588l170.405647 128.90353c39.243294-118.061176 151.100235-202.992941 283.919059-202.992942" fill="#EA4335" p-id="1416"></path><path d="M523.414588 805.315765c-133.511529 0-245.940706-84.359529-285.394823-201.728l-171.188706 128.12047C149.564235 896.301176 322.349176 1008.941176 523.444706 1008.941176c124.024471 0 242.447059-43.128471 331.38447-124.054588l-162.514823-123.090823c-45.808941 28.310588-103.574588 43.550118-168.929882 43.550117" fill="#34A853" p-id="1417"></path><path d="M1008.941176 511.216941c0-29.394824-4.638118-61.108706-11.565176-90.503529H523.354353v192.301176h272.835765c-13.613176 65.596235-50.718118 115.983059-103.875765 148.781177l162.484706 123.090823c93.394824-84.901647 154.142118-211.425882 154.142117-373.669647" fill="#4285F4" p-id="1418"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788675424920" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1788" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M468.598154 0a156.750769 156.750769 0 0 1 124.849231 62.464l90.820923 121.304615h183.689846A156.593231 156.593231 0 0 1 1024 340.598154v526.808615A156.593231 156.593231 0 0 1 867.879385 1024H156.120615A156.593231 156.593231 0 0 1 0 867.328V156.672C0 70.262154 69.868308 0.236308 156.199385 0z m399.36 265.688615H81.447385v601.639385c0 41.353846 33.398154 74.830769 74.830769 74.988308h711.68a74.988308 74.988308 0 0 0 74.752-74.988308V340.519385a74.909538 74.909538 0 0 0-74.752-74.83077zM477.971692 315.076923a241.664 241.664 0 0 1 241.664 241.664c0 47.891692-14.414769 92.317538-38.518154 129.969231l91.372308 91.372308a51.751385 51.751385 0 1 1-73.255384 73.255384l-91.372308-91.372308a240.009846 240.009846 0 0 1-129.969231 38.360616 241.664 241.664 0 1 1 0-483.249231z m0 69.001846a172.583385 172.583385 0 1 0 0 345.245539 172.583385 172.583385 0 0 0 0-345.245539z m-9.452307-302.395077H156.041846a74.909538 74.909538 0 0 0-74.673231 74.988308v27.096615h500.972308l-54.272-72.152615a74.988308 74.988308 0 0 0-59.707077-29.932308z" p-id="1789" fill="#e98f36"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile, readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const projectDir = fileURLToPath(new URL("../", import.meta.url))
|
||||
const appDir = path.join(projectDir, ".next/server/app")
|
||||
const outDir = path.join(projectDir, "out")
|
||||
const categories = JSON.parse(
|
||||
await readFile(path.join(projectDir, "src/data/categories.json"), "utf8"),
|
||||
)
|
||||
const routes = ["/", ...categories.map(({ id }) => `/categories/${id}`)]
|
||||
let checkedSegments = 0
|
||||
|
||||
// 只读构建产物:验证浏览器需要的扁平 URL,绝不修补或复制 out/ 文件。
|
||||
for (const route of routes) {
|
||||
const routeDir = path.join(outDir, route)
|
||||
assert((await readFile(path.join(routeDir, "index.html"))).length > 0)
|
||||
assert((await readFile(path.join(routeDir, "index.txt"))).length > 0)
|
||||
|
||||
const segmentsDir = path.join(
|
||||
appDir,
|
||||
`${route === "/" ? "index" : route}.segments`,
|
||||
)
|
||||
const files = (await readdir(segmentsDir, { recursive: true })).filter(
|
||||
(file) => file.endsWith(".segment.rsc"),
|
||||
)
|
||||
assert(files.length > 0, `${route} 缺少 RSC segment 构建产物`)
|
||||
|
||||
for (const file of files) {
|
||||
const segment = file
|
||||
.replaceAll("\\", "/")
|
||||
.slice(0, -".segment.rsc".length)
|
||||
.replaceAll("/", ".")
|
||||
const filename = `__next.${segment}.txt`
|
||||
const source = await readFile(path.join(segmentsDir, file))
|
||||
const exported = await readFile(path.join(routeDir, filename))
|
||||
assert.deepEqual(exported, source, `${route}/${filename} 内容不一致`)
|
||||
checkedSegments += 1
|
||||
}
|
||||
}
|
||||
|
||||
assert((await readFile(path.join(outDir, "404.html"))).length > 0)
|
||||
console.log(
|
||||
`静态导出检查通过:${routes.length} 个页面、${checkedSegments} 个 RSC 文件及 404.html。`,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import CategoryPage, { dynamicParams, generateStaticParams } from "./page"
|
||||
|
||||
describe("CategoryPage", () => {
|
||||
it("为全部 7 个分类生成静态参数并关闭运行时动态参数", () => {
|
||||
expect(generateStaticParams()).toEqual([
|
||||
{ categoryId: "ai" },
|
||||
{ categoryId: "dev" },
|
||||
{ categoryId: "design" },
|
||||
{ categoryId: "learning" },
|
||||
{ categoryId: "efficiency" },
|
||||
{ categoryId: "media" },
|
||||
{ categoryId: "life" },
|
||||
])
|
||||
expect(dynamicParams).toBe(false)
|
||||
})
|
||||
|
||||
it("通过 service 组合开发工具分类页", async () => {
|
||||
const page = await CategoryPage({
|
||||
params: Promise.resolve({ categoryId: "dev" }),
|
||||
})
|
||||
const { container } = render(page)
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "开发工具" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("共 15 个")).toBeInTheDocument()
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(15)
|
||||
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
)
|
||||
expect(screen.queryByRole("searchbox")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("通过同一路由组合空分类页", async () => {
|
||||
const page = await CategoryPage({
|
||||
params: Promise.resolve({ categoryId: "life" }),
|
||||
})
|
||||
render(page)
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "生活服务" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("共 0 个")).toBeInTheDocument()
|
||||
expect(screen.getByText("暂无工具")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { CategoryContent } from "@/features/category/components/category-content"
|
||||
import { AppShell } from "@/features/navigation/components/app-shell"
|
||||
import { getCategories, getCategoryById } from "@/services/categories"
|
||||
import { getLinksByCategory } from "@/services/links"
|
||||
import { getSettings } from "@/services/settings"
|
||||
|
||||
type CategoryPageProps = {
|
||||
params: Promise<{ categoryId: string }>
|
||||
}
|
||||
|
||||
// 静态导出不支持运行时补生成动态路径,未知分类统一返回 404。
|
||||
export const dynamicParams = false
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getCategories().map((category) => ({
|
||||
categoryId: category.id,
|
||||
}))
|
||||
}
|
||||
|
||||
export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
const { categoryId } = await params
|
||||
const category = getCategoryById(categoryId)
|
||||
|
||||
if (!category) notFound()
|
||||
|
||||
const categories = getCategories()
|
||||
const links = getLinksByCategory(category.id)
|
||||
const settings = getSettings()
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
categories={categories}
|
||||
siteName={settings.siteName}
|
||||
defaultCollapsed={settings.sidebar.defaultCollapsed}
|
||||
activeCategoryId={category.id}
|
||||
>
|
||||
<CategoryContent category={category} links={links} />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
@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: linear-gradient(
|
||||
180deg,
|
||||
rgba(235, 243, 251, 0.52),
|
||||
rgba(204, 220, 238, 0.34)
|
||||
);
|
||||
--surface-search: linear-gradient(
|
||||
135deg,
|
||||
rgba(240, 246, 252, 0.56),
|
||||
rgba(210, 225, 241, 0.4)
|
||||
);
|
||||
--surface-card: linear-gradient(
|
||||
135deg,
|
||||
rgba(231, 240, 249, 0.43),
|
||||
rgba(198, 216, 236, 0.32)
|
||||
);
|
||||
--surface-card-hover: linear-gradient(
|
||||
135deg,
|
||||
rgba(242, 248, 253, 0.6),
|
||||
rgba(216, 230, 244, 0.48)
|
||||
);
|
||||
--border-glass: rgba(255, 255, 255, 0.38);
|
||||
--border-subtle: rgba(15, 23, 42, 0.08);
|
||||
--shadow-soft: inset 0 1px 0 rgba(255, 255, 255, 0.34);
|
||||
--shadow-medium:
|
||||
0 22px 58px rgba(4, 12, 34, 0.17), inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
--shadow-panel:
|
||||
0 28px 72px rgba(4, 12, 34, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
||||
--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-color: #0b102b;
|
||||
background-image: url("/backgrounds/test.jpg");
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
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(3, 7, 28, 0.2) 0%,
|
||||
rgba(9, 18, 54, 0.07) 48%,
|
||||
rgba(216, 230, 244, 0.08) 100%
|
||||
);
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
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(16px) saturate(134%);
|
||||
backdrop-filter: blur(16px) saturate(134%);
|
||||
}
|
||||
|
||||
.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(18px) saturate(132%);
|
||||
backdrop-filter: blur(18px) saturate(132%);
|
||||
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.26);
|
||||
}
|
||||
|
||||
.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:hover {
|
||||
color: var(--primary-hover);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.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: rgba(248, 250, 252, 0.98);
|
||||
font-size: clamp(44px, 4.2vw, 56px);
|
||||
font-weight: 700;
|
||||
line-height: 1.16;
|
||||
letter-spacing: -0.035em;
|
||||
text-shadow: 0 3px 24px rgba(2, 6, 23, 0.48);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.clock-greeting__date {
|
||||
margin: 12px 0 0;
|
||||
color: rgba(241, 245, 249, 0.86);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
text-shadow: 0 2px 16px rgba(2, 6, 23, 0.42);
|
||||
}
|
||||
|
||||
.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(18px) saturate(136%);
|
||||
backdrop-filter: blur(18px) saturate(136%);
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
background 180ms ease;
|
||||
}
|
||||
|
||||
.search-box__form:focus-within {
|
||||
border-color: rgba(191, 219, 254, 0.46);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(245, 249, 253, 0.66),
|
||||
rgba(218, 231, 245, 0.52)
|
||||
);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(147, 197, 253, 0.12),
|
||||
var(--shadow-medium);
|
||||
}
|
||||
|
||||
.search-provider-menu__trigger {
|
||||
width: 48px;
|
||||
min-width: 48px;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
margin-left: 10px;
|
||||
padding: 0;
|
||||
color: #334155;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.search-provider-menu__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.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: rgba(51, 65, 85, 0.58);
|
||||
}
|
||||
|
||||
.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: rgba(51, 65, 85, 0.62);
|
||||
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: rgba(248, 250, 252, 0.96);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
text-shadow: 0 2px 14px rgba(2, 6, 23, 0.44);
|
||||
}
|
||||
|
||||
.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(16px) saturate(134%);
|
||||
backdrop-filter: blur(16px) saturate(134%);
|
||||
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.54);
|
||||
background: var(--surface-card-hover);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.quick-link-card > span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-page {
|
||||
min-height: 100svh;
|
||||
padding: 20px 20px 20px 0;
|
||||
}
|
||||
|
||||
.category-page__panel {
|
||||
min-height: calc(100svh - 40px);
|
||||
padding: 48px clamp(32px, 4vw, 64px) 56px;
|
||||
background: var(--surface-sidebar);
|
||||
box-shadow: var(--shadow-panel);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(132%);
|
||||
backdrop-filter: blur(18px) saturate(132%);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.category-header__title {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: clamp(36px, 3.4vw, 44px);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.category-header__description {
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.category-tools {
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.category-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.category-section-header__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.category-section-header__count {
|
||||
padding: 4px 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 96px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: 18px;
|
||||
background: var(--surface-card);
|
||||
box-shadow: var(--shadow-soft);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(134%);
|
||||
backdrop-filter: blur(16px) saturate(134%);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
background 180ms ease,
|
||||
border-color 180ms ease;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 255, 255, 0.54);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
|
||||
.tool-card__icon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tool-card__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-card__title,
|
||||
.tool-card__description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-card__title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tool-card__description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tool-card__external {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.72;
|
||||
transition:
|
||||
color 180ms ease,
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.tool-card:hover .tool-card__external {
|
||||
color: var(--primary-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px 24px;
|
||||
}
|
||||
|
||||
.tool-grid__item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-empty-state {
|
||||
display: flex;
|
||||
min-height: 280px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-empty-state > svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.category-empty-state__title {
|
||||
margin: 16px 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.category-empty-state__description {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.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 (min-width: 1280px) {
|
||||
.tool-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,80 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getCategoryById } from "@/services/categories"
|
||||
import { getLinksByCategory } from "@/services/links"
|
||||
|
||||
import { CategoryContent } from "./category-content"
|
||||
|
||||
describe("CategoryContent", () => {
|
||||
it("展示开发工具标题、描述、数量和 15 张卡片", () => {
|
||||
const category = getCategoryById("dev")
|
||||
if (!category) throw new Error("测试数据缺少 dev 分类")
|
||||
|
||||
const { container } = render(
|
||||
<CategoryContent
|
||||
category={category}
|
||||
links={getLinksByCategory(category.id)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "开发工具" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(category.description)).toBeInTheDocument()
|
||||
expect(screen.getByText("共 15 个")).toBeInTheDocument()
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(15)
|
||||
expect(screen.queryByText("暂无工具")).not.toBeInTheDocument()
|
||||
|
||||
const toolLinks = screen
|
||||
.getByRole("list", { name: "工具列表" })
|
||||
.querySelectorAll("a")
|
||||
expect(toolLinks).toHaveLength(15)
|
||||
for (const link of toolLinks) {
|
||||
expect(link).toHaveAttribute("target", "_blank")
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer")
|
||||
}
|
||||
})
|
||||
|
||||
it("为空分类展示计数和纯浏览空状态", () => {
|
||||
const category = getCategoryById("design")
|
||||
if (!category) throw new Error("测试数据缺少 design 分类")
|
||||
|
||||
render(
|
||||
<CategoryContent
|
||||
category={category}
|
||||
links={getLinksByCategory(category.id)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText("共 0 个")).toBeInTheDocument()
|
||||
expect(screen.getByText("暂无工具")).toBeInTheDocument()
|
||||
expect(screen.getByText("这个分类下还没有添加链接。")).toBeInTheDocument()
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole("list", { name: "工具列表" }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
["ai", 1],
|
||||
["dev", 15],
|
||||
["design", 0],
|
||||
["learning", 0],
|
||||
["efficiency", 2],
|
||||
["media", 2],
|
||||
["life", 0],
|
||||
] as const)("%s 分类展示正确数量", (categoryId, count) => {
|
||||
const category = getCategoryById(categoryId)
|
||||
if (!category) throw new Error(`测试数据缺少 ${categoryId} 分类`)
|
||||
|
||||
render(
|
||||
<CategoryContent
|
||||
category={category}
|
||||
links={getLinksByCategory(category.id)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(`共 ${count} 个`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { GlassPanel } from "@/components/shared/glass-panel"
|
||||
import { CategoryEmptyState } from "@/features/category/components/category-empty-state"
|
||||
import { CategoryHeader } from "@/features/category/components/category-header"
|
||||
import { CategorySectionHeader } from "@/features/category/components/category-section-header"
|
||||
import { ToolGrid } from "@/features/category/components/tool-grid"
|
||||
import type { Category, NavLink } from "@/types/navigation"
|
||||
|
||||
type CategoryContentProps = {
|
||||
category: Category
|
||||
links: NavLink[]
|
||||
}
|
||||
|
||||
export function CategoryContent({ category, links }: CategoryContentProps) {
|
||||
return (
|
||||
<main className="category-page">
|
||||
<GlassPanel className="category-page__panel">
|
||||
<CategoryHeader category={category} />
|
||||
<section
|
||||
className="category-tools"
|
||||
aria-labelledby="category-tools-title"
|
||||
>
|
||||
<CategorySectionHeader count={links.length} />
|
||||
{links.length > 0 ? (
|
||||
<ToolGrid links={links} />
|
||||
) : (
|
||||
<CategoryEmptyState />
|
||||
)}
|
||||
</section>
|
||||
</GlassPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PackageOpen } from "lucide-react"
|
||||
|
||||
export function CategoryEmptyState() {
|
||||
return (
|
||||
<div className="category-empty-state">
|
||||
<PackageOpen aria-hidden="true" />
|
||||
<p className="category-empty-state__title">暂无工具</p>
|
||||
<p className="category-empty-state__description">
|
||||
这个分类下还没有添加链接。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Category } from "@/types/navigation"
|
||||
|
||||
type CategoryHeaderProps = {
|
||||
category: Category
|
||||
}
|
||||
|
||||
export function CategoryHeader({ category }: CategoryHeaderProps) {
|
||||
return (
|
||||
<header className="category-header">
|
||||
<h1 className="category-header__title">{category.title}</h1>
|
||||
<p className="category-header__description">{category.description}</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
type CategorySectionHeaderProps = {
|
||||
count: number
|
||||
}
|
||||
|
||||
export function CategorySectionHeader({ count }: CategorySectionHeaderProps) {
|
||||
return (
|
||||
<div className="category-section-header">
|
||||
<h2 id="category-tools-title" className="category-section-header__title">
|
||||
全部工具
|
||||
</h2>
|
||||
<span className="category-section-header__count">共 {count} 个</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import type { NavLink } from "@/types/navigation"
|
||||
|
||||
import { ToolCard } from "./tool-card"
|
||||
|
||||
const link: NavLink = {
|
||||
id: "github",
|
||||
title: "GitHub",
|
||||
description: "全球最大的代码托管平台",
|
||||
url: "https://github.com",
|
||||
icon: "si:github",
|
||||
categoryId: "dev",
|
||||
pinned: true,
|
||||
pinnedSort: 10,
|
||||
keywords: ["代码", "git"],
|
||||
sort: 10,
|
||||
}
|
||||
|
||||
describe("ToolCard", () => {
|
||||
it("展示名称与描述并使用安全的新标签页链接", () => {
|
||||
render(<ToolCard link={link} />)
|
||||
|
||||
const card = screen.getByRole("link", { name: /GitHub/ })
|
||||
expect(card).toHaveAttribute("href", "https://github.com")
|
||||
expect(card).toHaveAttribute("target", "_blank")
|
||||
expect(card).toHaveAttribute("rel", "noopener noreferrer")
|
||||
expect(screen.getByText("全球最大的代码托管平台")).toBeInTheDocument()
|
||||
expect(card.querySelector(".tool-card__external")).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ExternalLink } from "lucide-react"
|
||||
|
||||
import { SiteIcon } from "@/components/shared/site-icon"
|
||||
import type { NavLink } from "@/types/navigation"
|
||||
|
||||
type ToolCardProps = {
|
||||
link: NavLink
|
||||
}
|
||||
|
||||
export function ToolCard({ link }: ToolCardProps) {
|
||||
return (
|
||||
<a
|
||||
className="tool-card"
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<SiteIcon className="tool-card__icon" link={link} size={44} />
|
||||
<span className="tool-card__copy">
|
||||
<span className="tool-card__title">{link.title}</span>
|
||||
<span className="tool-card__description">{link.description}</span>
|
||||
</span>
|
||||
<ExternalLink className="tool-card__external" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ToolCard } from "@/features/category/components/tool-card"
|
||||
import type { NavLink } from "@/types/navigation"
|
||||
|
||||
type ToolGridProps = {
|
||||
links: NavLink[]
|
||||
}
|
||||
|
||||
export function ToolGrid({ links }: ToolGridProps) {
|
||||
return (
|
||||
<ul className="tool-grid" aria-label="工具列表">
|
||||
{links.map((link) => (
|
||||
<li key={link.id} className="tool-grid__item">
|
||||
<ToolCard link={link} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
</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,200 @@
|
||||
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("闭合态只显示当前图标,展开后显示 Provider 名称", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearchBox()
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: "选择搜索方式,当前为 Google",
|
||||
})
|
||||
|
||||
expect(trigger.textContent).toBe("")
|
||||
expect(trigger.querySelector("img")).toHaveAttribute(
|
||||
"src",
|
||||
"/icons/google.svg",
|
||||
)
|
||||
|
||||
await user.click(trigger)
|
||||
for (const [label, icon] of [
|
||||
["Google", "google"],
|
||||
["Bing", "bing"],
|
||||
["GitHub", "github"],
|
||||
["本地", "local"],
|
||||
] as const) {
|
||||
const item = screen.getByRole("menuitemradio", { name: label })
|
||||
expect(item.querySelector("img")).toHaveAttribute(
|
||||
"src",
|
||||
`/icons/${icon}.svg`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
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,88 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
|
||||
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_ICON_PATHS: Record<SearchProvider, string> = {
|
||||
google: "/icons/google.svg",
|
||||
bing: "/icons/bing.svg",
|
||||
github: "/icons/github.svg",
|
||||
local: "/icons/local.svg",
|
||||
}
|
||||
|
||||
function ProviderIcon({ provider }: { provider: SearchProvider }) {
|
||||
return (
|
||||
<Image
|
||||
className="search-provider-menu__icon"
|
||||
src={PROVIDER_ICON_PATHS[provider]}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SearchProviderMenuProps = {
|
||||
provider: SearchProvider
|
||||
enabledProviders: SearchProvider[]
|
||||
onProviderChange: (provider: SearchProvider) => void
|
||||
}
|
||||
|
||||
export function SearchProviderMenu({
|
||||
provider,
|
||||
enabledProviders,
|
||||
onProviderChange,
|
||||
}: SearchProviderMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="search-provider-menu__trigger"
|
||||
aria-label={`选择搜索方式,当前为 ${PROVIDER_LABELS[provider]}`}
|
||||
>
|
||||
<ProviderIcon provider={provider} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuRadioGroup
|
||||
value={provider}
|
||||
onValueChange={(value) => {
|
||||
if (isSearchProvider(value)) onProviderChange(value)
|
||||
}}
|
||||
>
|
||||
{enabledProviders.map((enabledProvider) => {
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
key={enabledProvider}
|
||||
value={enabledProvider}
|
||||
>
|
||||
<ProviderIcon provider={enabledProvider} />
|
||||
{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,201 @@
|
||||
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("link", { name: "首页" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
)
|
||||
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
|
||||
"href",
|
||||
"/categories/dev",
|
||||
)
|
||||
})
|
||||
|
||||
it("分类页只选中当前分类", () => {
|
||||
render(
|
||||
<AppShell
|
||||
categories={categories}
|
||||
siteName="Polaris"
|
||||
defaultCollapsed={false}
|
||||
activeCategoryId="dev"
|
||||
>
|
||||
<main>分类内容</main>
|
||||
</AppShell>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole("link", { name: "首页" })).not.toHaveAttribute(
|
||||
"aria-current",
|
||||
)
|
||||
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
)
|
||||
})
|
||||
|
||||
it("折叠状态仍保留分类链接的可访问名称", () => {
|
||||
render(
|
||||
<AppShell
|
||||
categories={categories}
|
||||
siteName="Polaris"
|
||||
defaultCollapsed={true}
|
||||
activeCategoryId="dev"
|
||||
>
|
||||
<main>分类内容</main>
|
||||
</AppShell>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
"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
|
||||
activeCategoryId?: string
|
||||
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,
|
||||
activeCategoryId,
|
||||
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}
|
||||
activeCategoryId={activeCategoryId}
|
||||
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,113 @@
|
||||
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
|
||||
activeCategoryId?: string
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
categories,
|
||||
siteName,
|
||||
collapsed,
|
||||
activeCategoryId,
|
||||
onToggle,
|
||||
}: SidebarProps) {
|
||||
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose
|
||||
const toggleLabel = collapsed ? "展开侧边栏" : "折叠侧边栏"
|
||||
const homeIsActive = activeCategoryId === undefined
|
||||
|
||||
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={cn(
|
||||
"sidebar__nav-item",
|
||||
homeIsActive && "sidebar__nav-item--active",
|
||||
)}
|
||||
href="/"
|
||||
aria-current={homeIsActive ? "page" : undefined}
|
||||
>
|
||||
<Home aria-hidden="true" />
|
||||
<span className={cn("sidebar__label", collapsed && "sr-only")}>
|
||||
首页
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="sidebar__categories">
|
||||
{categories.map((category) => {
|
||||
const isActive = category.id === activeCategoryId
|
||||
|
||||
return (
|
||||
<Tooltip key={category.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
className={cn(
|
||||
"sidebar__nav-item",
|
||||
isActive && "sidebar__nav-item--active",
|
||||
)}
|
||||
href={`/categories/${category.id}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<CategoryIcon name={category.icon} />
|
||||
<span
|
||||
className={cn("sidebar__label", collapsed && "sr-only")}
|
||||
>
|
||||
{category.title}
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{category.title}</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
|
||||
}
|
||||
@@ -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,15 @@
|
||||
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)
|
||||
}
|
||||
|
||||
export function getCategoryById(id: string): Category | undefined {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
return category ? { ...category } : undefined
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 getLinksByCategory(categoryId: string): NavLink[] {
|
||||
return links
|
||||
.filter((link) => link.categoryId === categoryId)
|
||||
.map(cloneLink)
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
}
|
||||
|
||||
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,117 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getCategories, getCategoryById } from "@/services/categories"
|
||||
import { getLinks, getLinksByCategory, 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("按 ID 返回分类副本并拒绝未知分类", () => {
|
||||
const category = getCategoryById("dev")
|
||||
|
||||
expect(category).toMatchObject({ id: "dev", title: "开发工具" })
|
||||
expect(category).not.toBe(getCategoryById("dev"))
|
||||
expect(getCategoryById("unknown")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("按 sort 返回分类链接并提供深层副本", () => {
|
||||
const first = getLinksByCategory("dev")
|
||||
const second = getLinksByCategory("dev")
|
||||
|
||||
expect(first).toHaveLength(15)
|
||||
expect(first.map(({ id }) => id)).toEqual([
|
||||
"github",
|
||||
"gitlab",
|
||||
"bitbucket",
|
||||
"vscode",
|
||||
"jetbrains",
|
||||
"goland",
|
||||
"postman",
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"vercel",
|
||||
"netlify",
|
||||
"render",
|
||||
"npm",
|
||||
"pnpm",
|
||||
"figma",
|
||||
])
|
||||
expect(first).not.toBe(second)
|
||||
expect(first[0]).not.toBe(second[0])
|
||||
expect(first[0].keywords).not.toBe(second[0].keywords)
|
||||
expect(getLinksByCategory("design")).toEqual([])
|
||||
})
|
||||
|
||||
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 },
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||