Repository Wiki
LyraVoid/Mizuki

友链页面

友链页面(Friends Page)是 Mizuki 主题的"功能页"(feature pages)之一,以卡片网格的形式展示站长收录的友情链接,并提供纯客户端的关键字搜索、标签筛选与"复制链接"交互。整页在构建期(prerender)静态生成,交互逻辑由一个位于 public/js/ 的全局脚本承担,从而兼容 Swup 无刷新页面切换。

目的与范围

本页覆盖友链功能从数据到渲染再到交互的完整链路:

  • 数据模型与数据源:src/data/friends.ts 中的 FriendItem 接口、friendsData 示例数据以及 getFriendsList / getShuffledFriendsList 两个数据访问函数。
  • 页面入口:src/pages/friends.astro 的构建期数据获取、功能开关校验、标签聚合与页面骨架。
  • 展示组件:src/components/features/friends/FriendCard.astro 卡片结构、data-* 属性契约与入场动画。
  • 客户端交互:public/js/friends-page-handler.js 的搜索、标签筛选、复制链接、事件监听清理与全局状态管理。
  • 说明文档:src/content/spec/friends.md 作为 Markdown 附加内容被渲染在卡片网格之后。

以下相邻主题有意留给兄弟页面,本文仅点到为止:

  • 站点级功能开关的完整定义(siteConfig.featurePages 中除 friends 外的其他页面)与整体站点配置——见站点配置相关页面。
  • MainGridLayout、PageHeader、评论组件(Comment)等通用布局能力的内部实现——见布局与通用组件相关页面。
  • Swup 视图切换引擎与 right-sidebar-layout.js 的机制——见页面切换相关页面。

概述

友链页面解决的需求是:"把一批互链的站点以一种公平、可检索的方式展示出来"。它有几个关键设计取舍:

  1. 构建期洗牌(随机排序):friends.astro 在 frontmatter 中调用 getShuffledFriendsList(),用 Fisher–Yates 算法打乱数组。这样每次构建产物中卡片顺序都不同,避免"谁排在前面"的先后争议;同时因为是构建期完成,浏览器零运行时成本。
  2. 静态渲染 + 客户端过滤:所有卡片(FriendCard)都在 SSG 阶段渲染成完整 HTML;搜索与筛选只是切换已渲染卡片的 display,不发起任何请求、不做虚拟 DOM。这保证了首屏完整可读、SEO 友好,同时交互零延迟。
  3. 数据与文案分离:卡片数据来自 src/data/friends.ts(结构化数据),补充说明来自 src/content/spec/friends.md(Markdown 内容),UI 文案来自 src/i18n/translation(i18n 键)。三者互不耦合。
  4. 全局脚本而非组件内脚本:交互脚本放在 public/js/friends-page-handler.js 并通过 <script is:inline src="..."> 引入。文件内注释明确说明其动机——"此脚本作为全局脚本加载,不受 Swup 页面切换影响"。Astro 组件内的普通 <script> 在 Swup 视图切换后不会重新执行,因此采用这种规避方案。

架构

Loading diagram...

架构要点说明:

  • 两条数据流汇入一个页面:结构化数据(friends.ts)驱动卡片网格,Markdown 内容(spec/friends.md)驱动说明区块;二者在 friends.astro frontmatter 中分别通过 getShuffledFriendsList() 与 getEntry/render 获取。
  • SSG 输出即交互契约:FriendCard.astro 在渲染时把 title、desc、tags 以小写/逗号拼接形式写入 data-title / data-desc / data-tags 属性。客户端脚本完全基于这些属性做匹配,而不是读取 DOM 文本——这就是图中"渲染出 data-* → Handler"那条边的含义。
  • i18n 文本的跨脚本传递:页面把 friendsCopySuccess 的翻译文本写进一个 display:none 的隐藏 <div id="friends-copy-success-text">,脚本运行时读取其 textContent。这是因为全局脚本无法使用 Astro 的 import 编译期 i18n,只能通过 DOM 传递。

渲染流程:从构建到可交互

Loading diagram...

1. 功能开关与前置校验

页面在最前面声明 export const prerender = true(完全静态输出),随后立即做两道防线:

astro
1export const prerender = true; 2 3if (!siteConfig.featurePages.friends) { 4 return Astro.redirect("/404/"); 5} 6 7const friendsPost = await getEntry("spec", "friends"); 8 9if (!friendsPost) { 10 throw new Error("friends page content not found"); 11}

