Skip to main content

友链页面实现说明

这篇文档记录 /friends 友链页面的功能设计、组件架构和数据维护方式。

功能概述

友链页面用于展示朋友和同行的个人站点,所有访客均可浏览。核心交互与功能点:

  1. 背景图片卡片:每个友链可以配置背景图,卡片采用深色叠加层 + 头像/站点名/URL 的视觉布局。无背景图的卡片降级为纯色卡片,补充展示站点描述。
  2. 分类筛选:友链可按 category 字段分组。页面顶部渲染胶囊形标签按钮,点击切换分类过滤,支持键盘导航(Arrow 键、Home/End)。
  3. 滚动入场动画:卡片在 IntersectionObserver 触发时以 cubic-bezier(0.22, 1, 0.36, 1) 缓动淡入上移,尊重 prefers-reduced-motion: reduce
  4. 申请引导区块:页面底部以虚线边框区块展示友链申请条件和联系方式,引导有意交换友链的访客主动联系。
  5. 国际化:页面所有文案(标题、筛选标签、申请区块)和友链数据字段(名称、描述、分类)均支持 zh-Hans / en 双语。
  6. 移动端触摸交互:触屏设备采用两次点击模式——首次点击展开描述(添加 cardExpanded 类),再次点击跳转访问。点击卡片外区域收起描述。通过 window.matchMedia('(hover: none) and (pointer: coarse)') 检测触屏设备。

文件结构

src/
├── pages/
│ └── friends.mdx # 路由入口,挂载 FriendsSection 组件
├── components/
│ └── FriendsSection/
│ ├── index.js # 主组件,包含 Avatar、页面布局与状态逻辑
│ └── styles.module.css # CSS Modules 样式
└── data/
└── friends.js # 友链数据数组

数据格式

友链数据在 src/data/friends.js 中以数组形式维护,每个友链一个对象:

{
id: 'friend-slug', // 唯一标识(必填)
avatar: 'https://oss.nevergpdzy.com/avatars/friend.jpg', // 头像 URL(必填)
name: { 'zh-Hans': '站点名称', en: 'Site Name' }, // 站点名称(必填,支持 i18n)
description: { 'zh-Hans': '一句话介绍', en: 'One-line intro' }, // 描述(必填,支持 i18n)
url: 'https://example.com', // 站点 URL(必填)
background: 'https://example.com/screenshot.jpg', // 背景图片 URL(可选)
category: { 'zh-Hans': '技术', en: 'Tech' }, // 分类(可选,用于筛选)
note: { 'zh-Hans': '备注', en: 'Personal note' }, // 备注(可选,预留字段)
}

支持 i18n 的字段(namedescriptioncategorynote)可以传入普通字符串(所有语言共用)或包含 zh-Hans / en 键的对象。

新增友链

src/data/friends.js 的数组末尾追加一个对象即可。category 不填则归入"全部"中显示但不参与分类筛选;background 不填则卡片降级为纯色模式并展示描述文字。

组件架构

FriendsSection(主组件)

页面级组件,位于 src/components/FriendsSection/index.js

状态管理

  • activeCategory:当前选中的分类筛选值,null 表示"全部"。
  • categoriesuseMemo 从友链数据中动态提取的分类集合,随 locale 变化重新计算。
  • filteredFriendsuseMemo 根据 activeCategory 过滤后的友链列表。
  • expandedId:当前展开的友链卡片 ID(仅触屏设备使用),控制 cardExpanded 类的添加与移除。
  • bgUrl:每日背景图片 URL,由 API 动态获取,通过 inline style 应用到 .sectionBg 元素。

副作用

  • useEffect 在组件挂载时请求 https://goodimg.nevergpdzy.com/?format=url 获取每日背景图片 URL,校验后写入 bgUrl state;使用 AbortController 在卸载时取消请求。
  • useEffectfilteredFriends 变化时重新绑定 IntersectionObserver,为每个 .card 元素注册视口交叉监听,触发 cardVisible 类添加以实现入场动画。
  • useEffectexpandedId 变化时注册/注销 documentclick 监听器,点击卡片外区域时清除展开状态。

