手把手教你把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),便于前端直接拉取。若跨域受限,可改为服务端代理(本文以前端直连为例)。
一、页面与导航(把入口与路由先接上)
- 新增 moments 页面(多语言路由)
- 文件路径:
src/pages/[...lang]/moments/index.astro - 作用:
- 拉取 Ech0 分页接口
- 解析/归一化条目数据,最大兼容化字段
- 渲染正文、媒体(B 站/YouTube/音乐/音频)与图片画廊
- 提供“加载更多”和“长文折叠”
关键可配项(本文后面的完整示例已内置,位置如下):
- Ech0 基地址:
const base = 'https://你的Ech0域名' - 每页条数:
const pageSize = 30 - 长文折叠高度:
const maxHeight = 480(像素)
- 导航添加入口
- 修改文件:
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' },- 路由高亮与类型识别
- 修改文件:
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:255(normalizeEchoItems) - 加载更多按钮 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 / MUSICextension / 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:737、src/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>,仍不可见多半是网络访问受限。
- 打开开发者工具 Console,运行:
-
B 站短链(b23.tv)或裸
BV/av:- 已支持无协议域名与裸 ID 的识别,但若是极端短链无法解析出目标 BV 号,请直接粘贴
www.bilibili.com/video/BV...的完整链接。
- 已支持无协议域名与裸 ID 的识别,但若是极端短链无法解析出目标 BV 号,请直接粘贴
-
图片相对路径 404:
- 已自动补全为 Ech0 风格的
/api/images/...,若你的部署不同,请在resolveMediaUrl()调整规则(src/pages/[...lang]/moments/index.astro:315)。
- 已自动补全为 Ech0 风格的
-
跨域(CORS):
- 若浏览器提示跨域阻止,请在 Ech0 端增加允许的
Access-Control-Allow-Origin源。
- 若浏览器提示跨域阻止,请在 Ech0 端增加允许的
八、自定义建议(更贴合你的站点)
- 统一文案:在
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, '&').replace(/</g, '<').replace(/>/g, '>').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 告诉我;我会补上扩展篇。祝接入顺利!