Source: friends.astro

第一道是"功能级下线":featurePages.friends 为 false 时整页直接 302 到 /404/,让用户在站点配置层面就能关闭该页面(README 中对应该键默认为 true)。第二道是"内容缺失硬失败":说明文档 spec/friends.md 属于必备内容,缺失时抛出 Error("friends page content not found") 使构建失败,而不是静默降级——这符合 fail-fast 原则,提醒维护者补齐内容。

2. 数据获取与标签聚合

astro
const { Content } = await render(friendsPost); const friendsList = getShuffledFriendsList(); const allTags = Array.from(new Set(friendsList.flatMap((item) => item.tags)));

Source: friends.astro

allTags 用 flatMap 把每个条目的 tags 摊平,再用 Set 去重,得到筛选按钮所需的完整标签集合。这一步放在构建期意味着标签按钮数量与数据严格同步,不会出现"筛选按钮存在但没有对应卡片"的漂移。

数据模型

友链的唯一数据实体是 FriendItem,定义在 src/data/friends.ts 中:

typescript
1export interface FriendItem { 2 id: number; 3 title: string; 4 imgurl: string; 5 desc: string; 6 siteurl: string; 7 tags: string[]; 8}

Source: friends.ts

字段语义与使用位置:

字段类型用途消费方
idnumber条目唯一标识(示例数据为 1–8 递增)数据维护用,未参与渲染
titlestring站点名称,卡片标题 <h3>FriendCard 标题;data-title 搜索匹配
imgurlstring头像/图标 URL,<img src>FriendCard 头像(loading="lazy")
descstring一句话描述,两行截断data-desc 搜索匹配
siteurlstring站点链接,卡片"访问"按钮与复制按钮的 href / data-urlFriendCard;hostname 展示
tagsstring[]分类标签,渲染为小标签 chip页面级 allTags 聚合;data-tags 筛选匹配

注意 FriendCard.astro 自己声明的 Props 是 FriendItem 的结构子集(不含 id),组件对 siteurl 做了 new URL(friend.siteurl).hostname 解析用于展示域名——因此 siteurl 必须是含协议的绝对 URL(如 https://github.com),否则 new URL() 会在构建期抛出异常。这是数据维护时最重要的隐式约束。

astro
1interface Props { 2 friend: { 3 title: string; 4 siteurl: string; 5 imgurl: string; 6 desc: string; 7 tags: string[]; 8 }; 9} 10 11const { friend } = Astro.props; 12const hostname = new URL(friend.siteurl).hostname;

Source: FriendCard.astro

数据访问 API

typescript
1// 获取所有友情链接数据 2export function getFriendsList(): FriendItem[] { 3 return friendsData; 4} 5 6// 获取随机排序的友情链接数据 7export function getShuffledFriendsList(): FriendItem[] { 8 const shuffled = [...friendsData]; 9 for (let i = shuffled.length - 1; i > 0; i--) { 10 const j = Math.floor(Math.random() * (i + 1)); 11 [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 12 } 13 return shuffled; 14}

Source: friends.ts

  • getFriendsList():原样返回 friendsData,保留原始顺序。友链页面当前未使用它,它作为保持顺序语义的公开入口保留。
  • getShuffledFriendsList():标准 Fisher–Yates 洗牌。关键细节是第一行 [...friendsData] 先做浅拷贝再洗牌,不污染模块级源数组——如果直接在 friendsData 上原位交换,同一构建内第二次调用(例如另一个页面或重渲染)会基于已打乱的数组继续操作。每个条目本身是对象引用(浅拷贝),但组件只读字段不改写,因此浅拷贝足够。

核心实现详解

卡片组件:FriendCard.astro

卡片是一个纯展示组件,除 i18n 文案外无任何逻辑。它的三个关键设计:

1) 把筛选数据写进 data-* 属性(SSG → 客户端的契约)

astro
1<div 2 class="friend-card group relative bg-transparent rounded-xl border border-black/10 dark:border-white/10 overflow-hidden transition-all duration-300 hover:shadow-xl hover:-translate-y-1" 3 data-title={friend.title.toLowerCase()} 4 data-desc={friend.desc.toLowerCase()} 5 data-tags={friend.tags.join(",")} 6>

Source: FriendCard.astro

title/desc 在构建期预先 toLowerCase(),tags 用逗号连接成单字符串。这样客户端脚本读取属性即可做大小写不敏感匹配,避免运行时再遍历文本节点,也避免富文本/实体干扰匹配。逗号连接的前提是标签名本身不含逗号——这是数据维护的第二条隐式约束。

