友链页面实现说明
这篇文档记录 /friends 友链页面的功能设计、组件架构和数据维护方式。
功能概述
友链页面用于展示朋友和同行的个人站点,所有访客均可浏览。核心交互与功能点:
- 背景图片卡片:每个友链可以配置背景图,卡片采用深色叠加层 + 头像/站点名/URL 的视觉布局。无背景图的卡片降级为纯色卡片,补充展示站点描述。
- 分类筛选:友链可按
category字段分组。页面顶部渲染胶囊形标签按钮,点击切换分类过滤,支持键盘导航(Arrow 键、Home/End)。 - 滚动入场动画:卡片在
IntersectionObserver触发时以cubic-bezier(0.22, 1, 0.36, 1)缓动淡入上移,尊重prefers-reduced-motion: reduce。 - 申请引导区块:页面底部以虚线边框区块展示友链申请条件和联系方式,引导有意交换友链的访客主动联系。
- 国际化:页面所有文案(标题、筛选标签、申请区块)和友链数据字段(名称、描述、分类)均支持
zh-Hans/en双语。 - 移动端触摸交互:触屏设备采用两次点击模式——首次点击展开描述(添加
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 的字段(name、description、category、note)可以传入普通字符串(所有语言共用)或包含 zh-Hans / en 键的对象。
新增友链
在 src/data/friends.js 的数组末尾追加一个对象即可。category 不填则归入"全部"中显示但不参与分类筛选;background 不填则卡片降级为纯色模式并展示描述文字。
组件架构
FriendsSection(主组件)
页 面级组件,位于 src/components/FriendsSection/index.js。
状态管理:
activeCategory:当前选中的分类筛选值,null表示"全部"。categories:useMemo从友链数据中动态提取的分类集合,随 locale 变化重新计算。filteredFriends:useMemo根据activeCategory过滤后的友链列表。expandedId:当前展开的友链卡片 ID(仅触屏设备使用),控制cardExpanded类的添加与移除。bgUrl:每日背景图片 URL,由 API 动态获取,通过 inline style 应用到.sectionBg元素。
副作用:
useEffect在组件挂载时请求https://goodimg.nevergpdzy.com/?format=url获取每日背景图片 URL,校验后写入bgUrlstate;使用AbortController在卸载时取消请求。useEffect在filteredFriends变化时重新绑定IntersectionObserver,为每个.card元素注册视口交叉监听,触发cardVisible类添加以实现入场动画。useEffect在expandedId变化时注册/注销document级click监听器,点击卡片外区域时清除展开状态。
键盘无障碍:
- 筛选标签栏使用
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 断点下缩小卡片、头像和字号。
- 水印背景:
.sectionBg以position: 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唯一、url以https://开头。 - 修改申请条件文案需同时更新
COPY['zh-Hans']和COPY['en']中的applyRules数组。 - 分类标签文本来自友链数据的
category字段,若同一分类在中英文下名称不同(如'技术'vs'Tech'),筛选匹配将基于当前 locale 解析后的值进行。 - 若新增或删除友链后统计数量不准确,检查
countLabel引用的是friends.length(全部友链数量,非过滤后的数量)。