[{"data":1,"prerenderedAt":684},["ShallowReactive",2],{"content:\u002Ffriend-circle-page":3,"surround:\u002Ffriend-circle-page":675},{"id":4,"title":5,"body":6,"categories":647,"date":649,"description":650,"draft":651,"extension":652,"image":653,"meta":654,"navigation":656,"path":657,"permalink":657,"published":658,"readingTime":659,"recommend":658,"references":658,"seo":664,"sitemap":665,"stem":666,"tags":667,"type":673,"updated":649,"__hash__":674},"content\u002Fposts\u002F2026\u002Ffriend-circle-page.md","blog-v3 友圈：聚合友链文章",{"type":7,"value":8,"toc":631},"minimark",[9,18,22,25,59,72,75,85,92,96,99,107,112,192,197,260,267,272,283,286,292,295,303,315,319,322,328,334,343,354,358,371,374,384,391,406,410,417,423,426,440,444,450,453,486,489,493,500,509,512,516,575,578,584,587,593,599,603,606,609,624],[10,11,14],"alert",{"title":12,"type":13},"本篇对应博客的 \u002Fcircle 页面","info",[15,16,17],"p",{},"友链页回答“我的朋友是谁”，友圈页回答“朋友们最近写了什么”。这篇记录如何把 Friend-Circle 的公开 JSON 接入 Nuxt 4 + Clarity，展示友链状态和朋友的新文章。",[19,20,21],"h2",{"id":21},"先看清楚数据从哪里来",[15,23,24],{},"这个页面不在浏览器里逐个抓取朋友的博客，而是读取后端已经汇总好的数据。",[26,27,28,31,34,37,53,56],"timeline",{},[15,29,30],{},"{Friend-Circle 后端}",[15,32,33],{},"负责采集文章、检查站点状态，并生成供前端读取的公开 JSON。",[15,35,36],{},"{博客友圈页}",[15,38,39,40,44,45,48,49,52],{},"打开 ",[41,42,43],"code",{"code":43},"\u002Fcircle"," 时，浏览器并行请求 ",[41,46,47],{"code":47},"all.json"," 和 ",[41,50,51],{"code":51},"link.json","，然后渲染状态卡片与文章列表。",[15,54,55],{},"{读者访问原文}",[15,57,58],{},"点击文章卡片跳转到朋友的站点。友圈只提供发现入口，不复制和重新发布完整文章。",[15,60,61,65,66,65,69],{},[62,63,64],"badge",{},"公开 JSON"," ",[62,67,68],{},"每页 6 篇",[62,70,71],{},"按发布时间排序",[15,73,74],{},"本博客使用的两个地址是：",[76,77,83],"pre",{"className":78,"code":80,"language":81,"meta":82},[79],"language-text","https:\u002F\u002Ffc.moodlog.cn\u002Fall.json\nhttps:\u002F\u002Ffc.moodlog.cn\u002Flink.json\n","text","",[41,84,80],{"__ignoreMap":82},[10,86,89],{"title":87,"type":88},"接入前提","warning",[15,90,91],{},"本文讲的是博客前端接入，不包含 Friend-Circle 服务端从零部署。你需要先准备能返回下述字段的后端。如果使用其他版本或其他聚合服务，应以实际响应为准调整字段，不能只把域名换掉就默认兼容。",[19,93,95],{"id":94},"一检查接口和跨域","一、检查接口和跨域",[15,97,98],{},"先确认服务能够返回 JSON，而不是登录页、验证页或反向代理错误页面：",[76,100,105],{"className":101,"code":103,"language":104,"meta":82},[102],"language-sh","curl -fsS https:\u002F\u002Ffc.moodlog.cn\u002Fall.json\ncurl -fsS https:\u002F\u002Ffc.moodlog.cn\u002Flink.json\n","sh",[41,106,103],{"__ignoreMap":82},[15,108,109,111],{},[41,110,47],{"code":47}," 中，页面主要用到：",[113,114,115,128],"table",{},[116,117,118],"thead",{},[119,120,121,125],"tr",{},[122,123,124],"th",{},"字段",[122,126,127],{},"用途",[129,130,131,142,152,162,172,182],"tbody",{},[119,132,133,139],{},[134,135,136],"td",{},[41,137,138],{"code":138},"statistical_data.article_num",[134,140,141],{},"展示已收录文章总数",[119,143,144,149],{},[134,145,146],{},[41,147,148],{"code":148},"article_data[].title",[134,150,151],{},"文章标题",[119,153,154,159],{},[134,155,156],{},[41,157,158],{"code":158},"article_data[].created",[134,160,161],{},"发布时间和排序依据",[119,163,164,169],{},[134,165,166],{},[41,167,168],{"code":168},"article_data[].link",[134,170,171],{},"原文地址与列表 key",[119,173,174,179],{},[134,175,176],{},[41,177,178],{"code":178},"article_data[].author",[134,180,181],{},"作者名称",[119,183,184,189],{},[134,185,186],{},[41,187,188],{"code":188},"article_data[].avatar",[134,190,191],{},"文章卡片图标",[15,193,194,196],{},[41,195,51],{"code":51}," 中，状态卡片读取：",[113,198,199,208],{},[116,200,201],{},[119,202,203,205],{},[122,204,124],{},[122,206,207],{},"含义",[129,209,210,220,230,240,250],{},[119,211,212,217],{},[134,213,214],{},[41,215,216],{"code":216},"link_total_num",[134,218,219],{},"友链总数",[119,221,222,227],{},[134,223,224],{},[41,225,226],{"code":226},"link_reachable_num",[134,228,229],{},"可正常访问的站点数",[119,231,232,237],{},[134,233,234],{},[41,235,236],{"code":236},"link_unreachable_num",[134,238,239],{},"暂时无法访问的站点数",[119,241,242,247],{},[134,243,244],{},[41,245,246],{"code":246},"crawl_allowed_num",[134,248,249],{},"允许抓取的站点数",[119,251,252,257],{},[134,253,254],{},[41,255,256],{"code":256},"link_last_checked_time",[134,258,259],{},"后端最后检查时间",[15,261,262,263,266],{},"这些状态字段都位于 ",[41,264,265],{"code":265},"statistical_data"," 中。后端还可以返回单个站点的详细信息，但当前友圈页展示的是汇总状态，并没有逐站状态表。",[268,269,271],"h3",{"id":270},"终端能访问浏览器却失败怎么办","终端能访问，浏览器却失败怎么办",[15,273,274,275,278,279,282],{},"博客和数据源不在同一个源：",[41,276,277],{"code":277},"blog.moodlog.cn"," 与 ",[41,280,281],{"code":281},"fc.moodlog.cn"," 是不同源。即使同属一个主域名，也需要正确的跨域响应头。",[15,284,285],{},"可以先检查：",[76,287,290],{"className":288,"code":289,"language":104,"meta":82},[102],"curl -sI \\\n  -H 'Origin: https:\u002F\u002Fblog.moodlog.cn' \\\n  https:\u002F\u002Ffc.moodlog.cn\u002Fall.json\n",[41,291,289],{"__ignoreMap":82},[15,293,294],{},"如果数据服务只供自己的博客读取，可以按服务端实际配置限定允许来源：",[76,296,301],{"className":297,"code":299,"language":300,"meta":82},[298],"language-http","Access-Control-Allow-Origin: https:\u002F\u002Fblog.moodlog.cn\nVary: Origin\n","http",[41,302,299],{"__ignoreMap":82},[15,304,305,306,314],{},"这不是让前端添加请求头，而是要求数据服务或反向代理返回正确的响应头。本地预览来源也应单独考虑。",[307,308,309,310,313],"strong",{},"不要用 ",[41,311,312],{"code":312},"mode: 'no-cors'"," 绕过问题","，那样拿到的不透明响应无法读取 JSON。",[19,316,318],{"id":317},"二新建-nuxt-页面","二、新建 Nuxt 页面",[15,320,321],{},"在项目中创建：",[76,323,326],{"className":324,"code":325,"language":81,"meta":82},[79],"app\u002Fpages\u002Fcircle.vue\n",[41,327,325],{"__ignoreMap":82},[15,329,330,331,333],{},"Nuxt 会自动生成 ",[41,332,43],{"code":43}," 路由，无需再创建同名 Markdown 页面。下面是一份用于理解接入流程的精简实现，包含四项状态、最新文章、六篇分页和失败提示。现有项目已经完成接入时，不必覆盖原文件。",[76,335,341],{"className":336,"code":338,"filename":339,"language":340,"meta":82},[337],"language-vue","\u003Cscript setup lang=\"ts\">\ninterface CircleArticle {\n  title: string\n  created: string\n  link: string\n  author: string\n  avatar: string\n}\n\ninterface CircleAll {\n  statistical_data: { article_num: number }\n  article_data: CircleArticle[]\n}\n\ninterface CircleLinkData {\n  statistical_data: {\n    link_total_num: number\n    link_reachable_num: number\n    link_unreachable_num: number\n    crawl_allowed_num: number\n    link_last_checked_time: string\n  }\n}\n\nconst origin = 'https:\u002F\u002Ffc.moodlog.cn'\nconst allData = ref\u003CCircleAll>()\nconst linkData = ref\u003CCircleLinkData>()\nconst loading = ref(false)\nconst error = ref('')\nconst page = ref(1)\nconst pageSize = 6\n\nuseSeoMeta({ title: '友圈', description: '朋友们的新文章与友链状态。' })\n\nconst sortedArticles = computed(() => {\n  return [...(allData.value?.article_data ?? [])].sort((a, b) => {\n    const difference = new Date(b.created.replace(' ', 'T')).getTime()\n    \t- new Date(a.created.replace(' ', 'T')).getTime()\n    return difference || a.link.localeCompare(b.link)\n  })\n})\nconst pageCount = computed(() => Math.ceil(sortedArticles.value.length \u002F pageSize))\nconst articles = computed(() => {\n  const start = (page.value - 1) * pageSize\n  return sortedArticles.value.slice(start, start + pageSize)\n})\nconst metrics = computed(() => {\n  const stats = linkData.value?.statistical_data\n  return stats\n    ? [\n        { label: '友链总数', value: stats.link_total_num },\n        { label: '正常运行', value: stats.link_reachable_num },\n        { label: '暂时异常', value: stats.link_unreachable_num },\n        { label: '允许抓取', value: stats.crawl_allowed_num },\n      ]\n    : []\n})\n\nasync function readJson\u003CT>(path: string) {\n  const response = await fetch(`${origin}\u002F${path}?_=${Date.now()}`, {\n    cache: 'no-store',\n  })\n  if (!response.ok)\n    throw new Error(`HTTP ${response.status}`)\n  return await response.json() as T\n}\n\nasync function loadData() {\n  if (loading.value)\n    return\n  loading.value = true\n  error.value = ''\n  try {\n    const [all, links] = await Promise.all([\n      readJson\u003CCircleAll>('all.json'),\n      readJson\u003CCircleLinkData>('link.json'),\n    ])\n    allData.value = all\n    linkData.value = links\n    page.value = 1\n  }\n  catch {\n    error.value = '暂时无法获取友圈数据，请检查网络后刷新重试。'\n  }\n  finally {\n    loading.value = false\n  }\n}\n\nonMounted(loadData)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n\u003Cdiv class=\"circle-content\">\n\t\u003Ch1>友圈\u003C\u002Fh1>\n\t\u003CAlert v-if=\"error\" type=\"warning\" title=\"数据获取失败\">\n\t\t\u003Cp>{{ error }}\u003C\u002Fp>\n\t\u003C\u002FAlert>\n\t\u003Cp v-else-if=\"loading\">\n\t\t正在加载友圈…\n\t\u003C\u002Fp>\n\n\t\u003Ctemplate v-else-if=\"allData && linkData\">\n\t\t\u003Ch2>友链状态\u003C\u002Fh2>\n\t\t\u003Cp>最后检查于 {{ linkData.statistical_data.link_last_checked_time }}\u003C\u002Fp>\n\t\t\u003Cdl class=\"circle-stats\">\n\t\t\t\u003Cdiv v-for=\"metric in metrics\" :key=\"metric.label\" class=\"circle-stat card\">\n\t\t\t\t\u003Cdt>{{ metric.label }}\u003C\u002Fdt>\n\t\t\t\t\u003Cdd>{{ metric.value }}\u003C\u002Fdd>\n\t\t\t\u003C\u002Fdiv>\n\t\t\u003C\u002Fdl>\n\n\t\t\u003Ch2>朋友们的新文章\u003C\u002Fh2>\n\t\t\u003Cp>共收录 {{ allData.statistical_data.article_num }} 篇文章\u003C\u002Fp>\n\t\t\u003Cdiv class=\"circle-articles\">\n\t\t\t\u003CLinkCard\n\t\t\t\tv-for=\"article in articles\"\n\t\t\t\t:key=\"article.link\"\n\t\t\t\t:link=\"article.link\"\n\t\t\t\t:title=\"article.title\"\n\t\t\t\t:description=\"`${article.author} · ${article.created}`\"\n\t\t\t\t:icon=\"article.avatar\"\n\t\t\t\u002F>\n\t\t\u003C\u002Fdiv>\n\t\t\u003Cp v-if=\"!articles.length\">\n\t\t\t暂时还没有收录文章。\n\t\t\u003C\u002Fp>\n\t\t\u003Cnav v-if=\"pageCount > 1\" class=\"circle-pagination\" aria-label=\"文章分页\">\n\t\t\t\u003Cbutton type=\"button\" :disabled=\"page \u003C= 1\" @click=\"page--\">\n\t\t\t\t上一页\n\t\t\t\u003C\u002Fbutton>\n\t\t\t\u003Cspan>{{ page }} \u002F {{ pageCount }}\u003C\u002Fspan>\n\t\t\t\u003Cbutton type=\"button\" :disabled=\"page >= pageCount\" @click=\"page++\">\n\t\t\t\t下一页\n\t\t\t\u003C\u002Fbutton>\n\t\t\u003C\u002Fnav>\n\t\u003C\u002Ftemplate>\n\u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003Cstyle scoped>\n.circle-content { min-width: 0; margin: 1rem; }\n.circle-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.75rem; }\n.circle-stat { min-width: 0; padding: 1rem; border: 1px solid var(--c-border); }\n.circle-stat dt { color: var(--c-text-2); font-size: 0.85rem; }\n.circle-stat dd { margin: 0.6rem 0 0; font-size: 1.8rem; font-variant-numeric: tabular-nums; }\n.circle-articles { display: grid; gap: 0.75rem; }\n.circle-articles :deep(.link-card) { width: auto; min-width: 0; margin: 0; }\n.circle-pagination { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.75rem; margin-top: 1rem; }\n@media (max-width: 600px) {\n  .circle-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n}\n\u003C\u002Fstyle>\n","app\u002Fpages\u002Fcircle.vue","vue",[41,342,338],{"__ignoreMap":82},[15,344,345,346,349,350,353],{},"这段代码依赖本项目的 Clarity ",[41,347,348],{"code":348},"Alert","、",[41,351,352],{"code":352},"LinkCard"," 与主题变量，并不是任意 Nuxt 空项目都自带的组件。换用其他主题时，要相应替换它们。生产环境也应根据数据来源补充运行时结构校验和请求超时；TypeScript 类型断言本身并不验证接口数据。",[19,355,357],{"id":356},"三排序和分页为什么放在前端","三、排序和分页为什么放在前端",[15,359,360,362,363,366,367,370],{},[41,361,47],{"code":47}," 返回的是已收录文章集合。当前实现先按 ",[41,364,365],{"code":365},"created"," 从新到旧排序，再通过 ",[41,368,369],{"code":369},"slice()"," 选择当前页，每页展示六篇。",[15,372,373],{},"这里有两个小细节：",[375,376,377,381],"ol",{},[378,379,380],"li",{},"先复制数组再排序，不直接修改接口返回的原数组。",[378,382,383],{},"相同时间使用原文链接作次级排序，避免同一批文章反复交换位置。",[10,385,388],{"title":386,"type":387},"这是展示分页，不是后端分页","tip",[15,389,390],{},"点击下一页不会重新请求接口，也不会降低首次下载的 JSON 大小。如果后端将来收录成千上万篇文章，应评估服务端分页或限制导出数量，而不是只调整每页条数。",[15,392,393,394,397,398,401,402,405],{},"时间格式也需要约定。当前页面会把 ",[41,395,396],{"code":396},"2026-10-16 12:00:00"," 中的空格换为 ",[41,399,400],{"code":400},"T"," 后交给 ",[41,403,404],{"code":404},"Date"," 解析；若接入不同数据源，优先使用带时区的 ISO 时间。缺少时区或混合格式可能导致排序与显示产生偏差。",[19,407,409],{"id":408},"四添加导航入口","四、添加导航入口",[15,411,412,413,416],{},"在 ",[41,414,415],{"code":415},"app\u002Fapp.config.ts"," 的现有导航数组中加入一项，不要覆盖整个配置：",[76,418,421],{"className":419,"code":420,"language":81,"meta":82},[79],"{ icon: 'tabler:heart-handshake', text: '友圈', url: '\u002Fcircle' },\n",[41,422,420],{"__ignoreMap":82},[15,424,425],{},"上面是一条数组项片段，实际应放入项目已有的导航数组中。本博客已经配置好这项入口，无需重复添加。",[15,427,428,429,431,432,435,436,439],{},"现有 ",[41,430,43],{"code":43}," 页面还包含移动端 ",[41,433,434],{"code":434},"BlogHeader","、按页码跳转按钮，以及底部 ",[41,437,438],{"code":438},"PostComment"," 评论区。它们属于主题页面整合，和 Friend-Circle 数据采集本身是分开的。",[19,441,443],{"id":442},"五手机端和主题保持一致","五、手机端和主题保持一致",[15,445,446,447,449],{},"友链状态在桌面端为四列，手机端改成两列。文章列表保持一列，复用 Clarity 的 ",[41,448,352],{"code":352},"，避免文章标题和作者信息被挤在过窄的卡片中。",[15,451,452],{},"样式上优先使用主题变量：",[454,455,456,462,468,474,480],"ul",{},[378,457,458,461],{},[41,459,460],{"code":460},"--c-border","：边框",[378,463,464,467],{},[41,465,466],{"code":466},"--c-text-2","：辅助文字",[378,469,470,473],{},[41,471,472],{"code":472},"--c-primary","：主色",[378,475,476,479],{},[41,477,478],{"code":478},"--c-success","：正常状态",[378,481,482,485],{},[41,483,484],{"code":484},"--c-warning","：异常提醒",[15,487,488],{},"颜色不应成为唯一提示。例如“暂时异常”应同时有文字和图标，不能只把数字改成红色。",[19,490,492],{"id":491},"六理解实时的边界","六、理解“实时”的边界",[15,494,495,496,499],{},"页面使用时间戳查询参数和 ",[41,497,498],{"code":498},"cache: 'no-store'","，进入页面时读取接口当前返回的版本。这不代表页面会触发后台立即检查所有友链，也不代表浏览器会定时轮询。",[15,501,502,505,506,508],{},[307,503,504],{},"后端什么时候采集，页面就只能展示到什么时候。"," 判断数据是否陈旧，应看 ",[41,507,256],{"code":256}," 和采集日志，而不是只看页面刚刚有没有刷新。",[15,510,511],{},"同样，友链“暂时异常”只是某次检查的结果，可能来自网络、证书、防护或超时；“允许抓取”也不等于已经收录文章。不要根据单次异常就自动删除友链。",[19,513,515],{"id":514},"七排错与验证","七、排错与验证",[113,517,518,528],{},[116,519,520],{},[119,521,522,525],{},[122,523,524],{},"现象",[122,526,527],{},"优先检查",[129,529,530,538,549,557,567],{},[119,531,532,535],{},[134,533,534],{},"JSON 直接打开正常，页面读取失败",[134,536,537],{},"CORS、HTTPS、浏览器网络面板",[119,539,540,543],{},[134,541,542],{},"统计数字为空",[134,544,545,546,548],{},"实际字段是否和 ",[41,547,265],{"code":265}," 定义一致",[119,550,551,554],{},[134,552,553],{},"新文章迟迟不出现",[134,555,556],{},"后端采集时间、订阅源和导出结果",[119,558,559,562],{},[134,560,561],{},"卡片顺序不正确",[134,563,564,566],{},[41,565,365],{"code":365}," 格式、时区、无效日期",[119,568,569,572],{},[134,570,571],{},"下一页内容没有网络请求",[134,573,574],{},"正常，当前使用前端分页",[15,576,577],{},"在项目目录执行：",[76,579,582],{"className":580,"code":581,"language":104,"meta":82},[102],"pnpm exec eslint app\u002Fpages\u002Fcircle.vue\npnpm build\n",[41,583,581],{"__ignoreMap":82},[15,585,586],{},"本地调试可以使用：",[76,588,591],{"className":589,"code":590,"language":104,"meta":82},[102],"pnpm dev\n",[41,592,590],{"__ignoreMap":82},[15,594,595,596,598],{},"打开终端提示的本地地址并访问 ",[41,597,43],{"code":43},"，检查四项状态、文章链接、前后翻页和窄屏布局。文章标题使用 Vue 文本插值，而不是把上游内容直接作为 HTML 注入。",[19,600,602],{"id":601},"三个页面各司其职","三个页面，各司其职",[15,604,605],{},"友圈用来发现朋友的更新，动态用来记录自己的碎片，统计用来观察站点的访问和更新节奏。它们都能使用 API 数据，但并不需要采用完全相同的请求方式：友圈读取公开 JSON，动态通过博客服务端筛选内容，统计由服务端聚合数据。",[15,607,608],{},"同系列的另外两篇：",[454,610,611,618],{},[378,612,613],{},[614,615,617],"a",{"href":616},"\u002Fmemos-dynamic-page","用 Memos API 给 Nuxt 博客添加动态页",[378,619,620],{},[614,621,623],{"href":622},"\u002Fumami-analytics-page","给 Nuxt 博客添加访问统计页：Umami 趋势与 Vercel 推送节奏",[625,626,628],"quote",{"icon":627},"tabler:heart-handshake",[15,629,630],{},"把朋友们认真写下的文章放到一个容易发现的地方，让友链不只停留在名单里，也能在阅读中继续产生连接。",{"title":82,"searchDepth":632,"depth":632,"links":633},4,[634,636,640,641,642,643,644,645,646],{"id":21,"depth":635,"text":21},2,{"id":94,"depth":635,"text":95,"children":637},[638],{"id":270,"depth":639,"text":271},3,{"id":317,"depth":635,"text":318},{"id":356,"depth":635,"text":357},{"id":408,"depth":635,"text":409},{"id":442,"depth":635,"text":443},{"id":491,"depth":635,"text":492},{"id":514,"depth":635,"text":515},{"id":601,"depth":635,"text":602},[648],"博客折腾","2026-10-16","在 blog-v3 中接入 Friend-Circle 的 all.json 和 link.json，展示友链状态、最新文章，并使用 Clarity 卡片完成排序、分页与手机端适配。",false,"md","https:\u002F\u002Fimg.moodlog.cn\u002Fi\u002F1\u002Fd532dc9feada45d523229056fedbe26c\u002F6ad04cfad3063.webp",{"slots":655},{},true,"\u002Ffriend-circle-page",null,{"text":660,"minutes":661,"time":662,"words":663},"13 min read",12.155,729300,2431,{"title":5,"description":650},{"loc":657},"posts\u002F2026\u002Ffriend-circle-page",[668,669,670,671,672],"Nuxt","Friend-Circle","Clarity","友圈","blog-v3","story","N6k-nlStCOA4d1HkfglZO7MfSgBYRpfuxY6cuq1IhWU",[676,681],{"title":677,"path":678,"stem":679,"date":680,"type":673,"children":-1},"写给未来的第一封信","\u002Fwelcome","posts\u002F2026\u002Fwelcome","2026-10-12",{"title":682,"path":616,"stem":683,"date":649,"type":673,"children":-1},"blog-v3 动态：接入 Memos","posts\u002F2026\u002Fmemos-dynamic-page",1792159297513]