友链页面
友链页面(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的机制——见页面切换相关页面。
概述
友链页面解决的需求是:"把一批互链的站点以一种公平、可检索的方式展示出来"。它有几个关键设计取舍:
- 构建期洗牌(随机排序):
friends.astro在 frontmatter 中调用getShuffledFriendsList(),用 Fisher–Yates 算法打乱数组。这样每次构建产物中卡片顺序都不同,避免"谁排在前面"的先后争议;同时因为是构建期完成,浏览器零运行时成本。 - 静态渲染 + 客户端过滤:所有卡片(
FriendCard)都在 SSG 阶段渲染成完整 HTML;搜索与筛选只是切换已渲染卡片的display,不发起任何请求、不做虚拟 DOM。这保证了首屏完整可读、SEO 友好,同时交互零延迟。 - 数据与文案分离:卡片数据来自
src/data/friends.ts(结构化数据),补充说明来自src/content/spec/friends.md(Markdown 内容),UI 文案来自src/i18n/translation(i18n 键)。三者互不耦合。 - 全局脚本而非组件内脚本:交互脚本放在
public/js/friends-page-handler.js并通过<script is:inline src="...">引入。文件内注释明确说明其动机——"此脚本作为全局脚本加载,不受 Swup 页面切换影响"。Astro 组件内的普通<script>在 Swup 视图切换后不会重新执行,因此采用这种规避方案。
架构
架构要点说明:
- 两条数据流汇入一个页面:结构化数据(
friends.ts)驱动卡片网格,Markdown 内容(spec/friends.md)驱动说明区块;二者在friends.astrofrontmatter 中分别通过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 传递。
渲染流程:从构建到可交互
1. 功能开关与前置校验
页面在最前面声明 export const prerender = true(完全静态输出),随后立即做两道防线:
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. 数据获取与标签聚合
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 中:
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
字段语义与使用位置:
| 字段 | 类型 | 用途 | 消费方 |
|---|---|---|---|
id | number | 条目唯一标识(示例数据为 1–8 递增) | 数据维护用,未参与渲染 |
title | string | 站点名称,卡片标题 <h3> | FriendCard 标题;data-title 搜索匹配 |
imgurl | string | 头像/图标 URL,<img src> | FriendCard 头像(loading="lazy") |
desc | string | 一句话描述,两行截断 | data-desc 搜索匹配 |
siteurl | string | 站点链接,卡片"访问"按钮与复制按钮的 href / data-url | FriendCard;hostname 展示 |
tags | string[] | 分类标签,渲染为小标签 chip | 页面级 allTags 聚合;data-tags 筛选匹配 |
注意 FriendCard.astro 自己声明的 Props 是 FriendItem 的结构子集(不含 id),组件对 siteurl 做了 new URL(friend.siteurl).hostname 解析用于展示域名——因此 siteurl 必须是含协议的绝对 URL(如 https://github.com),否则 new URL() 会在构建期抛出异常。这是数据维护时最重要的隐式约束。
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
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 → 客户端的契约)
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) 安全外链与懒加载
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) 交错入场动画
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 内搭建搜索栏、标签栏、网格、空状态四个关键节点:
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 列 →
sm2 列 →xl3 列。 - 标签按钮第一个固定为"全部"(
data-tag="all"、初始active),其后由allTags.map生成。 #no-results初始hidden,由脚本在零命中时切换。
标签筛选按钮由服务端模板生成:
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) 全局单例状态与幂等初始化
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) 关键元素存在性守卫
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) 监听器生命周期清理(防重复绑定)
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) 过滤算法(纯属性匹配)
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) 搜索输入绑定
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 捕获循环变量)
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)
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 向全局脚本传参"的通用技巧:
1<!-- 隐藏元素用于传递 i18n 文本到全局脚本 -->
2<div id="friends-copy-success-text" style="display: none;">
3 {i18n(I18nKey.friendsCopySuccess)}
4</div>Source: friends.astro
交互状态机
状态切换的核心是 filterFriends() 中 visibleCount 的分支:零命中时 #friends-grid 加 hidden、#no-results 移除 hidden;有命中则反向。任何一次输入或点击都会重算,状态始终由 (searchTerm, currentTag) 二元组唯一决定,无隐藏状态。
使用示例
添加一条友链
在 src/data/friends.ts 的 friendsData 数组追加条目即可,无需改动任何组件:
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 控制:
1featurePages: {
2 // ...
3 friends: true,
4 // ...
5},Source: README.md
设为 false 后访问 /friends/ 会重定向到 /404/(对应 friends.astro 的守卫逻辑)。
编辑页面附加说明
说明区块的内容源是 src/content/spec/friends.md,通过 content collection 读取并在页面底部渲染:
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.friends | src/config/siteConfig.ts | boolean | true | 页面总开关;false 时 /friends/ 重定向 404 |
friendsData | src/data/friends.ts | FriendItem[] | 8 条示例 | 卡片数据源,构建期读取 |
| friends 说明正文 | src/content/spec/friends.md | Markdown(frontmatter + 正文) | 示例内容 | 缺失会导致构建抛错 |
| 网格列数 | friends.astro 的 class | CSS | 1 / sm:2 / xl:3 | 由 Tailwind 响应式类硬编码 |
| 复制成功文案 | i18n friendsCopySuccess | string | 随语言 | 经隐藏 DOM 传递给全局脚本 |
window.isFriendsPage | friends.astro 内联脚本 | boolean | — | 供其他全局脚本识别当前页面 |
页面通过内联脚本暴露页面标识,供站点级脚本探测:
<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.title | string | 是 | 卡片标题,同时写入 data-title(小写) |
friend.siteurl | string | 是 | 绝对 URL;用于访问链接、复制按钮、hostname 展示 |
friend.imgurl | string | 是 | 头像 URL,loading="lazy" |
friend.desc | string | 是 | 描述,两行截断,写入 data-desc(小写) |
friend.tags | string[] | 是 | 标签 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",首屏不阻塞。 - 搜索未防抖,靠单次过滤成本极低来换取实现简单与即时反馈。
扩展点
想在不动核心逻辑的前提下扩展该页面,可行路径:
- 新增字段:在
FriendItem增加字段(如创建日期),在FriendCard渲染并同步写入新的data-*属性,然后在filterFriends()中扩展匹配条件——这是既有的"属性即契约"模式,新维度筛选只需三处小改。 - 替换数据源:
friendsData是普通导出数组,可改为从远程构建期拉取(如linkding/webhook导出的 JSON),只要最终仍返回FriendItem[]即可,页面无需改动。 - 顺序策略:需要稳定顺序时把页面中的
getShuffledFriendsList()换成getFriendsList();需要"每次访问都随机"则需改为客户端洗牌(当前实现是构建期随机)。 - 新的 i18n 传递:任何需要传给全局脚本的翻译文本,复制
friends-copy-success-text的隐藏 DOM 模式即可。 - 接入评论:页面已带
<Comment path="/friends/" />(见 friends.astro),评论系统本身的配置见评论组件相关页面。