手把手教你把Ech0说说接入Astro博客

22 min

本文是基于本主题的教程:一步一步、复制即用、解释到位。我们会把 Ech0 的「说说」接入 Astro + Retypeset 主题,并做到:

  • 页面路由与导航集成
  • 基于 Ech0 API 的数据拉取与字段映射
  • 媒体自动嵌入:Bilibili / YouTube / 音乐(网易云、QQ 音乐、Spotify)/ 直链音频 / 图片
  • 主题适配:圆角、画廊、长文折叠
  • 分页与缓存:加载更多按钮、会话级缓存
  • 常见问题(CORS、短链、扩展字段)与调试

下文所有路径基于 Retypeset 主题默认结构(src/*)。若你的项目结构不同,请据此调整。

准备工作

  • 一个可访问的 Ech0 实例(文中示例为 https://moments.xzi.cc)。
  • 主题版本:Retypeset(Astro 5)。
  • 浏览器可直连 Bilibili/YouTube 等外链播放器域名(否则可能看不到播放容器)。

可选:在 Ech0 服务端开启允许的跨域源(CORS),便于前端直接拉取。若跨域受限,可改为服务端代理(本文以前端直连为例)。

一、页面与导航(把入口与路由先接上)

  1. 新增 moments 页面(多语言路由)
  • 文件路径:src/pages/[...lang]/moments/index.astro
  • 作用:
    • 拉取 Ech0 分页接口
    • 解析/归一化条目数据,最大兼容化字段
    • 渲染正文、媒体(B 站/YouTube/音乐/音频)与图片画廊
    • 提供“加载更多”和“长文折叠”

关键可配项(本文后面的完整示例已内置,位置如下):

  • Ech0 基地址:const base = 'https://你的Ech0域名'
  • 每页条数:const pageSize = 30
  • 长文折叠高度:const maxHeight = 480(像素)
  1. 导航添加入口
  • 修改文件:src/components/Navbar.astro
  • navItems 数组里加一个“说说/Moments”项(紧跟在 Albums 后面更自然):
{
  href: '/moments/',
  label: (currentUI as any).moments ?? (String(currentLang).startsWith('zh') ? '说说' : 'Moments'),
  className: getNavItemClass(isMomentsActive),
},
  • 解释:
    • href 指向我们即将新增的 /moments/ 页面。
    • 文案优先取 i18n 里的 moments,没有则中文回退“说说”,其他语言回退“Moments”。
    • isMomentsActive 通过后文第 3 步暴露,控制导航高亮。

可选:在 src/i18n/ui.ts 为每种语言新增键 moments,例如:

// 以中文与英文为例,其他语言同理
'zh': { /* ... */ moments: '说说' },
'en': { /* ... */ moments: 'Moments' },
  1. 路由高亮与类型识别
  • 修改文件:src/utils/page.ts
  • 添加一个页面类型判断函数,并在 getPageInfo() 里返回:
export function isMomentsPage(path: string) {
  return matchPageType(path, 'moments')
}

export function getPageInfo(path: string) {
  const currentLang = getLangFromPath(path)
  const isHome = isHomePage(path)
  const isPost = isPostPage(path)
  const isTag = isTagPage(path)
  const isAlbum = isAlbumPage(path)
  const isAbout = isAboutPage(path)
  const isMoments = isMomentsPage(path) // ← 新增

  return {
    currentLang,
    isHome,
    isPost,
    isTag,
    isAlbum,
    isAbout,
    isMoments, // ← 新增
    getLocalizedPath: (targetPath: string) =>
      getLocalizedPath(targetPath, currentLang),
  }
}

二、Ech0 API 对接(分页,含容错与缓存)

本实现默认使用 Ech0 的分页接口:

  • URL:POST {ECH0_BASE}/api/echo/page
  • 请求体:{ page: number, pageSize: number }
  • 示例:第一页 30 条
curl -sX POST https://moments.xzi.cc/api/echo/page \
  -H 'Content-Type: application/json' \
  -d '{"page":1,"pageSize":30}'

解析逻辑要点:

  • 数据提取优先 result.data.items,若结构不同,退化到“首个数组字段”提取(兼容不同部署)
  • 会话缓存:sessionStorage(1 分钟),减少重复拉取
  • 分页:点击“加载更多”拉取下一页并追加