键盘无障碍

  • 筛选标签栏使用 role="tablist"role="tab",通过 handleFilterKeyDown 处理 ArrowRight/Left、ArrowUp/Down、Home/End 按键,实现焦点移动与激活。

辅助函数

  • resolveLocale(value, locale):从 i18n 对象或纯字符串中解析当前语言的值。
  • safeHostname(url):安全提取 URL 的 hostname,解析失败时返回原始 URL 作为兜底。

Avatar(子组件)

独立的头像组件,内部管理图片加载失败状态:

  • 加载成功时渲染 <img> 标签,loading="lazy" 延迟加载。
  • onError 触发后切换为首字母回退圆圈(avatarFallback)。

样式设计要点

  • 卡片基础态:默认 opacity: 0; transform: translate3d(0, 14px, 0) 隐藏,由 .cardVisible 类触发 friendsReveal 动画。
  • 背景卡片:通过 :has(.cardBackground) 限定高度 200px、移除内边距;hover 时透明背景并放大背景图。
  • 悬停反馈:普通卡片 hover 时背景变为主题色、上浮 3px、所有文字变白;背景卡片 hover 时仅放大背景图、加深遮罩。
  • 域名显示.siteUrl 默认使用主题文字色(var(--site-text-soft)),仅在背景卡片上覆盖为白色(通过 ~ 兄弟选择器),hover 普通卡片时变为白色。
  • 申请区块:虚线边框,hover 时边框色变为主题色,规则列表用 ::before { content: '✓ ' } 伪元素装饰。
  • 减动偏好prefers-reduced-motion: reduce 下跳过所有入场动画,卡片直接完全可见。
  • 响应式:760px 和 480px 断点下缩小卡片、头像和字号。
  • 水印背景.sectionBgposition: fixed 覆盖视口,加载远程图片作为低透明度水印层(亮色 0.12、暗色 0.10),z-index: -1 位于卡片下方,pointer-events: none 不影响交互,随主题切换过渡透明度。图片来源为阿里云函数(https://goodimg.nevergpdzy.com/?format=url),组件挂载时通过 fetch 获取每日图片 URL 并以 inline style 覆盖 CSS 默认背景图,实现每日自动轮换。CSS 中保留静态 fallback 图片,API 请求失败时无缝降级。请求使用 AbortController 在组件卸载时取消,返回值通过正则校验确保为合法 HTTP(S) URL。
  • 移动端触摸展开:在 @media (hover: none) and (pointer: coarse) 内,先用 :hover 规则禁用桌面悬停效果(防止粘性 hover 干扰),再用 .card.cardExpanded.cardExpanded(重复类名提升特异性至 (0,4,0))确保展开状态始终高于移动端粘性 :hover(0,3,0),避免点击后文字不显示的问题。过渡时间统一缩短至 0.2s 以适配触屏交互节奏。

路由

友链页面通过 src/pages/friends.mdx 暴露为 /friends 路径(中文默认)和 /en/friends(英文),MDX 仅负责设置 frontmatter 并挂载 <FriendsSection /> 组件,不包含页面逻辑。

维护注意事项

  • 新增友链只需编辑 src/data/friends.js,无需修改组件代码。确保 id 唯一、urlhttps:// 开头。
  • 修改申请条件文案需同时更新 COPY['zh-Hans']COPY['en'] 中的 applyRules 数组。
  • 分类标签文本来自友链数据的 category 字段,若同一分类在中英文下名称不同(如 '技术' vs 'Tech'),筛选匹配将基于当前 locale 解析后的值进行。
  • 若新增或删除友链后统计数量不准确,检查 countLabel 引用的是 friends.length(全部友链数量,非过滤后的数量)。