2) 安全外链与懒加载

astro
1<a 2 href={friend.siteurl} 3 target="_blank" 4 rel="noopener noreferrer" 5 class="text-xs text-black/50 dark:text-white/50 hover:text-(--primary) truncate block transition-colors duration-200" 6> 7 {hostname} 8</a>

Source: FriendCard.astro

所有外链带 rel="noopener noreferrer"(安全实践:防止新窗口通过 window.opener 反向控制来源页)。头像 <img> 带 loading="lazy",友链数量多时避免一次性加载全部外站头像(友链头像往往来自第三方 CDN,是最慢的资源)。

3) 交错入场动画

css
1.friend-card { 2 animation: fadeInUp 0.5s ease-out forwards; 3 opacity: 0; 4} 5 6.friend-card:nth-child(1) { 7 animation-delay: 0.05s; 8} 9.friend-card:nth-child(2) { 10 animation-delay: 0.1s; 11} 12.friend-card:nth-child(3) { 13 animation-delay: 0.15s; 14}

Source: FriendCard.astro

卡片以 opacity: 0 初始隐藏,fadeInUp 动画 forwards 落到可见,nth-child 逐个增加 0.05s 延迟形成瀑布式入场。注意默认只有前若干个子元素定义了延迟(源文件按 1..N 逐条列出),超出部分延迟为 0。同时 filterFriends() 通过 style.display 切换可见性,与该动画互不冲突(动画只在首次渲染时播放一次)。

页面骨架与交互元素

页面在 MainGridLayout 内搭建搜索栏、标签栏、网格、空状态四个关键节点:

astro
1<div 2 id="friends-grid" 3 class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6 mb-6" 4> 5 {friendsList.map((item) => <FriendCard friend={item} />)} 6</div> 7 8<!-- 无结果提示 --> 9<div id="no-results" class="hidden text-center py-12"> 10 ... 11 <p class="text-black/50 dark:text-white/50 text-lg"> 12 {i18n(I18nKey.friendsNoResults)} 13 </p> 14</div>

Source: friends.astro

  • 网格响应式断点:移动端 1 列 → sm 2 列 → xl 3 列。
  • 标签按钮第一个固定为"全部"(data-tag="all"、初始 active),其后由 allTags.map 生成。
  • #no-results 初始 hidden,由脚本在零命中时切换。

标签筛选按钮由服务端模板生成:

astro
1<div class="filter-container flex flex-wrap gap-2"> 2 <button class="filter-tag active" data-tag="all"> 3 {i18n(I18nKey.friendsFilterAll)} 4 </button> 5 { 6 allTags.map((tag) => ( 7 <button class="filter-tag" data-tag={tag}> 8 {tag} 9 </button> 10 )) 11 } 12</div>

Source: friends.astro

客户端交互:friends-page-handler.js

该脚本以 IIFE 形式组织,通过页面底部 <script is:inline src="/js/friends-page-handler.js"></script> 引入(见 friends.astro)。它的存在理由写在文件头注释里:"此脚本作为全局脚本加载,不受 Swup 页面切换影响"。

1) 全局单例状态与幂等初始化

javascript
1// 使用全局变量存储状态 2if (typeof window.friendsPageState === "undefined") { 3 window.friendsPageState = { 4 initialized: false, 5 eventListeners: [], 6 mutationObserver: null, 7 copySuccessText: "已复制", // 默认值,会被页面覆盖 8 }; 9}

Source: friends-page-handler.js

window.friendsPageState 是跨页面切换存活的状态容器,核心字段:

  • eventListeners: []:以 [element, type, handler] 三元组登记所有已绑定监听器,用于重初始化前清理。
  • copySuccessText:复制成功的 toast 文案,默认中文"已复制",运行时被页面隐藏 <div> 的 i18n 文本覆盖。

2) 关键元素存在性守卫

javascript
1var searchInput = document.getElementById("friend-search"); 2var friendsGrid = document.getElementById("friends-grid"); 3var noResults = document.getElementById("no-results"); 4 5// 如果关键元素不存在,直接返回 6if (!searchInput || !friendsGrid || !noResults) { 7 return false; 8}

Source: friends-page-handler.js

因为脚本是全局的(每个包含它的页面都会加载),而站点其他页面没有这些元素,所以必须先探测 #friend-search / #friends-grid / #no-results 是否存在再绑定,避免在无关页面上报错。