对应代码:

  • 拉取与分页:src/pages/[...lang]/moments/index.astro:702 起的 loadMoments()
  • 通用数组提取:src/pages/[...lang]/moments/index.astro:172
  • 归一化 items:src/pages/[...lang]/moments/index.astro:255normalizeEchoItems
  • 加载更多按钮 DOM:src/pages/[...lang]/moments/index.astro:21 与逻辑:src/pages/[...lang]/moments/index.astro:750

三、字段映射与兼容策略(为什么这样写)

为适配不同 Ech0 部署的返回结构,做了“宽松、可拓展”的映射:

  • 时间:优先 created_at / createdAt / created_ts / createdTs / created / date / time / timestamp / pubDate
  • 正文:优先 content_html / html / content / text / message / body / desc
    • 若为 HTML,先做安全清理,再替换链接为嵌入
    • 若为纯文本,执行“自动链接 + 媒体识别嵌入”
  • 图片(多来源):images / assets / image_urls / imageUrls / media / attachments / files 的各元素里尝试 image_url / imageUrl / url / src / path / href
    • 同时从正文中的 <img> 与图片直链提取
  • 附加链接补齐:从 assets/attachments/media/files 中抽取 externalLink / url / src / href,补充为视频/音乐嵌入
  • 扩展字段(Ech0 特有):
    • extension_type / extensionType / ext_type / extType → 大写后判断 VIDEO / MUSIC
    • extension / ext 可为字符串或 JSON,支持:
      • 直接 URL
      • { url | link | pageUrl | page_url | videoUrl | video }
      • { platform | site, id | bvid | bv | videoId | video_id }
    • 对应解析:parseExtension()createVideoEmbedFromExtension()(见下方完整示例)

对应代码:

  • 映射/归一化:src/pages/[...lang]/moments/index.astro:255
  • 图片提取:src/pages/[...lang]/moments/index.astro:191
  • 扩展解析:src/pages/[...lang]/moments/index.astro:520

四、媒体自动嵌入(圆角 + 主题风格 + 回退)

已适配的媒体类型:

  • YouTube:优先使用 <lite-youtube>(更轻量),若脚本未加载,600ms 内自动回退到标准 <iframe>(保证可见)
  • Bilibili:支持 BV...av12345 以及 URL 中的 BV 号;播放器使用官方 blackboard/html5mobileplayer,16
    + 最小高度兜底
  • 音乐:网易云(歌曲 id)、QQ 音乐(songid/bid)、Spotify(track/…),以及直链音频(mp3/m4a/aac/flac/ogg/wav)

所有播放器最外层统一 rounded-md overflow-hidden,和主题图片圆角一致。

渲染后会对 DOM 做一次增强:

  • 将正文中的 <a href> 和纯文本 URL 变为“可嵌入的播放器”或安全外链
  • 支持无协议的域名(如 b23.tv/...www.bilibili.com/...)与裸 BV/av
  • 入口:enhanceEmbeds()src/pages/[...lang]/moments/index.astro:115

YouTube 初始化与回退:

  • 动态引入 lite-youtube-embed,失败或未升级则替换为标准 <iframe>,确保可见
  • 逻辑:src/pages/[...lang]/moments/index.astro:660

五、长文折叠(可切换,为什么按高度折叠)

  • 规则:正文渲染后,若内容高度超过 480px,自动折叠并在条目末尾追加按钮
  • 切换文案:中文“展开/收起”,英文“Show more/Collapse”
  • 我们根据渲染后的 scrollHeight 来判断是否折叠,能覆盖纯文本、带图片/播放器等复杂排版,比按字数/行数更准确。
  • 调整阈值:修改上述位置的 const maxHeight = 480

