友链页回答“我的朋友是谁”,友圈页回答“朋友们最近写了什么”。这篇记录如何把 Friend-Circle 的公开 JSON 接入 Nuxt 4 + Clarity,展示友链状态和朋友的新文章。
先看清楚数据从哪里来
这个页面不在浏览器里逐个抓取朋友的博客,而是读取后端已经汇总好的数据。
- Friend-Circle 后端
负责采集文章、检查站点状态,并生成供前端读取的公开 JSON。
- 博客友圈页
打开
/circle时,浏览器并行请求all.json和link.json,然后渲染状态卡片与文章列表。- 读者访问原文
点击文章卡片跳转到朋友的站点。友圈只提供发现入口,不复制和重新发布完整文章。
公开 JSON 每页 6 篇 按发布时间排序
本博客使用的两个地址是:
https://fc.moodlog.cn/all.json https://fc.moodlog.cn/link.json
本文讲的是博客前端接入,不包含 Friend-Circle 服务端从零部署。你需要先准备能返回下述字段的后端。如果使用其他版本或其他聚合服务,应以实际响应为准调整字段,不能只把域名换掉就默认兼容。
一、检查接口和跨域
先确认服务能够返回 JSON,而不是登录页、验证页或反向代理错误页面:
curl -fsS https://fc.moodlog.cn/all.json curl -fsS https://fc.moodlog.cn/link.json
all.json 中,页面主要用到:
| 字段 | 用途 |
|---|---|
statistical_data.article_num | 展示已收录文章总数 |
article_data[].title | 文章标题 |
article_data[].created | 发布时间和排序依据 |
article_data[].link | 原文地址与列表 key |
article_data[].author | 作者名称 |
article_data[].avatar | 文章卡片图标 |
link.json 中,状态卡片读取:
| 字段 | 含义 |
|---|---|
link_total_num | 友链总数 |
link_reachable_num | 可正常访问的站点数 |
link_unreachable_num | 暂时无法访问的站点数 |
crawl_allowed_num | 允许抓取的站点数 |
link_last_checked_time | 后端最后检查时间 |
这些状态字段都位于 statistical_data 中。后端还可以返回单个站点的详细信息,但当前友圈页展示的是汇总状态,并没有逐站状态表。
终端能访问,浏览器却失败怎么办
博客和数据源不在同一个源:blog.moodlog.cn 与 fc.moodlog.cn 是不同源。即使同属一个主域名,也需要正确的跨域响应头。
可以先检查:
curl -sI \ -H 'Origin: https://blog.moodlog.cn' \ https://fc.moodlog.cn/all.json
如果数据服务只供自己的博客读取,可以按服务端实际配置限定允许来源:
Access-Control-Allow-Origin: https://blog.moodlog.cn Vary: Origin
这不是让前端添加请求头,而是要求数据服务或反向代理返回正确的响应头。本地预览来源也应单独考虑。不要用 mode: 'no-cors' 绕过问题,那样拿到的不透明响应无法读取 JSON。
二、新建 Nuxt 页面
在项目中创建:
app/pages/circle.vue
Nuxt 会自动生成 /circle 路由,无需再创建同名 Markdown 页面。下面是一份用于理解接入流程的精简实现,包含四项状态、最新文章、六篇分页和失败提示。现有项目已经完成接入时,不必覆盖原文件。
<script setup lang="ts">
interface CircleArticle {
title: string
created: string
link: string
author: string
avatar: string
}
interface CircleAll {
statistical_data: { article_num: number }
article_data: CircleArticle[]
}
interface CircleLinkData {
statistical_data: {
link_total_num: number
link_reachable_num: number
link_unreachable_num: number
crawl_allowed_num: number
link_last_checked_time: string
}
}
const origin = 'https://fc.moodlog.cn'
const allData = ref<CircleAll>()
const linkData = ref<CircleLinkData>()
const loading = ref(false)
const error = ref('')
const page = ref(1)
const pageSize = 6
useSeoMeta({ title: '友圈', description: '朋友们的新文章与友链状态。' })
const sortedArticles = computed(() => {
return [...(allData.value?.article_data ?? [])].sort((a, b) => {
const difference = new Date(b.created.replace(' ', 'T')).getTime()
- new Date(a.created.replace(' ', 'T')).getTime()
return difference || a.link.localeCompare(b.link)
})
})
const pageCount = computed(() => Math.ceil(sortedArticles.value.length / pageSize))
const articles = computed(() => {
const start = (page.value - 1) * pageSize
return sortedArticles.value.slice(start, start + pageSize)
})
const metrics = computed(() => {
const stats = linkData.value?.statistical_data
return stats
? [
{ label: '友链总数', value: stats.link_total_num },
{ label: '正常运行', value: stats.link_reachable_num },
{ label: '暂时异常', value: stats.link_unreachable_num },
{ label: '允许抓取', value: stats.crawl_allowed_num },
]
: []
})
async function readJson<T>(path: string) {
const response = await fetch(`${origin}/${path}?_=${Date.now()}`, {
cache: 'no-store',
})
if (!response.ok)
throw new Error(`HTTP ${response.status}`)
return await response.json() as T
}
async function loadData() {
if (loading.value)
return
loading.value = true
error.value = ''
try {
const [all, links] = await Promise.all([
readJson<CircleAll>('all.json'),
readJson<CircleLinkData>('link.json'),
])
allData.value = all
linkData.value = links
page.value = 1
}
catch {
error.value = '暂时无法获取友圈数据,请检查网络后刷新重试。'
}
finally {
loading.value = false
}
}
onMounted(loadData)
</script>
<template>
<div class="circle-content">
<h1>友圈</h1>
<Alert v-if="error" type="warning" title="数据获取失败">
<p>{{ error }}</p>
</Alert>
<p v-else-if="loading">
正在加载友圈…
</p>
<template v-else-if="allData && linkData">
<h2>友链状态</h2>
<p>最后检查于 {{ linkData.statistical_data.link_last_checked_time }}</p>
<dl class="circle-stats">
<div v-for="metric in metrics" :key="metric.label" class="circle-stat card">
<dt>{{ metric.label }}</dt>
<dd>{{ metric.value }}</dd>
</div>
</dl>
<h2>朋友们的新文章</h2>
<p>共收录 {{ allData.statistical_data.article_num }} 篇文章</p>
<div class="circle-articles">
<LinkCard
v-for="article in articles"
:key="article.link"
:link="article.link"
:title="article.title"
:description="`${article.author} · ${article.created}`"
:icon="article.avatar"
/>
</div>
<p v-if="!articles.length">
暂时还没有收录文章。
</p>
<nav v-if="pageCount > 1" class="circle-pagination" aria-label="文章分页">
<button type="button" :disabled="page <= 1" @click="page--">
上一页
</button>
<span>{{ page }} / {{ pageCount }}</span>
<button type="button" :disabled="page >= pageCount" @click="page++">
下一页
</button>
</nav>
</template>
</div>
</template>
<style scoped>
.circle-content { min-width: 0; margin: 1rem; }
.circle-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.75rem; }
.circle-stat { min-width: 0; padding: 1rem; border: 1px solid var(--c-border); }
.circle-stat dt { color: var(--c-text-2); font-size: 0.85rem; }
.circle-stat dd { margin: 0.6rem 0 0; font-size: 1.8rem; font-variant-numeric: tabular-nums; }
.circle-articles { display: grid; gap: 0.75rem; }
.circle-articles :deep(.link-card) { width: auto; min-width: 0; margin: 0; }
.circle-pagination { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.75rem; margin-top: 1rem; }
@media (max-width: 600px) {
.circle-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
这段代码依赖本项目的 Clarity Alert、LinkCard 与主题变量,并不是任意 Nuxt 空项目都自带的组件。换用其他主题时,要相应替换它们。生产环境也应根据数据来源补充运行时结构校验和请求超时;TypeScript 类型断言本身并不验证接口数据。
三、排序和分页为什么放在前端
all.json 返回的是已收录文章集合。当前实现先按 created 从新到旧排序,再通过 slice() 选择当前页,每页展示六篇。
这里有两个小细节:
- 先复制数组再排序,不直接修改接口返回的原数组。
- 相同时间使用原文链接作次级排序,避免同一批文章反复交换位置。
点击下一页不会重新请求接口,也不会降低首次下载的 JSON 大小。如果后端将来收录成千上万篇文章,应评估服务端分页或限制导出数量,而不是只调整每页条数。
时间格式也需要约定。当前页面会把 2026-10-16 12:00:00 中的空格换为 T 后交给 Date 解析;若接入不同数据源,优先使用带时区的 ISO 时间。缺少时区或混合格式可能导致排序与显示产生偏差。
四、添加导航入口
在 app/app.config.ts 的现有导航数组中加入一项,不要覆盖整个配置:
{ icon: 'tabler:heart-handshake', text: '友圈', url: '/circle' },
上面是一条数组项片段,实际应放入项目已有的导航数组中。本博客已经配置好这项入口,无需重复添加。
现有 /circle 页面还包含移动端 BlogHeader、按页码跳转按钮,以及底部 PostComment 评论区。它们属于主题页面整合,和 Friend-Circle 数据采集本身是分开的。
五、手机端和主题保持一致
友链状态在桌面端为四列,手机端改成两列。文章列表保持一列,复用 Clarity 的 LinkCard,避免文章标题和作者信息被挤在过窄的卡片中。
样式上优先使用主题变量:
--c-border:边框--c-text-2:辅助文字--c-primary:主色--c-success:正常状态--c-warning:异常提醒
颜色不应成为唯一提示。例如“暂时异常”应同时有文字和图标,不能只把数字改成红色。
六、理解“实时”的边界
页面使用时间戳查询参数和 cache: 'no-store',进入页面时读取接口当前返回的版本。这不代表页面会触发后台立即检查所有友链,也不代表浏览器会定时轮询。
后端什么时候采集,页面就只能展示到什么时候。 判断数据是否陈旧,应看 link_last_checked_time 和采集日志,而不是只看页面刚刚有没有刷新。
同样,友链“暂时异常”只是某次检查的结果,可能来自网络、证书、防护或超时;“允许抓取”也不等于已经收录文章。不要根据单次异常就自动删除友链。
七、排错与验证
| 现象 | 优先检查 |
|---|---|
| JSON 直接打开正常,页面读取失败 | CORS、HTTPS、浏览器网络面板 |
| 统计数字为空 | 实际字段是否和 statistical_data 定义一致 |
| 新文章迟迟不出现 | 后端采集时间、订阅源和导出结果 |
| 卡片顺序不正确 | created 格式、时区、无效日期 |
| 下一页内容没有网络请求 | 正常,当前使用前端分页 |
在项目目录执行:
pnpm exec eslint app/pages/circle.vue pnpm build
本地调试可以使用:
pnpm dev
打开终端提示的本地地址并访问 /circle,检查四项状态、文章链接、前后翻页和窄屏布局。文章标题使用 Vue 文本插值,而不是把上游内容直接作为 HTML 注入。
三个页面,各司其职
友圈用来发现朋友的更新,动态用来记录自己的碎片,统计用来观察站点的访问和更新节奏。它们都能使用 API 数据,但并不需要采用完全相同的请求方式:友圈读取公开 JSON,动态通过博客服务端筛选内容,统计由服务端聚合数据。
同系列的另外两篇:
把朋友们认真写下的文章放到一个容易发现的地方,让友链不只停留在名单里,也能在阅读中继续产生连接。

评论区
评论加载中...