3) 监听器生命周期清理(防重复绑定)

javascript
1// 清理旧的事件监听器 2if (window.friendsPageState.eventListeners.length > 0) { 3 for (var i = 0; i < window.friendsPageState.eventListeners.length; i++) { 4 var listener = window.friendsPageState.eventListeners[i]; 5 var element = listener[0]; 6 var type = listener[1]; 7 var handler = listener[2]; 8 if (element && element.removeEventListener) { 9 element.removeEventListener(type, handler); 10 } 11 } 12 window.friendsPageState.eventListeners = []; 13}

Source: friends-page-handler.js

Swup 切换回友链页时会再次触发初始化。若不清理,旧的 input / click 监听器会叠加,导致同一次点击触发多次过滤、多次复制。把每个监听器登记进 eventListeners,重初始化时逐个 removeEventListener 再清空数组,是典型的"手动解绑 + 重建"模式。这也意味着闭包变量 currentTag / searchTerm 每次初始化都会重置回 "all" / ""——回到页面时筛选状态不保留,这是有意为之的简化。

4) 过滤算法(纯属性匹配)

javascript
1function filterFriends() { 2 var visibleCount = 0; 3 for (var i = 0; i < friendCards.length; i++) { 4 var card = friendCards[i]; 5 var title = (card.getAttribute("data-title") || "").toLowerCase(); 6 var desc = (card.getAttribute("data-desc") || "").toLowerCase(); 7 var tags = card.getAttribute("data-tags") || ""; 8 9 var matchesSearch = 10 !searchTerm || 11 title.indexOf(searchTerm) >= 0 || 12 desc.indexOf(searchTerm) >= 0; 13 var matchesTag = 14 currentTag === "all" || tags.split(",").indexOf(currentTag) >= 0; 15 16 if (matchesSearch && matchesTag) { 17 card.style.display = ""; 18 visibleCount++; 19 } else { 20 card.style.display = "none"; 21 } 22 } 23 24 if (visibleCount === 0) { 25 noResults.classList.remove("hidden"); 26 friendsGrid.classList.add("hidden"); 27 } else { 28 noResults.classList.add("hidden"); 29 friendsGrid.classList.remove("hidden"); 30 } 31}

Source: friends-page-handler.js

算法是 O(卡片数) 的线性扫描,搜索条件是"标题或描述包含关键字"(indexOf 子串匹配),标签条件是"卡片标签数组包含当前标签"。二者为逻辑与——先搜索再点标签时结果取交集。注意双保险的小写化:构建期 FriendCard 已 toLowerCase() 写入属性,这里对属性值再 toLowerCase() 一次,同时用户输入也 toLowerCase(),保证大小写不敏感。空结果时隐藏整个网格、显示 #no-results 空状态区。

5) 搜索输入绑定

javascript
1var searchHandler = (e) => { 2 searchTerm = e.target.value.toLowerCase(); 3 filterFriends(); 4}; 5searchInput.addEventListener("input", searchHandler); 6window.friendsPageState.eventListeners.push([ 7 searchInput, 8 "input", 9 searchHandler, 10]);

Source: friends-page-handler.js

绑定在 input 事件上即每敲一键即时过滤,未做防抖——对几十到上百张卡片的 display 切换,每键全量扫描的开销可忽略,因此选择了"最简单且即时反馈"的方案。

6) 标签筛选绑定(IIFE 捕获循环变量)