六、分页与缓存(加载更多 + 会话缓存)

  • 默认每页 30 条(pageSize),可按需调整(src/pages/[...lang]/moments/index.astro:711
  • “加载更多”在底部,若下一页为空会隐藏按钮(src/pages/[...lang]/moments/index.astro:737src/pages/[...lang]/moments/index.astro:799
  • 会话缓存 1 分钟,键名形如 ech0-cache-v2:{base}src/pages/[...lang]/moments/index.astro:709

七、常见问题与调试(踩坑点一网打尽)

  • 看不到播放器但无报错?

    • 打开开发者工具 Console,运行:
      • Array.from(document.querySelectorAll('iframe')).map(i => i.src)
      • document.querySelectorAll('lite-youtube').length
    • 若 YouTube Lite 未升级,600ms 内会自动替换为 <iframe>,仍不可见多半是网络访问受限。
  • B 站短链(b23.tv)或裸 BV/av

    • 已支持无协议域名与裸 ID 的识别,但若是极端短链无法解析出目标 BV 号,请直接粘贴 www.bilibili.com/video/BV... 的完整链接。
  • 图片相对路径 404:

    • 已自动补全为 Ech0 风格的 /api/images/...,若你的部署不同,请在 resolveMediaUrl() 调整规则(src/pages/[...lang]/moments/index.astro:315)。
  • 跨域(CORS):

    • 若浏览器提示跨域阻止,请在 Ech0 端增加允许的 Access-Control-Allow-Origin 源。

八、自定义建议(更贴合你的站点)

  • 统一文案:在 src/i18n/ui.ts 为各语言添加 moments 键,替换导航处的回退逻辑。
  • 主题色:播放器容器使用了 rounded-md overflow-hidden,如需更明显的分隔,可在外层再加 border 或背景色(与当前主题协调即可)。
  • 图片点击放大:我们已在页面根加了 view-image 属性,并复用了全站的图片查看器(ImageZoom)。

九、验收清单(Checklist)

  • /moments/ 可正常打开并展示最新 30 条说说
  • Bilibili/YouTube/音乐/音频/图片均可显示,并带有圆角
  • 长文出现“展开/收起”按钮
  • 点击“加载更多”后附加下一页
  • 导航“说说/Moments”正确高亮

到这里,你就完成了 Ech0 与 Astro(Retypeset 主题)的深度集成。下面给出一份“可复制即用”的 moments 页面完整示例(包含上文所有关键点),以及每段代码的说明。


十、完整示例(可直接新建 src/pages/[...lang]/moments/index.astro 粘贴)

说明:

  • 已包含分页、会话缓存、长文折叠、圆角样式、Bili/YouTube/音乐嵌入、附件/扩展字段解析、图片相对路径修正、YouTube 回退等。
  • 你只需要把 const base = 'https://moments.xzi.cc' 改成你自己的 Ech0 地址即可。
---
import { allLocales } from '@/config'
import { getLangFromLocale, getLangRouteParam } from '@/i18n/lang'
import Layout from '@/layouts/Layout.astro'

export const prerender = true

export async function getStaticPaths() {
  return allLocales.map(lang => ({ params: { lang: getLangRouteParam(lang) } }))
}

const currentLang = getLangFromLocale(Astro.currentLocale)
---

<Layout>
  <div class="uno-decorative-line" />
  <section id="moments-root" class="space-y-8" view-image>
    <div id="moments-loading" class="py-8 text-center text-3.8 c-secondary">
      {String(currentLang).startsWith('zh') ? '加载中…' : 'Loading…'}
    </div>
    <div id="moments-error" class="hidden py-8 text-center text-3.8 c-secondary"></div>
    <div id="moments-list" class="space-y-8"></div>
    <div id="moments-loadmore" class="hidden py-6 text-center">
      <button
        type="button"
        data-role="load-more"
        class="inline-flex items-center gap-2 border border-secondary/50 rounded-full px-4 py-1.5 text-3.6 transition-[colors,box-shadow] hover:(border-secondary/80 c-primary)"
      >
        {String(currentLang).startsWith('zh') ? '加载更多' : 'Load More'}
      </button>
    </div>
  </section>
</Layout>

<script>
// 1) 小工具函数:清理 HTML,防注入
function sanitize(html: string): string {
  const tpl = document.createElement('template')
  tpl.innerHTML = html
  ;['script', 'iframe', 'object', 'embed', 'link', 'style'].forEach(t =>
    tpl.content.querySelectorAll(t).forEach(el => el.remove())
  )
  tpl.content.querySelectorAll('*').forEach((el) => {
    for (const a of Array.from(el.attributes)) {
      const n = a.name.toLowerCase()
      const v = a.value.trim()
      if (n.startsWith('on'))
        el.removeAttribute(a.name)
      if ((n === 'href' || n === 'src') && v.startsWith('javascript:'))
        el.removeAttribute(a.name)
    }
  })
  return tpl.innerHTML
}

// 2) 媒体识别(YouTube/Bili/音乐/音频)
function extractYouTubeId(url: string): string | null {
  try {
    const u = new URL(url)
    if (u.hostname.includes('youtu.be'))
      return u.pathname.split('/')[1] || null
    if (u.hostname.includes('youtube.com')) {
      const id = u.searchParams.get('v')
      if (id)
        return id
      const parts = u.pathname.split('/')
      const i = parts.findIndex(p => p === 'shorts' || p === 'embed')
      if (i >= 0 && parts[i + 1])
        return parts[i + 1]
    }
  }
  catch {}
  return null
}
function extractBilibiliBVID(s: string): string | null {
  const m1 = s.match(/bilibili\.com\/video\/((BV\w{10,})|(av\d{1,10}))/i)
  if (m1)
    return m1[1]
  const m2 = s.match(/(BV\w{10,})/i)
  if (m2)
    return m2[1]
  const m3 = s.match(/av(\d{1,10})/i)
  if (m3)
    return `av${m3[1]}`
  return null
}
function normalizeUrl(input: string): string {
  const s = (input || '').trim()
  if (!s)
    return ''
  if (/^https?:\/\//i.test(s))
    return s
  if (s.startsWith('//'))
    return `${window.location.protocol}${s}`
  if (/^(www\.|b23\.tv\/|bilibili\.com\/|youtube\.com\/|youtu\.be\/)/i.test(s))
    return `https://${s.replace(/^\/+/, '')}`
  return s
}
function createEmbedForLink(url: string): string | null {
  const u = normalizeUrl(url)
  const yid = extractYouTubeId(u)
  if (yid)
    return `<div class=\"my-4 media-embed rounded-md overflow-hidden\"><lite-youtube videoid=\"${yid}\"></lite-youtube></div>`
  const bvid = extractBilibiliBVID(u)
  if (bvid) {
    const isBV = /^BV/i.test(bvid)
    const p = isBV ? `bvid=${encodeURIComponent(bvid)}` : `aid=${encodeURIComponent(bvid.replace(/^av/i, ''))}`
    return `<div class=\"my-4 media-embed rounded-md overflow-hidden\"><iframe class=\"bilibili-player\" style=\"width:100%;aspect-ratio:16/9;border:0;min-height:220px;\" loading=\"lazy\" src=\"https://www.bilibili.com/blackboard/html5mobileplayer.html?${p}&as_wide=1&high_quality=1&danmaku=0\" allowfullscreen=\"true\"></iframe></div>`
  }
  if (/music\.163\.com/.test(u)) {
    try {
      const id = new URL(u).searchParams.get('id'); if (id)
        return `<div class=\"my-4 media-embed rounded-md overflow-hidden\"><iframe frameborder=\"0\" width=\"100%\" height=\"110\" src=\"//music.163.com/outchain/player?type=2&id=${id}&auto=0&height=66\" style=\"border:0;display:block;\"></iframe></div>`
    }
    catch {}
  }
  if (/y\.qq\.com/.test(u)) {
    try {
      const url = new URL(u); let id = url.searchParams.get('songid') || ''; if (!id)
        id = url.pathname.match(/song(?:Detail)?\/(\w+)/i)?.[1] || url.pathname.match(/song\/(\w+)\.html/i)?.[1] || ''; if (id)
        return `<div class=\"my-4 media-embed rounded-md overflow-hidden\"><iframe frameborder=\"0\" width=\"100%\" height=\"110\" src=\"https://i.y.qq.com/n2/m/outchain/player/index.html?songid=${id}&auto=0\" style=\"border:0;display:block;\"></iframe></div>`
    }
    catch {}
  }
  if (/open\.spotify\.com\/track\//.test(u)) {
    try { const url = new URL(u); return `<div class=\"my-4 media-embed rounded-md overflow-hidden\"><iframe src=\"https://open.spotify.com/embed${url.pathname}\" width=\"100%\" height=\"152\" frameborder=\"0\" allow=\"autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture\" loading=\"lazy\" style=\"border:0;display:block;\"></iframe></div>` }
    catch {}
  }
  if (/\.(mp3|m4a|aac|flac|ogg|wav)(\?|$)/i.test(u))
    return `<div class=\"my-4 media-embed rounded-md overflow-hidden\"><audio controls preload=\"metadata\" src=\"${u}\" style=\"display:block;width:100%\"></audio></div>`
  return null
}
function enhanceEmbeds(container: HTMLElement) {
  // 替换正文 a[href] 为播放器
  container.querySelectorAll('a[href]').forEach((a) => {
    const href = (a as HTMLAnchorElement).getAttribute('href') || ''
    const e = createEmbedForLink(href)
    if (e) { const t = document.createElement('template'); t.innerHTML = e; a.replaceWith(t.content) }
  })
  // 纯文本 URL / 无协议域名 / 裸 BV/av
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
  const urlRe = /https?:\/\/[^\s<]+/g
  const domRe = /\b((?:https?:\/\/)?(?:www\.)?(?:b23\.tv|bilibili\.com|youtu\.be|youtube\.com)\/[^\s<]+)/gi
  const idRe = /(BV\w{10,}|av\d{1,10})/gi
  const nodes: Text[] = []
  let n: Node | null
  while ((n = walker.nextNode())) {
    const t = n as Text; if (t.parentElement && !['SCRIPT', 'STYLE'].includes(t.parentElement.tagName)) {
      if (urlRe.test(t.data) || domRe.test(t.data) || idRe.test(t.data))
        nodes.push(t)
    }
  }
  nodes.forEach((t) => {
    const text = t.data; const parts: (string | Node)[] = []; let last = 0
    const push = (raw: string) => {
      const e = createEmbedForLink(raw); if (e) { const tpl = document.createElement('template'); tpl.innerHTML = e; parts.push(tpl.content) }
      else { const a = document.createElement('a'); a.href = normalizeUrl(raw); a.target = '_blank'; a.rel = 'noopener'; a.textContent = raw; parts.push(a) }
    }
    text.replace(urlRe, (m, off) => {
      if (off > last)
        parts.push(text.slice(last, off)); push(m); last = off + m.length; return m
    })
    if (last < text.length) {
      const rest = text.slice(last); let sub = 0; rest.replace(domRe, (m, _1, off) => {
        const abs = normalizeUrl(m); if (off > sub)
          parts.push(rest.slice(sub, off)); push(abs); sub = off + m.length; return m
      }); if (sub < rest.length)
        parts.push(rest.slice(sub))
    }
    if (parts.length > 0) { const f = document.createDocumentFragment(); parts.forEach(p => typeof p === 'string' ? f.appendChild(document.createTextNode(p)) : f.appendChild(p)); t.replaceWith(f) }
  })
}

// 3) 图片相对路径修正(Ech0 常用 /api/images/...)
function resolveMediaUrl(url: string, endpoint: string, kind: 'image' | 'avatar' | 'generic' = 'generic') {
  if (!url)
    return ''
  const trimmed = url.trim()
  if (/^https?:\/\//i.test(trimmed))
    return trimmed
  if (trimmed.startsWith('//'))
    return `${location.protocol}${trimmed}`
  let origin = ''
  try { origin = new URL(endpoint).origin }
  catch { origin = location.origin }
  const n = trimmed.replace(/^\.\//, '').replace(/^\/+/, '')
  if (kind !== 'generic') {
    if (n.startsWith('api/images/'))
      return `${origin}/${n}`
    if (n.startsWith('api/'))
      return `${origin}/${n}`
    if (n.startsWith('images/'))
      return `${origin}/api/${n}`
    return `${origin}/api/images/${n}`
  }
  return `${origin}/${n}`
}

// 4) 数据归一化与分页
type Raw = Record<string, any>
interface Item { id: string, createdAt: Date, html: string, images: string[] }
function toDate(v: any): Date {
  if (v == null)
    return new Date(); if (typeof v === 'number')
    return new Date(v < 2e10 ? v * 1000 : v); if (typeof v === 'string') {
    const n = Number(v); if (!Number.isNaN(n))
      return toDate(n); return new Date(v)
  } return new Date()
}
function pickArrayFromResponse(json: any): any[] {
  if (Array.isArray(json))
    return json; if (Array.isArray(json?.data))
    return json.data; if (Array.isArray(json?.list))
    return json.list; for (const k of Object.keys(json || {})) {
    const v = (json as any)[k]; if (Array.isArray(v))
      return v
  } return []
}
function pickFirstString(...vals: any[]): string | null {
  for (const v of vals) {
    if (typeof v === 'string' && v.trim())
      return v
  } return null
}
function pickImages(it: Raw): string[] {
  const out: string[] = []
  const push = (u: any) => {
    if (!u)
      return; if (typeof u === 'string') { out.push(u); return } const c = [u.image_url, u.imageUrl, u.url, u.src, u.path, u.href]; for (const x of c) {
      if (typeof x === 'string' && x.trim()) { out.push(x); break }
    }
  }
  ;[it.images, it.assets, it.image_urls, it.imageUrls, it.media, it.attachments, it.files].forEach((arr: any) => {
    if (Array.isArray(arr))
      arr.forEach(push)
  })
  const content = pickFirstString(it.content_html, it.content, it.text, it.message, it.desc, it.body) || ''
  const re = /(https?:\/\/[^\s"']+\.(?:png|jpe?g|gif|webp|avif))[^\s"']*/gi; let m: RegExpExecArray | null
  while ((m = re.exec(content)) !== null) out.push(m[1])
  if (/<img\s/i.test(content)) {
    const t = document.createElement('template'); t.innerHTML = content; t.content.querySelectorAll('img').forEach((img) => {
      const s = (img.getAttribute('src') || '').trim(); if (s)
        out.push(s)
    })
  }
  return Array.from(new Set(out))
}
function parseExtension(it: Raw): { type: string, payload: any } {
  const typeRaw = it?.extension_type ?? it?.extensionType ?? it?.ext_type ?? it?.extType ?? ''
  const type = typeof typeRaw === 'string' ? typeRaw.toUpperCase() : ''
  let payload: any = it?.extension ?? it?.ext ?? null
  if (typeof payload === 'string') {
    const s = payload.trim(); if (s.startsWith('{') || s.startsWith('[')) {
      try { payload = JSON.parse(s) }
      catch {}
    }
    else {
      payload = s
    }
  }
  return { type, payload }
}
function createVideoEmbedFromExtension(payload: any): string | null {
  let value = ''
  if (typeof payload === 'string')
    value = payload
  else if (payload && typeof payload === 'object')
    value = payload.url ?? payload.link ?? payload.pageUrl ?? payload.page_url ?? payload.videoUrl ?? payload.video ?? ''
  if (typeof value !== 'string' || !value.trim())
    return null
  const v = value.trim()
  if (/^BV\w+/i.test(v) || /^av\d{1,10}$/i.test(v)) {
    const isBV = /^BV/i.test(v); const id = isBV ? `bvid=${encodeURIComponent(v)}` : `aid=${encodeURIComponent(v.replace(/^av/i, ''))}`
    return `<div class=\"my-4 media-embed rounded-md overflow-hidden\"><iframe class=\"bilibili-player\" style=\"width:100%;aspect-ratio:16/9;border:0;min-height:220px;\" loading=\"lazy\" src=\"https://www.bilibili.com/blackboard/html5mobileplayer.html?${id}&as_wide=1&high_quality=1&danmaku=0\" allowfullscreen=\"true\"></iframe></div>`
  }
  const u = normalizeUrl(v)
  const m = u.match(/BV\w+/i)
  if (m)
    return `<div class=\"my-4 media-embed rounded-md overflow-hidden\"><iframe class=\"bilibili-player\" style=\"width:100%;aspect-ratio:16/9;border:0;min-height:220px;\" loading=\"lazy\" src=\"https://www.bilibili.com/blackboard/html5mobileplayer.html?bvid=${encodeURIComponent(m[0])}&as_wide=1&high_quality=1&danmaku=0\" allowfullscreen=\"true\"></iframe></div>`
  const yid = extractYouTubeId(u)
  if (yid)
    return `<div class=\"my-4 media-embed rounded-md overflow-hidden\"><iframe style=\"width:100%;aspect-ratio:16/9;border:0;min-height:220px;\" loading=\"lazy\" src=\"https://www.youtube.com/embed/${encodeURIComponent(yid)}\" title=\"YouTube video player\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" allowfullscreen></iframe></div>`
  return null
}
function normalizeItems(arr: Raw[], endpoint: string): Item[] {
  return arr.map((it, idx) => {
    const id = String(it.id ?? it.nid ?? it.uuid ?? it._id ?? it.created_at ?? it.createdAt ?? idx)
    const createdRaw = it.created_at ?? it.createdAt ?? it.created_ts ?? it.createdTs ?? it.created ?? it.date ?? it.time ?? it.timestamp ?? it.pubDate
    const createdAt = toDate(createdRaw)
    const rawHtml = pickFirstString(it.content_html, it.html, it.content, it.text, it.message, it.body, it.desc) || ''
    let html = ''
    if (/<\w+/.test(rawHtml)) { const safe = sanitize(rawHtml); html = safe.replace(/<a\s+href="([^"]+)"[^>]*>[^<]*<\/a>/g, (full, href: string) => createEmbedForLink(href) || full) }
    else { html = (rawHtml || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') }
    const extraLinks: string[] = []
    const scanAsset = (a: any) => {
      if (!a || typeof a !== 'object')
        return; const c = [a.externalLink, a.link, a.url, a.src, a.href]; c.forEach((v) => {
        if (typeof v === 'string' && /^https?:\/\//i.test(v))
          extraLinks.push(v)
      })
    }
    ;[it.assets, it.attachments, it.media, it.files].forEach((arr: any) => {
      if (Array.isArray(arr))
        arr.forEach(scanAsset)
    })
    const { type: extType, payload: extPayload } = parseExtension(it)
    const extEmbeds: string[] = []
    if (extType === 'VIDEO') {
      const v = createVideoEmbedFromExtension(extPayload); if (v)
        extEmbeds.push(v)
    }
    else if (extType === 'MUSIC') {
      let target = ''; if (typeof extPayload === 'string')
        target = extPayload; else if (extPayload && typeof extPayload === 'object')
        target = extPayload.url ?? extPayload.link ?? extPayload.pageUrl ?? ''; const m = target ? createEmbedForLink(target) : null; if (m)
        extEmbeds.push(m)
    }
    const linkEmbeds = extraLinks.map(createEmbedForLink).filter(Boolean) as string[]
    const allEmbeds = [...extEmbeds, ...linkEmbeds]
    if (allEmbeds.length > 0)
      html += allEmbeds.map(e => `\n<div class=\"my-3\">${e}</div>`).join('')
    let images = pickImages(it).map(u => resolveMediaUrl(u, endpoint, 'image'))
    return { id, createdAt, html, images }
  })
}

// 5) 拉取分页 + 渲染 + 折叠 + 加载更多
async function ensureYouTubeUpgraded() {
  const els = Array.from(document.querySelectorAll('lite-youtube'))
  if (els.length === 0)
    return
  let ok = false; try { // @ts-expect-error
    await import('lite-youtube-embed'); ok = true
  }
  catch (e) { console.error(e) }
  setTimeout(() => {
    document.querySelectorAll('lite-youtube').forEach((el) => {
      const vid = el.getAttribute('videoid') || ''; if (!vid)
        return
      if ((el as any).shadowRoot == null && el.children.length === 0) {
        const f = document.createElement('iframe'); f.src = `https://www.youtube.com/embed/${vid}`; f.title = 'YouTube Video'; f.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'; f.allowFullscreen = true; f.loading = 'lazy'; f.style.width = '100%'; f.style.border = '0'; (f.style as any).aspectRatio = '16/9'; (f as any).height = '315'; el.replaceWith(f)
      }
    })
  }, ok ? 600 : 0)
}
function renderItems(list: Item[], append = false) {
  const root = document.getElementById('moments-list') as HTMLElement
  if (!append)
    root.innerHTML = ''
  list.forEach((m) => {
    const article = document.createElement('article')
    article.className = 'moment-item group relative'
    const meta = document.createElement('div'); meta.className = 'text-3.5 c-secondary font-time'; meta.textContent = new Intl.DateTimeFormat(document.documentElement.lang || 'zh', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(m.createdAt); article.appendChild(meta)
    const content = document.createElement('div'); content.className = 'heti mt-2 text-3.8 leading-relaxed'; content.innerHTML = m.html; enhanceEmbeds(content); article.appendChild(content)
    queueMicrotask(() => {
      const maxHeight = 480; if (content.scrollHeight > maxHeight) {
        content.style.maxHeight = `${maxHeight}px`; content.style.overflow = 'hidden'; const b = document.createElement('button'); b.type = 'button'; b.className = 'mt-2 inline-flex items-center gap-1 rounded-full border border-secondary/50 px-3 py-0.5 text-3.4 transition-[colors,box-shadow] hover:(c-primary border-secondary/80)'; const zh = document.documentElement.lang.startsWith('zh'); b.textContent = zh ? '展开' : 'Show more'; b.addEventListener('click', () => {
          const col = content.style.maxHeight !== ''; if (col) { content.style.maxHeight = ''; b.textContent = zh ? '收起' : 'Collapse' }
          else { content.style.maxHeight = `${maxHeight}px`; b.textContent = zh ? '展开' : 'Show more' }
        }); article.appendChild(b)
      }
    })
    if (m.images?.length) { const g = document.createElement('div'); g.className = 'gallery-container'; m.images.forEach((src) => { const it = document.createElement('div'); it.className = 'gallery-item'; const img = document.createElement('img'); img.src = src; img.alt = 'moment image'; img.className = 'rounded-md object-cover max-h-60'; it.appendChild(img); g.appendChild(it) }); article.appendChild(g) }
    const sep = document.createElement('div'); sep.className = 'mt-8 h-px bg-secondary/15'; article.appendChild(sep)
    root.appendChild(article)
  })
}
async function fetchPage(base: string, page: number, pageSize: number): Promise<Item[]> {
  const endpoint = `${base.replace(/\/$/, '')}/api/echo/page`
  const r = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page, pageSize }) })
  if (!r.ok)
    throw new Error(`Ech0 request failed: ${r.status}`)
  const json = await r.json(); const raw = Array.isArray(json?.data?.items) ? json.data.items : pickArrayFromResponse(json)
  return normalizeItems(raw, endpoint)
}
async function loadMoments() {
  const loading = document.getElementById('moments-loading') as HTMLElement
  const errorBox = document.getElementById('moments-error') as HTMLElement
  const loadMoreWrap = document.getElementById('moments-loadmore') as HTMLElement
  const loadMoreBtn = document.querySelector('#moments-loadmore [data-role="load-more"]') as HTMLButtonElement | null
  const base = 'https://moments.xzi.cc' // ← 改成你的 Ech0 地址
  const cacheKey = `ech0-cache-v2:${base}`
  let currentPage = 1; const pageSize = 30; let all: Item[] = []
  try {
    const cached = sessionStorage.getItem(cacheKey)
    if (cached) { const { ts, items } = JSON.parse(cached); if (Date.now() - ts < 60_000) { all = items; renderItems(all); await ensureYouTubeUpgraded(); loading?.classList.add('hidden'); loadMoreWrap?.classList.toggle('hidden', all.length < pageSize); return } }
    const items = await fetchPage(base, currentPage, pageSize); all = items; renderItems(all); await ensureYouTubeUpgraded(); document.dispatchEvent(new Event('astro:page-load')); sessionStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), items })); loading?.classList.add('hidden'); errorBox?.classList.add('hidden'); loadMoreWrap?.classList.toggle('hidden', items.length < pageSize)
  }
  catch (err) { console.error(err); loading?.classList.add('hidden'); if (errorBox) { errorBox.classList.remove('hidden'); const zh = document.documentElement.lang.startsWith('zh'); errorBox.innerHTML = zh ? '无法加载说说数据。请确认 Ech0 接口可用,或修改页面脚本中的地址。' : 'Failed to load moments. Please verify Ech0 API or update the base URL.' } }
  if (loadMoreBtn) {
    let busy = false; loadMoreBtn.addEventListener('click', async () => {
      if (busy)
        return; busy = true; const zh = document.documentElement.lang.startsWith('zh'); const prev = loadMoreBtn.textContent || ''; loadMoreBtn.textContent = zh ? '加载中…' : 'Loading…'; try {
        currentPage += 1; const next = await fetchPage(base, currentPage, pageSize); if (next.length) { renderItems(next, true); await ensureYouTubeUpgraded(); document.dispatchEvent(new Event('astro:page-load')) } if (next.length < pageSize)
          loadMoreWrap?.classList.add('hidden')
      }
      catch (e) { console.error(e); loadMoreWrap?.classList.add('hidden') }
      finally { loadMoreBtn.textContent = prev; busy = false }
    })
  }
}
document.addEventListener('astro:page-load', loadMoments)
loadMoments()
</script>

为什么这样设计:

  • “边解析边增强”:Ech0 返回的正文可能是 HTML,也可能是纯文本;我们对 HTML 做安全清理,对 a 链接和纯文本 URL 再替换为播放器,尽量“所见即所得”。
  • 图片相对路径:常见部署会把图片挂在 /api/images/ 下,这里做了自动补全;你的部署若不同,按 resolveMediaUrl() 的注释自行调整。
  • YouTube 兜底:lite-youtube 未成功升级时自动回退 <iframe>,避免“看不到”的尴尬。
  • 分页 + 会话缓存:减少接口压力,同时支持“加载更多”。

如果你希望教程包含更多平台(如 Vimeo 等)或更灵活的过滤/搜索,请在本文评论区/项目 issue 告诉我;我会补上扩展篇。祝接入顺利!