javascript
1for (var i = 0; i < tagFilters.length; i++) { 2 ((button) => { 3 var clickHandler = () => { 4 // 更新选中状态 5 for (var j = 0; j < tagFilters.length; j++) { 6 var btn = tagFilters[j]; 7 btn.classList.remove("active"); 8 } 9 button.classList.add("active"); 10 11 currentTag = button.getAttribute("data-tag") || "all"; 12 filterFriends(); 13 }; 14 button.addEventListener("click", clickHandler); 15 window.friendsPageState.eventListeners.push([ 16 button, 17 "click", 18 clickHandler, 19 ]); 20 })(tagFilters[i]); 21}

Source: friends-page-handler.js

点击标签时先清空所有按钮的 active 再给当前按钮加上(互斥单选),读取 data-tag 更新 currentTag 后调用 filterFriends()。外层用 var 声明循环变量,因此必须用 IIFE 把 tagFilters[i] 作为参数 button 捕获进闭包,否则所有 handler 都会引用最后一次循环的元素。

7) 复制链接(Clipboard API)

javascript
1for (var i = 0; i < copyButtons.length; i++) { 2 ((button) => { 3 var clickHandler = () => { 4 var url = button.getAttribute("data-url"); 5 if (!url) return; 6 7 if (navigator.clipboard && navigator.clipboard.writeText) { 8 navigator.clipboard 9 .writeText(url) 10 .then(() => { 11 var originalHTML = button.innerHTML;

Source: friends-page-handler.js

复制优先走 navigator.clipboard.writeText(异步 Promise),成功后临时替换按钮图标/内容给出视觉反馈(保存 originalHTML 以便恢复);代码同时检测 API 存在性以兼容不支持 Clipboard API 的环境(降级路径在源文件后续部分)。复制成功文案来自 #friends-copy-success-text 的 textContent,即构建期注入的 i18n 文本——这是"SSG 向全局脚本传参"的通用技巧:

astro
1<!-- 隐藏元素用于传递 i18n 文本到全局脚本 --> 2<div id="friends-copy-success-text" style="display: none;"> 3 {i18n(I18nKey.friendsCopySuccess)} 4</div>

Source: friends.astro

交互状态机

Loading diagram...

状态切换的核心是 filterFriends() 中 visibleCount 的分支:零命中时 #friends-grid 加 hidden、#no-results 移除 hidden;有命中则反向。任何一次输入或点击都会重算,状态始终由 (searchTerm, currentTag) 二元组唯一决定,无隐藏状态。

使用示例

添加一条友链

在 src/data/friends.ts 的 friendsData 数组追加条目即可,无需改动任何组件:

typescript
1// 友情链接数据 2export const friendsData: FriendItem[] = [ 3 { 4 id: 1, 5 title: "Astro", 6 imgurl: "https://avatars.githubusercontent.com/u/44914786?v=4&s=640", 7 desc: "The web framework for content-driven websites", 8 siteurl: "https://github.com/withastro/astro", 9 tags: ["Framework"], 10 },

Source: friends.ts

新条目中的新标签会在下次构建时自动出现在筛选按钮中(allTags 聚合逻辑保证)。约束提醒:siteurl 必须是带协议的绝对 URL;tags 元素不能包含逗号。

启用 / 禁用友链页面

页面存在性由 siteConfig.featurePages.friends 控制:

typescript
1featurePages: { 2 // ... 3 friends: true, 4 // ... 5},

Source: README.md

设为 false 后访问 /friends/ 会重定向到 /404/(对应 friends.astro 的守卫逻辑)。

编辑页面附加说明

说明区块的内容源是 src/content/spec/friends.md,通过 content collection 读取并在页面底部渲染:

astro
1<!-- 说明文档 --> 2<Markdown class="mt-8 prose dark:prose-invert max-w-none"> 3 <Content /> 4</Markdown>

Source: friends.astro

该文件缺失会使构建直接失败(throw new Error("friends page content not found")),因此它是"必须存在的可选内容"——页面结构要求它存在,但其正文可为空。

配置选项

友链页面没有独立的 options 对象,所有可配置项分散在三个位置:

配置项位置类型默认说明
featurePages.friendssrc/config/siteConfig.tsbooleantrue页面总开关;false 时 /friends/ 重定向 404
friendsDatasrc/data/friends.tsFriendItem[]8 条示例卡片数据源,构建期读取
friends 说明正文src/content/spec/friends.mdMarkdown(frontmatter + 正文)示例内容缺失会导致构建抛错
网格列数friends.astro 的 classCSS1 / sm:2 / xl:3由 Tailwind 响应式类硬编码
复制成功文案i18n friendsCopySuccessstring随语言经隐藏 DOM 传递给全局脚本
window.isFriendsPagefriends.astro 内联脚本boolean—供其他全局脚本识别当前页面

页面通过内联脚本暴露页面标识,供站点级脚本探测:

astro
<script is:inline> window.isFriendsPage = true; </script>

Source: friends.astro

API 参考

getFriendsList(): FriendItem[]

返回模块级 friendsData 的引用,保持原始书写顺序。

返回:FriendItem[] —— 全部友链条目。

备注:友链页面本身未使用该函数;它是保留的顺序访问入口,适合需要稳定排序的调用方。

Source: friends.ts

getShuffledFriendsList(): FriendItem[]

以 Fisher–Yates 算法返回随机排序副本。

返回:FriendItem[] —— 洗牌后的新数组;元素为原对象引用(浅拷贝),模块级 friendsData 不被修改。

行为细节:每次调用独立随机;在 SSG 场景下意味着"每次构建产物顺序不同,同一产物内所有访问者看到相同顺序"。

Source: friends.ts

FriendCard.astro 组件 Props

属性类型必填说明
friend.titlestring是卡片标题,同时写入 data-title(小写)
friend.siteurlstring是绝对 URL;用于访问链接、复制按钮、hostname 展示
friend.imgurlstring是头像 URL,loading="lazy"
friend.descstring是描述,两行截断,写入 data-desc(小写)
friend.tagsstring[]是标签 chips,逗号拼接写入 data-tags

Source: FriendCard.astro

全局脚本依赖的 DOM 契约

friends-page-handler.js 依赖以下页面元素(任一缺失则跳过初始化并返回 false):

选择器作用
#friend-search搜索输入框,绑定 input
#friends-grid卡片网格容器,空结果时整体隐藏
#no-results空状态容器,初始 hidden
.filter-tag(含 data-tag)标签筛选按钮,绑定 click
.friend-card(含 data-title/data-desc/data-tags)被过滤的卡片
.copy-link-btn(含 data-url)复制按钮,绑定 click
#friends-copy-success-text隐藏元素,向脚本传递 i18n 复制文案

Source: friends-page-handler.js

失败模式、边界与并发

构建期失败

  • spec/friends.md 缺失 → throw new Error("friends page content not found"),构建失败(fail-fast)。
  • siteurl 非法(如缺少协议)→ FriendCard 中 new URL(friend.siteurl).hostname 抛出,构建失败。
  • featurePages.friends: false → 生成重定向到 /404/ 的页面,而非报错。

运行时边界

  • 空数据:若 friendsData 为空数组,allTags 为空(仅剩"全部"按钮),网格为空但 #no-results 初始仍是 hidden——因为空状态展示由 filterFriends() 驱动,零数据时脚本不会判定"无结果"。
  • 搜索无命中:visibleCount === 0 时显示空状态并隐藏整个网格。
  • 属性缺失:getAttribute(...) || "" 与 || "all" 兜底,卡片缺属性不会抛错,只是不参与匹配。
  • 旧浏览器:navigator.clipboard 不存在时跳过新 API 路径(源码有存在性判断),复制功能降级。
  • 非友链页面加载脚本:元素守卫直接 return false,不产生副作用。

并发与重复初始化(Swup 场景)

全局脚本在每次进入友链页时重新执行 initFriendsPage()。防重复绑定机制是:所有监听器登记进 window.friendsPageState.eventListeners,重初始化前逐个 removeEventListener。潜在边界:若旧的 DOM 元素已被 Swup 替换为新的,旧监听器随旧元素一起被回收,清理步骤只是保险;真正要防的是"同一批元素上叠加绑定"。此外每次重新初始化都会重置 currentTag/searchTerm,筛选状态不跨页面访问保留。

性能特征

  • 构建期一次性完成洗牌、标签聚合、全部卡片 HTML 渲染;运行时无数据请求。
  • 过滤为 O(n) 属性扫描 + display 切换,n = 卡片数;未做虚拟化,适合几十到几百量级。
  • 头像全部 loading="lazy",首屏不阻塞。
  • 搜索未防抖,靠单次过滤成本极低来换取实现简单与即时反馈。

扩展点

想在不动核心逻辑的前提下扩展该页面,可行路径:

  1. 新增字段:在 FriendItem 增加字段(如创建日期),在 FriendCard 渲染并同步写入新的 data-* 属性,然后在 filterFriends() 中扩展匹配条件——这是既有的"属性即契约"模式,新维度筛选只需三处小改。
  2. 替换数据源:friendsData 是普通导出数组,可改为从远程构建期拉取(如 linkding/webhook 导出的 JSON),只要最终仍返回 FriendItem[] 即可,页面无需改动。
  3. 顺序策略:需要稳定顺序时把页面中的 getShuffledFriendsList() 换成 getFriendsList();需要"每次访问都随机"则需改为客户端洗牌(当前实现是构建期随机)。
  4. 新的 i18n 传递:任何需要传给全局脚本的翻译文本,复制 friends-copy-success-text 的隐藏 DOM 模式即可。
  5. 接入评论:页面已带 <Comment path="/friends/" />(见 friends.astro),评论系统本身的配置见评论组件相关页面。

相关链接

Sources

(4 files)
src/components/features/friends
src/data
src/pages