本文覆盖 src/components/ 下全部 21 个 Astro 组件(src/components/ 13 个通用组件 + src/components/views/ 8 个页面级组件)与 1 个布局。

组件分层约定
项目把组件分成两层,理解这一点能省很多力气:

  • 页面级组件 src/components/views/*.astro:承载某个页面的完整内容(标题区、列表、空状态),只接收数据 props;
  • 路由文件: src/pages/*.astro:只做「取数据 → 传给页面组件 → 包一层 BaseLayout」的薄封装。

所以「改页面长相」去 components/views/,「改 URL 或取数逻辑」去 src/pages/。

1. 组件总览

组件路径Props引入的 CSS位置/角色
Iconcomponents/Icon.astrod, class?—基础原子:把所有 MDI 图标统一成 1em 的 SVG
SiteHeadercomponents/SiteHeader.astrolang?— (用 Tailwind)常驻顶部导航(桌面横排 + 移动 <details> 菜单)
SiteFootercomponents/SiteFooter.astrolang?—常驻页脚(版权、运行天数、RSS、Sitemap)
SearchModalcomponents/SearchModal.astrolang?—常驻搜索弹窗(Pagefind 检索)
ScrollControlscomponents/ScrollControls.astrolang?, showTocButton?ScrollControls.css常驻右下按钮组(目录/主题/搜索/回顶)
ProfileCardcomponents/ProfileCard.astrolang?ProfileCard.css首页个人名片 + 站点统计(下方社交条与卡片共边,条内左端按钮可刷漆切换到数码管时钟;时钟视图见 DigitalClock)
DigitalClockcomponents/DigitalClock.astrolang?—(复用 ProfileCard.css 的 .clock-layer 块)名片内的数码管时钟:时区 / 七段数码管 / 日期视图 + 走时与刷漆切换逻辑,由 ProfileCard 渲染在卡片内(切换按钮留在名片社交条,脚本按 data-clock-toggle 从 .profile-card 根节点取)
PostCardcomponents/PostCard.astrolang?, postPostCard.css首页文章卡片(与名片同款「另一套主题」预览方窗,共用 lib/pointerPreview.ts)
Paginationcomponents/Pagination.astrolang?, currentPage, totalPages, baseUrl内联 <style>首页分页
Commentscomponents/Comments.astro—comments.css评论区挂载容器(由 siteConfig.commentScript 决定是否渲染)
MusicPlayercomponents/MusicPlayer.astrolang?music-player.css常驻右下磁铁音乐播放器
Petcomponents/Pet.astrolang?Pet.css常驻左下角宠物挂件(仅 siteConfig.pet.enabled 为 true 时渲染):8×8 方格描边轮廓 + 两只方块眼睛,视线跟随指针 / 眨眼 / 休眠 / 滚轮与触屏滑出;长按弹出「请去休息」气泡,确认后 24 小时内不再出现(退场为方格消解、回归为方格聚合),逻辑在 lib/client/pet.ts
HomePagecomponents/views/HomePage.astrolang?, posts, currentPage, totalPages, baseUrl—首页内容
PostPagecomponents/views/PostPage.astrolang?, post, prevPost?, nextPost?, seriesPosts?, seriesIdx?PostPage.css;katex 与 photoswipe 的 CSS 均按需注入文章详情页(有系列时含系列目录折叠面板)。灯箱 CSS 随 JS 按需加载:只有正文有图片、真要初始化 PhotoSwipe 时才注入 photoswipe/style.css;KaTeX 的 CSS 同理,只在出现 .katex 时注入
SeriesToccomponents/SeriesToc.astrolang?, seriesPosts, postId—系列目录折叠面板(由 PostPage 抽出):条目编号 + 标题,当前项 bg-accent/10 text-ink + 左侧 border-l-2 border-accent 竖条;折叠状态由组件自身脚本按 #series-toggle 幂等绑定
PostNavcomponents/PostNav.astrolang?, prev?, next?—上一篇 / 下一篇(由 PostPage 抽出):无相邻文章时渲染灰态占位;有系列时 PostPage 传入的是系列内相邻篇
ArchivePagecomponents/views/ArchivePage.astrolang?, posts—归档 / 分类详情 / 标签详情(三处复用)
TaxonomyPagecomponents/views/TaxonomyPage.astrolang?, kind, items—分类总览(kind="categories")/ 标签云(kind="tags"),搜索 + 排序逻辑共用一份
SeriesIndexPagecomponents/views/SeriesIndexPage.astrolang?, seriesList—系列总索引(系列名 + 篇数)
SeriesDetailPagecomponents/views/SeriesDetailPage.astrolang?, name, posts—系列总览(按 series_order 排序)
FriendsPagecomponents/views/FriendsPage.astrolang?, friendsFriendsPage.css友链网格 + 空状态插画
AboutPagecomponents/views/AboutPage.astro无 Props(不声明也不使用任何 prop)—关于页(内容为硬编码)
BaseLayoutlayouts/BaseLayout.astro见 #5main.css(全局)唯一布局

所有组件的 lang 都默认为 defaultLang(即 siteConfig.defaultLang)。当前项目所有路由都传 defaultLang,因为只构建默认语言页面(详见 07-页面与路由.md#5)。

PostPage 的三个补充说明

  1. CSS 按需注入:katex/dist/katex.min.css 只有在正文出现 .katex 时才注入,photoswipe/style.css 只有在正文有图片、真要初始化灯箱时才注入。两者都用 ?inline 取 CSS 文本后写入 <style data-…-css>,因为 astro.config.mjs 的 build.inlineStylesheets: 'always' 会把动态 import 出来的 CSS 资源内联进每个文章页 HTML 并删掉资源文件(无公式 / 无图片的页面也会被内联,且公式页会多出一个 404 请求)。

  2. 系列目录当前项:浅色模式为 bg-accent/10 text-ink + 左侧 border-l-2 border-accent 竖条(实测对比度 11.97:1);暗色模式维持原样 dark:text-accent 且不描边(4.91:1)。早先的 text-accent 浅色下仅 4.48:1,不达 WCAG AA 4.5:1。

  3. 拆分:系列目录与上下篇导航分别抽成 SeriesToc.astro / PostNav.astro;客户端逻辑抽到 lib/photoswipe.ts(灯箱)、lib/postActions.ts(复制按钮 + 锚点滚动)、lib/postContent.ts(图片框载入态 + 滚动淡入)、lib/postToc.ts(目录定位 / 折叠 / 当前项跟踪),PostPage.astro 只保留数据计算、视图组合与 onPageLoad 编排。

2. 基础原子组件

2.1 Icon.astro

astro
1
<Icon d={mdiCalendarOutline} class="text-sm" />
Prop类型必填默认说明
dstring是—SVG <path> 的 d 属性,来自 src/lib/icons.ts 的导出常量
classstring否—追加到根 <svg>,会与 mdi-icon 类合并

输出结构:

html
12
<svg class="mdi-icon text-sm" width="1em" height="1em" viewBox="0 0 24 24"
     fill="currentColor" aria-hidden="true"><path d="…"></path></svg>

要点:

  • 尺寸用 1em,所以通过字体大小 class 控制图标大小(text-sm / text-lg / text-[11px]);
  • 颜色继承 currentColor,跟随文字色;
  • 固定 aria-hidden="true",装饰性图标不进无障碍树。需要语义时请在父元素上写 aria-label(如 ScrollControls 的 title)。

2.2 客户端字符串版本 iconSvg()

有些地方需要在构建期拼 HTML 字符串(代码块头部、复制按钮),不能渲染组件,于是 src/lib/icons.ts 提供了同名函数:

ts
123
import { iconSvg, type IconName } from '@/lib/icons'
iconSvg('mdi-content-copy')          // → '<svg class="mdi-icon" …></svg>'
iconSvg('mdi-check', 'text-green')   // 第二参数追加 class
参数类型说明
nameIconNameicons 映射表的 key,kebab-case 全名
clsstring追加 class,默认 ''

3. 常驻组件(位于 #swup-container 之外)

3.1 SiteHeader.astro

Prop类型默认说明
langLangdefaultLang取词语言

导航项 = 注册表 + 配置白名单两级:SiteHeader.astro 内的 NAV_DEFS 登记每个可用 path 的词条 key 与图标,siteConfig.nav(字符串数组)决定「显示哪些、什么顺序」;首页 / 固定第一位、不可移除,无需写入配置:

ts
1234567891011
// src/site.config.ts
nav: ['/archive', '/categories', '/tags', '/series', '/friends', '/about'],

// SiteHeader.astro
const navConfig = siteConfig.nav ?? []                              // 缺失/类型非法时回退空数组
const navItems = [
  { path: '/', label: t('nav.home'), icon: mdiHomeOutline },        // 固定第一项
  ...[...new Set(Array.isArray(navConfig) ? navConfig : [])]
    .filter((path) => path !== '/' && path in NAV_DEFS)             // 去重、忽略未登记的 path
    .map((path) => ({ path, label: t(NAV_DEFS[path].key), icon: NAV_DEFS[path].icon })),
]

现有导航页(/archive ~ /about)只需在 siteConfig.nav 里增删/排序,空数组 [] 则顶栏只剩首页一项;新增自定义页面还要在 NAV_DEFS 登记词条 key 与图标 + 在三个 i18n 的 JSON 里加对应 nav.* 词条。图标来自 src/lib/icons.ts(MDI path 常量)。

结构要点:

元素关键 class / 属性说明
<header>sticky top-0 z-[100];内联 background-color: var(--wp-header-bg) + backdrop-filter: blur(3px)毛玻璃吸顶。背景色改 --wp-header-bg
桌面导航 <nav>absolute left-1/2 -translate-x-1/2 max-md:hidden绝对居中、项间无间隔,与左右两侧 Logo / 菜单互不影响;md 以下切换为汉堡菜单
导航项.nav-item + data-nav-path={localePath(lang, item.path)}初始只显示图标;宽度以 .nav-label 文字为基准恒定(图标绝对定位覆盖、不参与布局),hover 前后宽度不变,响应式切换无布局抖动;data-nav-path 供 BaseLayout 的 syncNavState() 做高亮;hover 背景板被 .nav-clip 裁剪在 --nav-window(默认 60×60)固定窗口内;zh 双字加 padding 后项宽≈60px,窗口天然相贴。窗口尺寸只有一个来源:.nav-item 上的 --nav-window: 60px,.nav-clip 的宽高与 .nav-compact .nav-content 的宽度都取它,fitNavLabels() 也运行时读它。词条宽于「--nav-window − 4px」(默认 56px,左右各留 2px 余量)时(如 en/ja),fitNavLabels() 按「最宽词条」算出统一缩放比例、全组同字号缩小(下限具名常量 MIN_FONT_SIZE = 10px,Web 字体加载完成后复校一次),并给每项加 .nav-compact 类把 .nav-content 固定为 --nav-window 宽、去掉横向 padding,使 clip 窗口与项同宽、相邻项背景板无缝相贴;词条不缩写、字号一致,zh 不触发、完全不变
hover 背景板.nav-clip > .nav-board(组件内 <style>)hover / :focus-visible 时主题色矩形板(无圆角、inset: 0 高度宽度占满整个导航项)从上方拉下(translateY(-101%) → 0),下拉与收起统一用 cubic-bezier(0.4, 0, 0.2, 1) 平滑缓动,无回弹
图文替换.nav-icon(原生 <span> 包裹 <Icon>,绝对定位居中)与 .nav-label(常驻布局、opacity 显隐)常态只显示居中图标;触发时图标先完全淡出(0.16s)、名称再淡入(延迟 0.16s),两个方向都先出后进、任意时刻绝不同时可见,hover 只显示居中文字;背景为主题色、文字变白。注意 Astro 作用域样式不穿透子组件,<Icon> 必须包一层原生 <span> 才能被组件内 <style> 命中
当前项常驻.nav-item.text-accent 复用 hover 全部样式syncNavState() 切换的 text-accent 即状态钩子,无需改 JS;继续 hover 无变化
兜底项data-nav-path="__fallback__"当没有任何导航项匹配当前路径时,由它承接[全部取消高亮]的状态
移动端hidden max-md:block 容器内 <details class="group relative"> + <summary> + 绝对定位面板纯 CSS/HTML 折叠菜单,无 JS 状态;md 以下替代桌面导航

交互脚本(SiteHeader.astro 底部 <script>):点击 header details 外部时移除 open 属性关闭菜单;同一脚本内的 fitNavLabels() 按 --nav-window 缩放导航词条(选择器限定为 header nav .nav-label),并在 document.fonts.ready 后再复校一次。

首页高亮特例:isHomePaged = /^\/\d+(\/|$)/.test(currentPath),即 /2/、/3/ 这类分页路径也视为[首页]高亮。

3.2 SiteFooter.astro

Prop类型默认
langLangdefaultLang

构建期取最早一篇文章的发布时间,运行时长(天/时/分/秒)由客户端脚本每秒实时计算。published 在 content.config.ts 中按 UTC 解析(无时区字符串补 Z),因此存储的是真实 UTC 时刻;运行时长 = 访问者当前 UTC(Date.now() 即 UTC epoch)− 发布 UTC,不依赖构建机、也不依赖访问者的本地时区,所有访问者看到一致的结果:

ts
123456789
// 构建期:只确定起点,并把初始时长写进页面作为无 JS 的回退显示。
// earliest 已是真实 UTC 时刻(schema 层按 UTC 解析)
const publishedPosts = await getPublishedPosts()
const earliest = publishedPosts.reduce<Date | null>(
  (min, p) => (min === null || p.data.published < min ? p.data.published : min),
  null,
)
function breakdown(ms: number) { /* …拆分天/时/分/秒… */ }
const buildUnits = earliest ? breakdown(Date.now() - earliest.valueOf()) : null
ts
123456789101112131415161718192021222324252627282930
// 客户端:当前时间与最早文章时间的差值实时计算,精确到秒(<script> in SiteFooter.astro)
function breakdown(ms: number) {
  const total = Math.max(0, Math.floor(ms / 1000))
  return {
    days: Math.floor(total / 86400),
    hours: Math.floor((total % 86400) / 3600),
    minutes: Math.floor((total % 3600) / 60),
    seconds: total % 60,
  }
}
function updateRunningTime() {
  document.querySelectorAll<HTMLElement>('[data-running-days]').forEach((el) => {
    const iso = el.dataset.earliest
    const template = el.dataset.template
    if (!iso || !template) return
    const u = breakdown(Date.now() - new Date(iso).getTime())
    const text = template
      .replaceAll('{days}', String(u.days))
      .replaceAll('{hours}', String(u.hours))
      .replaceAll('{minutes}', String(u.minutes))
      .replaceAll('{seconds}', String(u.seconds))
    if (el.textContent !== text) el.textContent = text
  })
}
updateRunningTime()
document.addEventListener('astro:page-load', updateRunningTime)
// 每秒刷新,秒位实时跳动;重复执行时只挂一个定时器
if (!('__runningTimeTimer' in window)) {
  ;(window as any).__runningTimeTimer = setInterval(updateRunningTime, 1000)
}
显示项条件文案 key备注
© {year} {author} All rights reserved总是—年份取构建时的 new Date().getUTCFullYear(),作者链接指向 siteConfig.url
Powered by Astro & CitrusGrid总是—纯静态文本
RSS / Sitemap 链接总是footer.rss / footer.sitemapRSS 走 localePath(lang, '/rss.xml');Sitemap 硬编码 /sitemap-index.xml
运行时长(天/时/分/秒)earliest 存在(即至少有一篇文章)footer.running({days} {hours} {minutes} {seconds} 占位)无文章时不显示

运行时长的实现细节:

  • <p data-running-days> 上挂 data-earliest(ISO 时间,带 Z,构建期写入)和 data-template(当前语言的 footer.running 文案),服务端先渲染构建期算好的初始值。
  • 完全实时,不受构建固定:首次加载、Swup 换页(astro:page-load)、以及每秒的定时器都会用客户端当前时间与最早文章时间的差值重算四个单位;仅文本变化时写 DOM,避免无谓重排。页面一直开着,秒位也会实时跳动并自动进位。
  • 语义为"经过的时长"(毫秒差拆分为天/时/分/秒),时区无关。

3.3 SearchModal.astro

Prop类型默认
langLangdefaultLang

DOM 关键 id(改样式时直接定位这些 id):

选择器作用
#search-modal最外层遮罩,初始 hidden,打开时加 flex;data-more-hits 携带「本文还有 N 处命中」的文案模板({n} 占位)
#search-input输入框,input 事件触发搜索
#search-clear清空按钮,有内容时移除 hidden
#search-results结果容器(hidden / flex 切换)
#search-empty「没有匹配的文章」
#search-failed「搜索索引不可用」(仅 dev 下出现)
#search-hint输入提示,有输入时隐藏

行为参数(源码内常量,可直接调):

常量值位置作用
pagefindUrl'/pagefind/pagefind.js'loadPagefind()索引路径,与 postbuild.js 中 pagefind --site dist 的输出目录对应
结果条数上限20res.results.slice(0, 20)防止一次渲染过多 DOM
子结果条数上限3(MAX_SUB_RESULTS)renderSubResults()单篇文章最多展示 3 个命中章节,超出显示「本文还有 N 处命中」
打开事件document 上的 open-search 自定义事件document.addEventListener('open-search', open)由 ScrollControls 的搜索按钮派发
关闭时机Escape / 点遮罩 / 点 #search-close 走 close();astro:after-swap 与点结果跳转走 close(false)—动画:遮罩 wp-fade-out + 面板 wp-pop-out,0.16s 淡出后置 hidden;prefers-reduced-motion 或跳转时瞬时关闭;关闭途中重新打开会取消定时器防闪烁
跳转方式带锚点时接入跨页锚点机制(hash 记到 window.__pendingScrollHash,swup.navigate() 用去掉 hash 的 URL,加载后平滑滚动);同页带锚点直接调用 window.__scrollToHeadingAnchor 平滑滚动,不刷新页面;无锚点 / 无 swup 时退化为 location.assigngo()搜索结果(含章节级子结果)能精确滚动到命中位置,见 07-页面与路由.md#84

搜索结果渲染的字段(与 Pagefind 返回结构对应):

ts
12345678
item.url              // 结果链接(页面级,无 hash)
item.meta?.title      // 标题(缺省回退到 url)
item.excerpt          // 页面级摘要(Pagefind 自带高亮 <mark>),仅在没有子结果时兜底显示
item.sub_results      // 章节级子结果:按带 id 的 h1~h6 拆分页面,命中多的章节优先
//   sub.url         // 形如 /posts/xxx/#章节id(中文锚点已百分号编码)
//   sub.title       // 章节标题(首个无锚点子结果是页面本身,渲染时跳过)
//   sub.excerpt     // 该章节内的高亮摘录
//   sub.anchor      // { element, id, text, location },用于去重与回退取标题

excerpt 是 Pagefind 生成的 HTML,未经 escapeHtml,这是有意为之(保留高亮 <mark>,用 [&_mark]: 任意属性选择器上主题色);url 与 title 走了 escapeHtml。

结果卡片结构:文章标题(点击进文章顶部)→ 分隔线 → 命中章节列表(最多 MAX_SUB_RESULTS 条,点击滚动到对应标题)。文章无带 id 的标题时退化为「标题 + 页面级摘录」。

3.4 ScrollControls.astro

Prop类型默认说明
langLangdefaultLang取词
showTocButtonbooleanfalse是否渲染目录按钮(仅文章页传 true)

右侧整合菜单:底部一个常驻「菜单」触发器(data-scroll-menu,汉堡图标,只负责展开/收起),点击后其余按钮从触发器左侧横向弹出(在 .scroll-control-nav 上加 .is-open);从左到右:目录 → 主题 → 搜索 → 回顶(回顶最靠近触发器)。滚动、点击外部、按 Esc 或换页(astro:before-swap)都会自动收起。

弹层内按钮(从左到右,回顶最靠近触发器):

按钮关键属性触发方式备注
回顶data-backtopBaseLayout 委托点击 → window.scrollTo({behavior:'smooth'})常驻显示,无滚动显隐判断
搜索data-open-searchBaseLayout 委托点击 → 派发 open-search—
主题data-theme-togglestores/theme.ts 的全局委托点击图标由 syncThemeButtons() 动态替换(日/月)
目录id="toc-fab",hidden={!showTocButton}PostPage.astro 里绑定 click → openTocModal()ScrollControls.css 中 @media (min-width: 1280px) { .toc-btn { display: none } }:桌面端隐藏,因为桌面上用 xl:block 的侧边固定目录

定位:触发器 fixed right-5 bottom-8 z-[90] max-md:right-3 max-md:bottom-16,弹出层用绝对定位锚定在其左侧(right: calc(100% + 8px); bottom: 0),横向排开。启用音乐播放器时,音乐 FAB 由 MusicPlayer.reposition() 固定在右下底部槽位(桌面 32 / 移动 64),菜单触发器随之挪到音乐按钮正上方(原搜索位置)。

桌面侧边目录(#toc-affix,仅 xl 且有目录时出现)自带收起/展开:标题行右侧的 #toc-collapse 按钮把面板淡出,同时在右下菜单触发按钮正上方浮出 #toc-expand FAB(复用 scroll-control-btn 样式;位置由 PostPage 的 positionTocExpandFab() 读取 [data-scroll-menu] 矩形实时计算,音乐按钮启用时菜单上移、FAB 跟随,音乐仍保持最底;菜单弹层向左弹出后不占用该位置,菜单展开时 FAB 无需隐藏)。收起状态存于模块变量 tocCollapsed,换文章页时由 initPage() 重新应用(跨文章保持)。移动端(<xl)不渲染该控件,仍走 toc-fab 弹窗目录。

⚠️ hidden 属性必须靠 CSS 显式覆盖。ScrollControls.css 里有 .scroll-control-btn[hidden] { display: none },因为 display: inline-flex 优先级高于 hidden 属性的默认样式。自定义按钮时别丢这条规则。

3.5 Pet.astro

Prop:lang?: Lang(默认 defaultLang)。宠物本体没有文案,但长按弹出的「请去休息」气泡需要翻译,用到 pet 下的 dialogLabel / title / desc / confirm 四个词条(三份语言包齐全,由 scripts/check-i18n.js 保证键集合一致);另有 pet.restore 供名片上的「唤回宠物」按钮使用。

渲染条件与结构:

astro
1
const petEnabled = siteConfig.pet?.enabled === true
html
1234567891011121314151617
<div class="pet" data-pet>
  <div class="pet-face" data-pet-face aria-hidden="true">
    <div class="pet-slide" data-pet-slide>
      <div class="pet-eyes" data-pet-eyes>
        <span class="pet-eye" data-pet-eye></span>
        <span class="pet-eye" data-pet-eye></span>
      </div>
    </div>
  </div>
  <div class="pet-card" data-pet-card role="dialog" aria-label="…" hidden>
    <p class="pet-card-title">…</p>
    <p class="pet-card-desc">…</p>
    <div class="pet-card-actions">
      <button class="pet-btn pet-btn-ok" type="button" data-pet-dismiss>…</button>
    </div>
  </div>
</div>

各层各管一件事(Pet.css + lib/client/pet.ts):.pet 定位与尺寸;.pet-face 承载描边与辉光,装饰本体整体带 aria-hidden="true"(气泡是对外可读的对话框,所以放在它外面、与本体同级);.pet-slide 只做纵向滑出位移;.pet-eyes 只做视线位移;.pet-eye 只做 scaleY 眨眼。消解 / 聚合期间由 JS 临时插进 .pet-face 的方格层 .pet-grid(子元素 .pet-cell)不写进模板,见下文「方格消解与聚合」。组件还内嵌一段 is:inline 守卫脚本,见下文「休息期与首帧守卫」。

外形:.pet 是 8×8 个 --pet-unit 的方框,.pet-face::before 用 border: var(--pet-unit) solid var(--pet-ink) 描出一圈 1 格厚的方边(z-index: 2),中间留出 6×6 的空洞当「脸」;两只 .pet-eye 各 1 格宽 × 2.5 格高、间距 1 格(合计 3 格宽),--pet-ink: var(--wp-accent),外圈再带一层主色 35% 的柔光(box-shadow 的 --pet-glow)。.pet-face 自身 overflow: hidden,滑出的眼睛到边沿就被裁掉,看起来是从框里滑出去。所以宠物完全由主色与方格组成,没有图片资源。这套 8×8 几何同时也是消解 / 聚合动画的基础:那圈 1 格厚的描边正好等于网格的最外圈(28 格),JS 按行列生成等大的方格就与描边完全重合,不必另算坐标。

尺寸与定位(Pet.css 顶部的变量):

变量取值说明
--pet-anchor-xJS 写入#swup-container > main 的左边缘视口坐标,即内容玻璃卡左界
--pet-unitclamp(11.2px, min(1.9444vw, 3.8889vh, (anchor − 28px) / 8), 28px)一个「格」的边长:等价于 28px × min(vw/1440, vh/720, 可用边距/224)。即按 1440×720 设计视口等比缩放、并额外受内容列左侧可用边距约束;上限 28px、下限 11.2px
--pet-sizecalc(var(--pet-unit) * 8)整体边长(最大 224px、最小 89.6px)
--pet-gap28px与内容列之间保留的空隙
--pet-viewport-gap16px贴视口左侧时的最小留白
--pet-bottomJS 写入(CSS 兜底 32px,≤768px 时 64px)距视口下边缘的距离,由 .scroll-control-nav 与 .music-fab 中最低的底边换算而来,令宠物底边与右侧按钮组最低处对齐
  • position: fixed,left: max(16px + env(safe-area-inset-left), anchor − size − 28px):宽屏时紧贴内容列左侧停放,窄屏兜底到视口左缘 16px,不会溢出视口。
  • bottom: max(var(--pet-bottom), env(safe-area-inset-bottom)):CSS 里先按 ScrollControls 的档位兜底(bottom-8 = 32px,≤768px 时 max-md:bottom-16 = 64px),随后 syncBottom() 量测右侧按钮组(.scroll-control-nav 与启用时的 .music-fab)中最低的那条底边,改写成 窗口高 − 底边,宠物底边因此始终与按钮组最低处齐平——音乐播放器把 FAB 放到底部槽位、菜单触发器上移时也跟得上。z-index: 80(低于右下按钮组的 90 / 顶层的 100)。
  • pointer-events: none:纯装饰,永不拦截点击;aria-hidden="true":不进无障碍树。

行为(lib/client/pet.ts,常量集中在文件头部):

触发反应
鼠标移动视线跟随:按指针方向偏移,横向最多 ±0.5 格、纵向 ±0.57 格(MAX_X / MAX_Y × scale),在 280px 处达到满幅(RANGE);每帧按 EASE = 0.15 缓动逼近目标
鼠标移出文档 / 窗口失焦视线回中(reset(),active = false)
左键点击(页面任意处)眨一次眼(blinkOnce() → scaleY 收到 0.4 再回弹)
右键点击(页面任意处)闭眼 1.5s(CLOSE_HOLD);右键坐标落在宠物矩形内时同时 preventDefault() 屏蔽原生右键菜单(几何判定,与 pointer-events: none 无关)
滚轮沿滚动方向滑出,再从另一侧滑回(slidePhase 1 → 2 → 0);一次循环未走完时忽略新的滚轮
触屏点按眨一次眼(≤300ms 且位移 ≤10px)
触屏在宠物上纵向滑 ≥24px上下滑出(起点必须在宠物矩形内)
静止 5s(SLEEP_DELAY)休眠:闭眼,直到鼠标移动 / 滚轮 / 按下 / 触摸唤醒
触摸后的 500ms 内忽略模拟产生的 mousedown(EMULATED_MOUSE_GUARD),避免一次点按被算成两次眨眼
长按宠物 550ms(LONG_PRESS_MS,按住期间位移 ≤14px)弹出「请去休息」气泡,宠物本体同时蹦一下(pet-hop 620ms);鼠标左键与触屏共用同一条 startLongPress()。详见下文「长按气泡与 24 小时休息」

实现要点:

项说明
眼睛开合scaleY = max(0.4, blink × closeValue):0.4 即 BLINK_SCALE = 1/2.5,2.5 格高的眼睛正好压成 1 格方块。眨眼(blink)与闭眼 / 休眠(closeValue)是两条独立缓动,取乘积后统一夹在 0.4 以上
滑出相位 1 位移到 slideDir × 168px × scale(SPEED_OUT = 0.26),到位后把偏移镜像翻到 −goal 并切到相位 2(SPEED_IN = 0.2)滑回 0——于是看起来是「滑出去、从另一侧滑回来」,位移只作用于 .pet-slide,眼睛与轮廓不参与
尺寸换算scale = 实测宽度 / 224(FACE_SIZE),由 ResizeObserver 在宠物尺寸变化时刷新,视线与滑出的像素行程都乘这个 scale,保证缩放后观感一致
单帧循环一个常驻 requestAnimationFrame 循环(tick())驱动视线、眨眼、开合、滑出四条缓动;每处 DOM 写入都先用格式化字符串与上次比对,值不变就不写,静止时几乎零开销
幂等与换页initPet() 以 data-pet-ready="1" 标记做幂等;宠物在 #swup-container 之外、换页不重建,因此 bindPetToSwup() 把 syncPet() 挂到 Swup 的 content:replace 与 page:view 钩子上重测布局(--pet-anchor-x + --pet-bottom,window.swup 未就绪时每 100ms 重试,最多 100 次)。布局也监听 resize、documentElement 的 ResizeObserver、visualViewport.resize 与 astro:after-swap / astro:page-load;这几路监听统一用 requestAnimationFrame 延后一帧执行,保证在音乐播放器 layout.reposition() 把 FAB 与菜单摆好之后再量
气泡与休息长按 → 气泡开合 → 确认后写 petClosedUntil 并停掉常驻循环,全部逻辑见下文「长按气泡与 24 小时休息」
方格消解与聚合外框拆成 8×8 网格最外圈的方格层,退场自左上向右下错峰炸开、回归取相反次序收拢,见下文「方格消解与聚合」
降级prefers-reduced-motion: reduce 时只关掉「滑出」(slideOut() 直接返回 false),视线跟随与眨眼保留(它们跟随指针、不是自发运动);气泡改为瞬时开合、方格消解 / 聚合整个跳过(直接切到终态,也不生成方格层);同时把 will-change 置 auto 并关掉 pet-hop
关闭方法把 siteConfig.pet.enabled 设为 false,组件不渲染任何 DOM,名片上的「唤回宠物」按钮也一起消失;若要从模板里彻底移除,另删 BaseLayout.astro 的 <Pet />、lib/client/index.ts 的 initPet() / bindPetToSwup() 调用、swupBindings.ts 的 bindPetToSwup()、ProfileCard.astro 里的 data-pet-restore 按钮,以及 lib/client/pet.ts 与 styles/Pet.css

长按气泡与 24 小时休息

环节行为
触发在宠物矩形内按下并保持 550ms(LONG_PRESS_MS):mousedown(左键)与 touchstart 走同一条 startLongPress();按住期间指针 / 手指位移超过 14px(LONG_PRESS_MOVE)即撤销,多半是想滚动或选词
气泡.pet-card 是 role="dialog" + aria-label 的对话框,浮在宠物头顶(bottom: calc(100% + 14px)),宽 max-content、上限 min(236px, 100vw − 32px),圆角 12px,::after 是朝下的小尾巴;文案取自 pet.title / pet.desc / pet.confirm
打开openCard():hidden = false + is-open(弹出回弹 300ms),同时给 .pet 挂 is-alert 让本体蹦一下(pet-hop 620ms 后自动摘掉);顺手唤醒休眠中的宠物,并让视线瞄向气泡
收起点气泡以外的任意处(pointerdown 且目标不在气泡内;触摸抬手后 500ms 内补发的鼠标事件不算「点了别的地方」)、或按 Escape:is-closing(150ms 下沉缩回)过渡结束后再 hidden = true
吞点击长按抬手后紧跟的那一次 click 会在捕获阶段被拦下(CLICK_GUARD = 400ms)——宠物底下常常就是文章链接,不能被顺带点到
确认气泡里只有「关闭」一个按钮(data-pet-dismiss),刻意不放取消按钮:点别处收起气泡本身就等于「再陪一会」
休息写入 localStorage.petClosedUntil = Date.now() + 24h(HIDE_DURATION);宠物播完方格消解后,<html> 挂 pet-off(.pet-off .pet { display: none },气泡一并消失)并停掉常驻 requestAnimationFrame,同时通知名片侧放出「唤回宠物」按钮
到期 / 唤回键上的时间戳过期(刷新即自动回归),或用户点「唤回宠物」把键清掉,宠物都以方格聚合重新出现

方格消解与聚合

宠物外框按 8×8 网格拆解,只取最外圈 28 格——正好与 .pet-face::before 那一格厚的描边完全重合:

项实现
方格层buildPetGrid() 在 .pet-face 内生成 .pet-grid(position: absolute; inset: 0; z-index: 3)与 28 个 .pet-cell:width/height: var(--pet-unit)、background: var(--pet-ink)、left/top 取 --pet-unit 的行列倍数,容器带 aria-hidden="true";已存在则复用同一层
让位播放期间给 .pet 挂 is-leaving(消解)或给 <html> 挂 pet-assembling(聚合):.pet-face 去掉 box-shadow、overflow 改 visible(方格可以鼓出轮廓外,做出气泡感),描边与 .pet-slide 一起 opacity: 0,画面里只剩方格
消解单格 scale(1) → 1.45 → 0.1 且透明度归零(CELL_DISSOLVE_MS = 380),按 data-order(row + col)自左上向右下错峰 16ms(CELL_STEP)推进,看起来是气泡从左上角一卷一卷炸开
聚合关键帧完全相反(scale(0.1) → 1.3 → 1,CELL_ASSEMBLE_MS = 400),次序也倒过来(CELL_MAX_ORDER − order),自右下向左上收拢拼回外框,即「方格消解的相反过程」
收尾总时长按 (14 × 16ms) + 单格时长 计算,脚本再多等 24ms(SETTLE_MS)才移除方格层并摘掉类名,避免最后一格还没落定就切状态
句柄逐格 animate() 的句柄存在模块级数组 gridAnims 里,下一次播放前先 cancel(),避免残留的 fill: both 定格画面压住新动画
降级prefers-reduced-motion: reduce 时不建方格层、直接切状态(.pet-cell 的 will-change 也置 auto)

休息期与首帧守卫

<Pet /> 在 #swup-container 之外,由客户端脚本(deferred module)接管,而宠物先于脚本绘制——如果只靠脚本判断,被关掉的宠物会先闪一帧再消失。所以组件内嵌一段 is:inline 守卫脚本,在宠物 DOM 解析前就给 <html> 打类名:

情况处理
petClosedUntil 未过期直接加 pet-off,首帧就不渲染 .pet,不闪
正常进入且未开启减弱动效先加 pet-assembling 把本体藏起来,等 initPet() 到位后播方格聚合再放行;另留 2 秒兜底——脚本若因异常没跑起来(查不到 [data-pet][data-pet-ready])就摘掉类名,绝不让宠物被永久藏住
prefers-reduced-motion: reduce不打 pet-assembling,宠物直接可见
Swup 换页脚本必须带 data-swup-ignore-script 属性。@swup/scripts-plugin 默认(reloadScripts: true)会在每次 content:replace 重跑当前文档里的所有内联脚本——它的作用域是整个 document,而这段守卫在 #swup-container 之外、换页后仍留在文档里,会被一并重跑、又给 <html> 补上 pet-assembling;这时 initPet() 因 data-pet-ready 早退不再播聚合,2 秒兜底又读到该标记直接 return,类名从此摘不掉,宠物在非首页会永久透明。加上该属性即被排除在重跑清单外(initPet() 不会随换页重跑,所以守卫只该跑首次加载那一次)

initPet() 遇到未过期的 petClosedUntil 时只调 syncRestoreBtn(true) 就返回,不初始化宠物本体;不再像从前那样把根节点 remove(),否则「唤回」就没有落脚点了。

唤回按钮(名片侧)

宠物去休息后,入口落在首页名片的社交条上(ProfileCard.astro 的 .social-strip 内、时钟切换按钮左侧),仅当 siteConfig.pet?.enabled === true 时渲染;平时带 hidden,由 lib/client/pet.ts 在宠物进入 / 退出休息时切换:

项实现
标记data-pet-restore,图标借用 mdi-eye(lib/icons.ts 目前没有爪印类图标),aria-label 与 title 都取 pet.restore
显隐syncRestoreBtn() 判断 petClosedUntil() > Date.now():休息中放出、否则收起。冷却期进入页面时由 initPet() 调用(此时不初始化宠物本体),宠物刚被关掉时则由 confirmHide() 在退场动画结束后调用
出现宽度 0 → 42px 连续铺开,同时 translateY(-46px) → 5px → 0 向下弹出(520ms,cubic-bezier(0.34, 1.56, 0.64, 1))。之所以用宽度动画,是因为 .social-strip 是 justify-content: center:直接放出一个 42px 的格子会让整条瞬间跳 21px,让宽度连续长出来才能实现「向左移居中」而不跳变
收起宽度 42px → 0 并上移 12px(240ms),用 fill: 'forwards' 定格到收窄后的状态再置 hidden,否则收尾那一帧会闪回原样;重新放出前先 cancel() 掉这段残留动画
点击restorePet() 清掉 petClosedUntil、收起按钮,再复活宠物:本页会话里初始化过的走 petRevive() 原地复活(复用既有监听器,不重复绑定),否则移除 pet-off 后补一次 initPet()
样式复用 .card-toggle 的 42×42 直角方块与 hover 色板动效,只在 ProfileCard.css 补一条 .pet-restore[hidden] { display: none }——.card-toggle 的 display: inline-flex 会盖掉 hidden 属性的默认样式

4. 内容展示组件

4.1 ProfileCard.astro

Prop类型默认
langLangdefaultLang

数据全部来自构建期计算:

展示项计算方式单位词条
总字数totalWords(posts) = 每篇 readingTime(body).words 求和profile.wordsUnit(字)
预计阅读总分钟 readMinutes(400 字/分,单篇最少 1 分钟)求和后 /60 保留 1 位小数profile.hoursUnit(小时)
总篇数posts.lengthprofile.articlesUnit(篇)
累计访问 PV仅当 siteConfig.statsScript 非空时渲染,值为 <span data-stats="site-pv" class="stat-live">-</span>(data-stats 给统计客户端挂载、stat-live 给预览克隆就地同步),由客户端填充profile.pvUnit / profile.pvLabel
独立访客 UV同上,data-stats="site-uv"profile.uvUnit / profile.uvLabel

社交按钮与时钟开关(渲染在名片正下方的 .social-strip:条首是 42×42 直角 .card-toggle 时钟开关,与右侧社交块并入一个整体、全部共边零间隙;启用宠物时它左侧还会多一颗同规格的「唤回宠物」按钮 .pet-restore,平时带 hidden 不占位,只在宠物处于 24 小时休息期时出现,出现 / 收起动画与点击复活见 §3.5;社交块 42×42 纯图标正方形、块间与卡片底边共边无缝、整排居中,hover/focus 均用顶栏 nav-clip 同款品牌色板下拉动效;卡片内部不再渲染社交行与横线,wrapper 也不再需要 pt-8 顶部空间):

siteConfig.socials 每项 { platform, url },映射逻辑(源码第 44–82 行):

表作用未命中时的回退
platformIconsplatform → MDI 图标常量mdiLinkVariant(通用链接图标)
platformBrandsplatform → 品牌色(hover 背景色)var(--wp-accent)

已内置的 platform 键(大小写不敏感,会 toLowerCase()):

platform图标hover 品牌色
githubGitHub#24292e
twitter / xTwitter#1da1f2
linkedinLinkedIn#0a66c2
youtubeYouTube#ff0000
instagramInstagram#e4405f
facebookFacebook#1877f2
devtoDev.to#0a0a0a
medium通用链接图标#000000
rssRSS#f26522
email / mail邮件无(回退主题色)
website / blog网页无(回退主题色)
其他任意值通用链接图标主题色

按钮 hover 变色通过内联自定义属性实现:

astro
1
<a class="social-btn" style={'--p-brand:' + s.brand} aria-label={s.label} title={s.label}>…</a>

CSS 里色板动效 .social-btn::before 直接取 var(--p-brand)(nav-clip 同款:hover/focus 时从上方 translateY(-101%) → 0 拉下,0.5s cubic-bezier(0.4, 0, 0.2, 1)),hover/focus 同时把图标转白(color: #fff,SVG 走 currentColor)、边框改为品牌色。所以加平台只改 ProfileCard.astro 的两个映射表即可,无需动 CSS。

外链处理:isExternal(url) = /^(https?:|mailto:)/,外链加 target="_blank" rel="noopener noreferrer";mailto: 不加 target。

平台名文本:label = platform.charAt(0).toUpperCase() + platform.slice(1),即 github → Github。方块为纯图标,label 用作 aria-label / title;想改提示文案(如 GitHub),需改这段逻辑或改用固定映射表。

响应式断点(ProfileCard.css)

断点变化
≤ 640px卡片内边距 36px 16px 16px(顶部 36px 为内容留白,切换按钮已挂到卡外顶边、不受断点影响);头像 64→54px;统计区独占一行;数字 21→18px

顶边居中的时钟切换采用「刷漆」动效,机制如下(时钟视图与走时 / 切换逻辑已整体拆到 components/DigitalClock.astro,由本节的名片渲染调用;布局与样式未变,.clock-layer 样式仍在 ProfileCard.css):

项实现
按钮.card-toggle:42×42 直角方块(border-radius: 0),位于社交条 .social-strip 最左端、作为首个流式子项与右侧社交块并入一个整体(无 margin/gap,相邻方块经 .social-strip > * + * { border-left: none } 共用竖线,条内零间隙),border-top: none 与全条同规范共用卡片底边,整排居中(position: relative 只为给 ::before 色板提供包含块,wrapper 已去掉 pt-8,各断点同一位置);hover/focus 走顶栏 nav-clip 同款色板动效(主题色 translateY(-101%) → 0 拉下、0.5s cubic-bezier(0.4, 0, 0.2, 1),图标同步转白);图标在 mdi-clock-outline / mdi-account-outline 间切换,同步更新 aria-pressed、aria-label 与 title(词条 profile.showClock / profile.showProfile,三语齐全);title 必须与 aria-label 一起同步,否则鼠标悬停看不到提示(aria-label 不产生可视 tooltip,社交块用的是 title,两者表现要一致);按钮已移出 .card,DigitalClock.astro 的 initDigitalClock() 从 .profile-card 根节点查 data-clock-toggle,克隆件不再包含按钮(sync() 无需同步按钮 innerHTML)
时钟层.clock-layer 绝对覆盖整卡,自身为容器(container-type: inline-size)供内部用 cqw 取卡片宽;面板底色为主题色 4%→15% 竖向渐变,与卡片 --wp-surface 错开,保证刷漆竖条扫过时特效可见(底部更浓模拟湿漆);默认 clip-path: inset(0 0 100% 0) 完全裁掉。内容整体双向居中(justify-content: center + align-items: center + padding: 10px 16px),三行:时区(Intl…timeZone + UTC 偏移)、七段数码管时分秒、Intl.DateTimeFormat 年月日星期(locale 由 data-locale 传入)
数码管每位 7 个 <i data-seg="a…g">,段位用多边形 clip-path 切出外观:横段(a/g/d)两端 45° 倒角、倒角距离 = 段厚一半(--seg-t / 2),竖段(f/b/e/c)用同一多边形旋转 90°(坐标 (x,y)→(y,x)),倒角同角同长、接缝形状一致(倒角不能按元素长度百分比裁,横竖两段长宽比不同会角度错位);数字宽高比 5 : 8(W ≈ 0.625×seg-h,横段长 ≈ 0.435×seg-h、竖段长 ≈ 0.375×seg-h);竖段高 50% − seg-t/2 − 2×seg-gap(尖端探到 H/2 − G,四角与中段共 8 处接缝菱形间隙全部一致);亮/灭只切背景色(--seg-on 主题色 / --seg-off 8% 文字色),尺寸由 --seg-h: clamp(36px, 10.75cqw, 140px) 等比派生(时钟行总宽 ≈ 4.85×seg-h ≈ 50% 卡片宽,随卡片等比缩放、高度同步拉长;内容整体居中呈现,移动端兜底 36px);冒号 1s 周期闪烁(即 500ms 亮、500ms 灭)
刷漆动画卡片宽度按 28px 均分竖条(round(W/28),实测 27.3–28px,整宽铺满不留缝);每条竖条自上而下被裁掉,相邻条错峰 25ms 依次推进(左→右),单条 480ms,总时长 480 + (N-1)×25。用一个「阶梯多边形」clip-path 表达所有竖条的合成形状,等价于逐条裁切但不复制 DOM。同一套关键帧也在预览方窗的克隆层上同跑,方窗才会与本体同步刷过(见下方「动效同步」)
反向时间轴倒放(关键帧数组反转 + offset 取补):竖条从右到左重构覆盖层,裁切方向与正向完全相反
时钟常驻时钟不受动画影响,setTimeout 对齐秒边界持续走时,visibilitychange 回到前台立即补算;月日/时区变化时才写 DOM
可达性时钟层 aria-hidden="true";时钟态给卡片加 .is-clock,底层内容 visibility: hidden,配合 .clock-layer.is-on { pointer-events: auto } 屏蔽底层点击
降级prefers-reduced-motion: reduce 时跳过动画直接切换;动画进行中忽略重复点击;按钮尺寸与动画时长无关

另一套主题预览镜头(鼠标划入首页名片;窗口样式在 main.css 的共享 .card-preview 块,行为由 src/lib/pointerPreview.ts 的 attachPointerPreview(el, options) 统一提供,ProfileCard.astro 只传自己的外观 / 同步参数):方窗里不是色块,而是把卡片克隆一份、整体套上相反主题 token 后的真实渲染——即「访客切到暗夜 / 白天模式时这张名片长什么样」。

名片与文章卡共用同一份实现(src/lib/pointerPreview.ts 的 attachPointerPreview(el, options)):创建浮窗、跟随指针、进出淡入、克隆构建与生命周期只有一处代码,两张卡片只在 options 里声明差异:

option作用名片的取值文章卡的取值
prepareClone(clone)克隆件生成后的定制(剔除节点 / 剥属性)剔 .card-preview、剥 data-stats / data-clock-* / data-label-*去掉 data-post-card、剔 .card-badge / .card-actions
syncClone(clone, source, preview)就地增量同步(不重建节点)同步 .stat-live / .clock-tz / .clock-date 文本与 .seg-digit i / .clock-layer / .stats 类名不传(只做通用根类名同步)
resetOnDetach卡片脱离文档时是否立即熄灭falsetrue
hover悬停镜像选择器 / 类名不传[data-hover] + preview-hover
resyncEvents额外触发一次同步的事件profile-card-clock-settle不传

名片侧的具体行为:

项实现
结构卡片内第一个子节点是空的 .card-preview 覆盖层(position: absolute; inset: -1px、overflow: hidden、border-radius: 12px、pointer-events: none、inert、aria-hidden="true");克隆件由脚本在加载期一次性生成并常驻复用(图片提前解码,指针划入时窗口直接是满的)
逐像素对齐卡片有 1px 边框,覆盖层用 inset: -1px 向外各扩 1px 正好等于卡片外框;克隆件 .card-preview-clone 用 box-sizing: border-box; width: 100% 撑满覆盖层,字体 / 头像 / 内边距 / 圆角与本体一致,窗口内外内容无缝接续
反色克隆根节点加 .dark(当前亮色模式时)或 .light(当前暗色模式时):.dark 是暗色 token 块,.light 与 :root 同级(main.css 的 :root, .light),于是克隆内部所有 --wp-* 都取到另一套主题的值,Tailwind dark: 变体(&:where(.dark, .dark *))随之生效
方窗覆盖层用 clip-path: inset(上 右 下 左) 只留指针处 56×56 的窗口,四个 insets 由 --preview-x / --preview-y(指针在覆盖层内的坐标)算出,窗口随指针走;外层 overflow: hidden + 12px 圆角把窗口裁在卡片形状内
常驻只切换 opacity(.card-preview.is-on):划入后一直保持,没有淡出/涟漪动画;指针移出名片(或卡片被 hidden)才熄灭。时钟模式下方窗照常唤起:sync() 会把根类名 is-clock、时钟层 is-on 与数码管段位类一起同步进克隆,方窗里显示的是反色时钟(亮段 --wp-accent 取克隆自身变量,随反主题翻转)
跟随pointermove 记下指针视口坐标,与覆盖层 getBoundingClientRect() 原点相减写入变量 → 方窗中心始终对准指针,连续平滑跟随、不吸附 28px 网格
克隆同步attachPointerPreview 在脚本加载期就 card.cloneNode(true) 生成克隆并常驻(先剔掉克隆里的 .card-preview 防递归,并剥掉 data-stats / data-clock-* 等 JS 挂载点,避免外部脚本把克隆件当成真实挂载点往里写;data-stats 处保留 class="stat-live" 作同步钩子)。此后永不重建:sync() 只就地改根类名(is-clock / .dark / .light),名片的文本 / 段位类同步经 syncClone 选项注入(段位 on 类、时区/日期/PV/UV 文本)—— 换新 <img> 要等解码,重建产生的空帧在方窗里就是「头像闪烁」,常驻克隆从根上杜绝。主题变化经 theme-change 立即同步;指针静止时 250ms 定时器同步走秒,移动时每帧顺带同步
动效同步刷漆期间克隆层跟着跑同一套关键帧:card.querySelectorAll('.clock-layer') 里除本体外就是克隆层(克隆已剥掉 data-clock-*,只按类名选中),克隆与本体逐像素重合、同一 tick 创建即共享时间轴,方窗因此和本体同步刷过。缺这一步的话克隆层只能在 is-on(全显)与默认(全裁)之间二选一、没有中间态,整段动效里方窗都会停在切换前的画面,直到 settle() 才跳变。onfinish 中 settle() 之后派发 profile-card-clock-settle,预览侧收到立即 sync()(把落定的 is-on 同步进克隆)——只等 250ms 定时器会在方窗里残留旧画面;动画 cancel() 与克隆同步都在这一处收尾
事件与节流共用实现统一处理:仅响应鼠标(pointerType === 'mouse');requestAnimationFrame 合并高频 pointermove,每帧最多写一次;scroll / resize 重测原点;首次划入先测原点、再就地同步、再摆窗口,避免淡入首帧闪到左上角(两张卡片的时序因此在同一条代码路径上)
可达性覆盖层 aria-hidden 且 inert(无 Tab 焦点、不进无障碍树)、pointer-events: none(鼠标事件直穿本体),克隆件里的按钮/链接不会产生重复操作点
降级prefers-reduced-motion: reduce 时 .card-preview 直接 display: none,脚本也不激活

4.2 PostCard.astro

Prop类型必填说明
langLang否默认 defaultLang
postPost(CollectionEntry<'posts'>)是文章条目

渲染内容与来源:

元素数据来源说明
左上角角标「置顶」post.data.index > 0class card-badge card-badge-left,背景 --wp-gold
右上角分类角标post.data.category链接 /categories/{encodeURIComponent(category)}
系列标识post.data.series 存在时标题下方小标签:mdi-layers + 系列名 + (series_order 存在时)第 N 篇,链接 /series/{encodeURIComponent(系列名)};浅色模式用 text-accent-deep(#b6502b,对比度 4.58:1)呈现「第 N 篇」、系列名用 text-ink-2,保证 WCAG AA 对比度,暗色模式仍用 text-accent
标题post.data.title链接 /posts/{post.id}(post.id 即 slug)
日期formatDate(post.data.published) → YYYY-MM-DD HH:mm—
字数wordCount(post)词条 post.words
阅读时长readMinutes(post)词条 post.minutes
摘要excerptOf(post)description 优先;否则先 replace(/<[^>]+>/g, '') 去 HTML 标签,再 replace(/[#*>]/g, '')去掉# *反引号>,trim()后取前 120 字加...;CSS 上truncate`单行截断
标签post.data.tags渲染 #tag,链接 /tags/{...}
文章 PV/UV仅当 statsScript 非空data-stats-path="/posts/{id}" 容器内放 data-stats="article-pv" / article-uv
「前往阅读 →」—词条 list.readMore
左下角更新时间post.data.updated 存在时class card-btn-left,带 mdi-update 图标

角标定位机制(PostCard.css):角标分为三组,定位方式不同——

位置选择器定位
左上「置顶」.card-badge.card-badge-leftposition: absolute; top: -5px; left: -5px
右上「分类」.card-badge.card-badge-rightposition: absolute; top: -5px; right: -5px
右下按钮组.card-actions(含 data-stats 徽标 + 「前往阅读」)position: absolute; bottom: -5px; right: -5px
左下更新时间.card-btn-leftposition: absolute; bottom: -5px; left: -5px
.card-btn / .card-btn-adjacent按钮组内的两项position: static(由父级 .card-actions 定位)

统一形成「骑在卡片边缘」的贴纸效果。移动端(≤768px)偏移量收到 -4px,高度 30→26px,字号 12→10px。

hover 效果:卡片 hover:-translate-y-0.5 hover:shadow-card-hover;角标 hover:scale(1.05) + box-shadow: 0 4px 16px color-mix(in srgb, var(--wp-accent) 40%, transparent)。

预览方窗(与名片同一套机制:同一份 lib/pointerPreview.ts 的 attachPointerPreview,样式在 main.css 共享块,克隆的 hover 复刻在 PostCard.css;PostCard.astro 只传 prepareClone / resetOnDetach / hover 三项差异参数):每张文章卡同样内嵌 .card-preview 覆盖层(position: absolute; inset: -1px、overflow: hidden、pointer-events: none、inert),鼠标划入时用 clip-path: inset(...) 露出指针处 56×56 方窗,窗口里是反主题克隆:

项实现
极简克隆prepareClone 在 cloneNode(true) 后去掉根的 data-post-card、剔除 .card-badge(置顶 / 分类 / 更新时间角标)与 .card-actions(PV/UV 徽标、「前往阅读」),克隆只保留标题 / 系列 / 日期 / 摘要 / 标签正文——方窗内不骑边、不含统计挂载点(data-stats 节点随 .card-actions 整体移除,无需像名片那样剥属性)
悬停镜像预览层 pointer-events: none,克隆永远收不到 :hover;hover 选项让共用实现的 pointermove 用 closest('[data-hover]') 命中真实卡的标题 / 系列 / 标签链接(模板里带 `data-hover="title
镜像样式PostCard.css 的 .card-preview-clone [data-hover].preview-hover 复刻 hover:文字主题色 --wp-accent,系列另加主题色描边,标签另加 --wp-surface-hover 底;颜色全部取克隆自身变量,随克隆的反主题自动翻转
同步与节流与名片同一条代码路径(build / measure / render / reset / move / leave / refresh 都在 attachPointerPreview 内),sync() 只就地改根类名(card.className + card-preview-clone + 反主题类),文章卡不传 syncClone、没有统计 / 时钟文本要同步;rAF 合并高频 pointermove,仅响应鼠标(pointerType === 'mouse'),prefers-reduced-motion 下 main.css 直接 display: none、脚本不激活;卡片被 Swup 移除时靠 resetOnDetach 立即熄灭

4.3 Pagination.astro

Prop类型必填说明
langLang否默认 defaultLang
currentPagenumber是当前页码(从 1 开始)
totalPagesnumber是总页数;≤ 1 时整块不渲染
baseUrlstring是分页基址,首页传 localePath(lang, '/')

URL 生成规则:

ts
12
pageUrl(1)  → `${base}/`        // 第 1 页不带页码
pageUrl(n)  → `${base}/${n}/`   // 其余带页码,注意尾斜杠

页码省略算法(源码第 21–32 行,s = 1 为[当前页两侧各保留 1 个]):

总页数输出
n ≤ 7(5 + s*2)全量展开 1 2 3 … n
n > 71 … [c-1 c c+1] … n,省略号按需插入

想改「当前页两侧保留几个」,改 const s = 1。

样式(组件内 <style>,非独立 CSS 文件):

class外观
.pg-nav上一页/下一页箭头,背景 --wp-text,文字 --wp-bg
.pg-num数字按钮,1px --wp-border 描边,背景 --wp-bg
.pg-num-active当前页:背景/描边 --wp-accent,白字,font-weight:600
.pg-disabled首/尾页的禁用态,cursor: not-allowed

hover 统一:变主题色背景 + scale(1.05) + box-shadow: 0 4px 16px color-mix(in srgb, var(--wp-accent) 40%, transparent)。

4.4 HomePage.astro

Prop类型必填说明
langLang否—
postsPost[]是当前页文章
currentPagenumber是—
totalPagesnumber是—
baseUrlstring是—

渲染顺序:ProfileCard → <h1 class="sr-only"> (无障碍标题)→ 文章卡片列表 → Pagination。

空状态:posts.length === 0 时显示 t('list.empty')(暂无文章)。

4.5 ArchivePage.astro

Prop类型必填
langLang否
postsPost[]是

被三个路由复用:/archive(全部文章)、/categories/[name](某分类)、/tags/[name](某标签)。

分组逻辑:

ts
12345
const pinned = posts.filter(p => p.data.index > 0)                    // 置顶区,单独展示
const grouped = groupByYear(
  posts.filter(p => p.data.index <= 0)                                // 非置顶按年分组
       .sort((a,b) => b.data.published.valueOf() - a.data.published.valueOf())
)
区块标题排序
置顶区t('list.pinned') + 篇数保持传入顺序(即 getPublishedPosts() 的 index 降序)
年份区{year} + 篇数年份降序(groupByYear 内部固定倒序)

每行结构:MM-DD 日期 → 标题(truncate)→ 分类图标+名 / 首个标签(md 以上才显示)。

4.6 TaxonomyPage.astro

分类总览与标签云合并为一个组件,由 kind 决定词汇表差异(渲染 / 搜索 / 排序逻辑只此一份)。

Prop类型必填说明
langLang否—
kind'categories' | 'tags'是分类总览 / 标签云
itemsNameCount[]({ name: string; count: number }[])是由 groupByCategory() / groupByTags() 生成,默认按数量降序

两种词汇表的差异(其余 DOM 结构完全一致):

项kind="categories"kind="tags"
根 / 搜索 / 清空 / 合计 / 空态 id#categories / #category-search / #category-clear / #categories-total / #categories-empty#tags / #tag-search / #tag-clear / #tags-total / #tags-empty
行 class.category-row.tag-row
列表容器flex flex-col(竖向,左右两端对齐)mt-4 flex flex-wrap justify-center gap-2.5(居中标签云)
合计间距mt-6mt-8
详情页前缀/categories/{name}/tags/{name}
排序存储categoriesSorttagsSort

交互(客户端脚本,按根元素 id 判定当前词汇表):

元素id / class行为
搜索框#category-search / #tag-searchinput 事件按 data-name 做 toLowerCase().includes 过滤
清空按钮#category-clear / #tag-clear有输入时显示;点击清空输入并重新聚焦,列表恢复全量
排序按钮 A.sort-btn[data-sort="name"]按 localeCompare(..., 'zh') 排序
排序按钮 #.sort-btn[data-sort="count"]按 data-count 数值降序
列表#category-list / #tag-list通过 appendChild 重排 + style.display 显隐
计数#categories-total / #tags-total渲染「共 N 个分类 / 标签」;data-template 存原文案模板,render() 里 replace('{n}', 筛选结果数) 实时更新
空提示#categories-empty / #tags-empty筛选无结果时显示,默认 hidden

排序偏好持久化:src/stores/ui.ts 的 categoriesSort / tagsSort(localStorage 同名 key,取值 'name' | 'count',默认 'count')。

想加「按拼音排序」

把 localeCompare(b, 'zh') 换成 localeCompare(b, 'zh-Hans-CN', { numeric: true }),或引入 pinyin-pro(依赖里已有)先转拼音再比较。

4.7 FriendsPage.astro

Prop类型必填说明
langLang否—
friendsFriendLink[]({ url, name, desc, icon })是由 getFriendLinks() 归一化后传入

数据归一化(src/lib/friends.ts):

输入处理
url 无协议自动补 https://
name 为空取 new URL(url).hostname 去掉 www. 前缀
desc 为空同 name 的回退
icon 为空只显示首字母方块

卡片结构:<a class="friend-card"> → 头像槽(.friend-avatar,内含首字母 .friend-avatar-fallback + 可选 <img onerror="this.remove()">)→ 名称/描述 → 跳转图标。

入场错峰动画:内联 style={--stagger: ${Math.min(i, 8) * 60}ms},最多 8 档(即第 8 张及以后都是 480ms),避免长列表尾部等待过久。

空状态:friends.length === 0 时渲染一个手绘 SVG 哭脸(FriendsPage.css 里驱动眼睛/眼泪动画),文案 friends.emptyTitle + friends.emptySub(后者用 set:html 注入 <code>src/site.config.ts</code>)。

页面底部:额外渲染一个 <Comments />(即友链页也有评论区,受 siteConfig.commentScript 控制)。

4.8 AboutPage.astro

Prop类型默认
langLangdefaultLang(当前未使用)

自定义页面,支持使用 html 。

4.9 Comments.astro

无 Props。是否渲染完全由 siteConfig.commentScript 决定:

astro
12345678
const commentScript = siteConfig.commentScript
{commentScript && (
  <section class="comment-section is-loading" data-comment-script={commentScript} aria-label="Comments">
    <div class="comment-skeleton" aria-hidden="true"><span/><span/><span/></div>
    <div class="comment-mount" />
  </section>
)}
<script>import '@/comments/client'</script>
class状态说明
is-loading初始显示 shimmer 骨架(3 条灰条)
is-loaded适配器渲染出非 <script> 子节点后骨架 display:none
is-error适配器加载/挂载失败整个评论区 display:none

comment-mount 是插件唯一被允许写入的容器。契约见 08-插件系统.md#2。

4.10 MusicPlayer.astro

Prop类型默认说明
langLangdefaultLang取 UI 文案

代码结构(T10 拆分后):MusicPlayer.astro 只留视图(面板 / 列表 / 控制条 DOM)与事件绑定,逻辑分三处:

模块职责
src/lib/player.ts播放状态机与纯逻辑:当前曲目、播放 / 暂停、播放模式、音量、上下曲索引、进度换算(createPlayer());对外只依赖 MediaPort 端口,不碰 DOM
src/lib/musicUi.tsDOM 助手:元素查询(queryPlayerElements())、音频端口实现(createMediaPort())、视图更新(createPlayerViewFrom())、面板 / 列表动画、滚动条与浮动定位
src/lib/musicPlaylist.ts构建期扫描 public/music/ 生成播放列表

组件与 lib/player.ts 通过 MediaPort(组件实现:setSource / play / pause / isPaused / 进度 / 音量)+ 状态回调(onState / onTrack)通信,双方都不直接操作对方 DOM。

播放列表来源(两级优先):

优先级条件行为
1siteConfig.musicPlayer.playlist.length > 0直接用配置的列表(可自定义 title / cover / 顺序)
2配置为空数组构建期扫描 public/music/,按文件名 localeCompare(a, b, 'zh') 排序生成列表

扫描支持的扩展名(源码 src/lib/musicPlaylist.ts 的 AUDIO_EXTS):

text
12345
# 已验证
.flac  .mp3

# 未验证
.m4a  .ogg  .wav  .opus  .aac  .webm

自动生成的条目:title = 去扩展名的文件名,src = /music/${encodeURIComponent(文件名)}(中文名会被 URL 编码)。

封面来源:cover 显式指定优先;否则客户端用 src/lib/audioCover.ts 通过 Range 请求只读文件头 ≤ 2MB,解析 FLAC PICTURE 块或 MP3 ID3v2 APIC 帧,URL.createObjectURL 成 blob 展示。拿不到封面时按钮显示音符图标。

audioCover.ts 的 FETCH_LIMIT = 2 * 1024 * 1024(2MB),若你的音频封面位于 2MB 之后会取不到(极少见)。

关键 DOM 属性(改样式/写脚本时定位用):

属性元素
[data-music-player]播放器根 <section>
[data-music-open]右下角磁铁悬浮按钮
[data-music-audio]<audio preload="metadata" playsinline>
[data-music-cover] / [data-music-cover-wrap]封面图与容器
[data-music-list]播放列表面板
[data-music-showlist]展开播放列表按钮

注入给客户端的配置:

html
1234
<script is:inline define:vars={{ playlist, ui }}>
  window.__musicPlayerConfig = { playlist }   // 曲目列表
  window.__musicPlayerUI = ui                 // 控件文案(来自 i18n 的 player.* 词条)
</script>
全局变量注入内容客户端用途
window.__musicPlayerConfig{ playlist }cfg.playlist:切曲、列表渲染、曲目计数
window.__musicPlayerUIui(由 player.* 词条组成的对象)uiText:播放/暂停按钮的 aria-label 与 title 等文案

两个都要注入:客户端读 window.__musicPlayerUI 来设置按钮文案,只注入 __musicPlayerConfig 会让按钮缺失无障碍文案。

功能:播放/暂停/停止、上一首/下一首、随机、顺序、单曲循环、音量、进度条拖拽、播放列表选择、曲目索引与时长显示。切换页面不中断(组件在 #swup-container 外)。

开关:siteConfig.musicPlayer.enabled(当前 true)。设为 false 时整个组件不渲染。

4A. 图标系统(src/lib/icons.ts)

全站图标硬编码在 src/lib/icons.ts:66 个 export const mdiXxx = '<path d>' 常量,加一个 icons 映射表(kebab-case 名 → path)与两个导出。

设计取向(源码注释 + 添加自定义图标.md):零网络请求、零依赖,代价是加图标要手写三步。

4A.1 三个导出

导出类型用途
mdiXxxstring(SVG path 的 d)组件里按名 import 后传给 <Icon d={…} />
iconsas const 对象kebab-case 全名 → path,供字符串场景使用
IconNamekeyof typeof icons类型约束(iconSvg() 的入参类型)
iconSvg(name, cls?)(IconName, string) => string构建期拼 HTML 字符串(代码块头部、复制按钮用)
ts
123
export function iconSvg(name: IconName, cls = ''): string {
  return `<svg class="mdi-icon${cls ? ` ${cls}` : ''}" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="${icons[name]}"></path></svg>`
}

4A.2 添加图标的三个步骤

第 1 步:在 MDI 图标库 复制 SVG path,按字母序粘贴到 src/lib/icons.ts 顶部:

ts
1
export const mdiAbTesting = 'M4 2A2 2 0 0 0 2 4V12H4V8H6V12H8V4…'

第 2 步:在文件底部的 icons 映射表加条目(key 必须是 kebab-case 全名,与常量名对应):

ts
1234
export const icons = {
  // ...
  'mdi-ab-testing': mdiAbTesting,
} as const

第 3 步:在组件里使用

astro
123456
---
import Icon from '@/components/Icon.astro'
import { mdiAbTesting } from '@/lib/icons'
---

<Icon d={mdiAbTesting} class="text-sm" />

或在构建期拼字符串:iconSvg('mdi-ab-testing')。

漏掉第 2 步会类型报错

IconName = keyof typeof icons,只加常量不加映射时,iconSvg('mdi-ab-testing') 会 TS 报错。

4A.3 现有图标 → 使用位置对照(部分)

图标常量使用位置
mdiGithub mdiTwitter mdiLinkedin mdiYoutube mdiInstagram mdiFacebook mdiDevTo mdiRss mdiEmailOutline mdiWeb mdiLinkVariantProfileCard.astro 的 platformIcons / FriendsPage 的跳转图标
mdiLanguageJavascript mdiLanguageTypescript mdiCodeJson …rehypeCodeBlock.ts 的 langIconMap(代码块语言图标)
mdiCalendarOutline mdiFolderOutline mdiTagOutline mdiClockOutline mdiTextRecognition mdiUpdate mdiEye mdiAccountOutlinePostCard / PostPage / ArchivePage 的元信息图标
mdiLayersPostCard / PostPage / SeriesIndexPage / SeriesDetailPage 的系列标识
mdiArrowUp mdiMagnify mdiTableOfContents mdiWeatherNightScrollControls.astro 的右下按钮组
mdiWeatherSunnystores/theme.ts 的 syncThemeButtons()(切换后替换图标)
mdiPlay mdiPause mdiStop mdiSkipNext mdiSkipPrevious mdiShuffleVariant mdiRepeat mdiRepeatOnce mdiVolumeHigh mdiVolumeMute mdiPlaylistMusic mdiMusicNote mdiCloseMusicPlayer.astro
mdiCheck mdiContentCopy代码复制按钮(PostPage 的 copyDoneHtml / copyIdleHtml)
mdiMenu mdiChevronLeft mdiChevronRight mdiCloseCircle mdiArrowLeft mdiHomeOutline mdiSitemap头部菜单、分页、搜索、404、页脚
mdiChevronDown⚠️ 已在 icons 表注册但全项目无组件引用(预留未使用)

4A.4 平台图标(首页社交按钮)

ProfileCard.astro 里有两张独立的映射表,加平台只改组件、不用改 CSS:

表内容
platformIconsplatform(小写)→ mdiXxx 常量;未命中回退 mdiLinkVariant
platformBrandsplatform(小写)→ 品牌色 hex 字符串;未命中回退 'var(--wp-accent)'

品牌色通过内联 CSS 变量传递,驱动 nav-clip 同款色板下拉动效(ProfileCard.css):

astro
1
<a class="social-btn" style={'--p-brand:' + s.brand} aria-label={s.label} title={s.label}>…</a>
css
123
.social-btn::before { background-color: var(--p-brand); transform: translateY(-101%); transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
.social-btn:hover::before { transform: translateY(0); } /* 色板拉下盖住方块 */
.social-btn:hover { border-color: var(--p-brand); color: #fff; }

5. BaseLayout.astro 参数与注入项

5.1 Props

Prop类型默认用途
titlestring—有值时 <title> = {siteConfig.title} - {title};无值 = {siteConfig.title} - {siteConfig.subtitle}
descriptionstringsiteConfig.description<meta name="description">、OG、Twitter、JSON-LD
langLangdefaultLang<html lang> 与 OG locale 映射
showTocButtonbooleanfalse透传给 ScrollControls,控制右下目录按钮
seoType'website' | 'article''website'决定 og:type、article:* meta,以及是否输出 BlogPosting JSON-LD
publishedTimestring—ISO 字符串,写入 article:published_time 与 datePublished
modifiedTimestring—写入 article:modified_time 与 dateModified
seoImagestringsiteConfig.logoOG/Twitter 图片;文章页传正文第一张图

5.2 OG locale 映射

ts
1
const ogLocale = { zh: 'zh_CN', ja: 'ja_JP', en: 'en_US' }[lang]

5.3 输出到 <html> 的属性

属性来源说明
langprop—
data-default-themesiteConfig.defaultTheme供 stores/theme.ts 的 defaultMode() 读取
data-stats-scriptsiteConfig.statsScript,且仅生产环境客户端 stats/client.ts 据此决定加载哪个插件

5.4 防闪烁主题脚本(<head> 内联)

js
1234567
var stored = localStorage.getItem('themeMode')
var mode = (stored === 'light' || stored === 'dark') ? stored : defaultTheme
if (mode !== 'light' && mode !== 'dark') {                 // 即 defaultTheme === 'auto'
  mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.toggle('dark', mode === 'dark')
document.documentElement.style.colorScheme = mode

这段脚本必须在 <head> 同步执行,否则会出现「先白后黑」闪烁。改主题逻辑时不要把它改成 module 或挪到 <body> 末尾。

5.5 #swup-container 外的脚本职责(src/lib/client/*)

BaseLayout.astro 底部只有一行 <script>import '@/lib/client'</script>,实现按职责拆到 src/lib/client/(入口 index.ts 按下列顺序执行):

模块内容
pageBindings.tsinitTheme()、page-load 标记、syncTocFab()、右下按钮组菜单、控件点击委托
swupBindings.tssyncNavState()、bindNavStartSync()、换页前后钩子、跨页锚点与 pending hash、bindPetToSwup()
profileCardAnim.ts首页名片(#profile-card-root)进出场动画
gridTrail.ts鼠标划过网格背景轨迹
pet.tsinitPet()(左下角宠物:视线 / 眨眼 / 休眠 / 滑出 / 长按气泡 / 方格消解与聚合)与 syncPet()(重测锚点与尺寸);另外 petClosedUntil 的读写与名片上「唤回宠物」按钮的显隐、复活逻辑也在这里(initPet() 与 bindRestore() 都从模块级入口进入,所以冷却期内按钮仍可用)

各绑定与迁移前一致(逐段搬运):

函数 / 监听作用
initTheme()初始化主题(来自 stores/theme.ts)
syncTocFab()依据当前页是否存在 #toc-affix 决定右下目录按钮显隐;在 astro:after-swap 与 astro:page-load 两个时机各同步一次,避免竞态
syncNavState(path?)遍历 a[data-nav-path],用 text-accent / text-ink-2 切换高亮;处理 __fallback__;关闭 header details[open]
bindNavStartSync()绑定 Swup 的 visit:start 钩子,让导航高亮在跳转开始就更新(最多重试 100 次 × 100ms,等待 window.swup 就绪)
bindProfileCardAnim()绑定名片进出场钩子:Swup 已就绪直接绑定,否则监听一次 swup:enable 或最多重试 20 次 × 100ms(迁移前为 100 次轮询)
initPet()左下角宠物初始化:查 [data-pet] 并以 data-pet-ready 幂等;写 --pet-anchor-x、按实测宽度算 scale,再起一个常驻 requestAnimationFrame 循环驱动视线 / 眨眼 / 开合 / 滑出。若 petClosedUntil 未过期则只 syncRestoreBtn(true) 后返回(不初始化本体,留出唤回入口)。详见上文 §3.5
bindPetToSwup()把 syncPet() 挂到 Swup 的 content:replace 与 page:view 钩子(宠物在容器外不重建,换页后须重测锚点);window.swup 未就绪时每 100ms 重试,最多 100 次
astro:before-swap移除容器外残留的 #toc-affix(重新 append 到 body),并同步导航状态
点击委托[data-scroll-menu] 切换整合菜单展开/收起;[data-backtop] 平滑回顶;[data-open-search] 派发 open-search;details.md-container-details 的 summary 点击时播放折叠动画(用 Web Animations API 从 scrollHeight 动画到 0,然后移除 open)
跨页锚点点击委托拦截「同源 + 带 # + 跨路径」的内部链接:锚点记到 window.__pendingScrollHash,swup.navigate() 用去掉 hash 的 URL 导航到页顶(无 swup 时回退 location.assign);修饰键、非左键、data-no-swup、外站链接放行
consumePendingScrollHash()astro:page-load 时消费待滚动锚点:hash 写回地址栏,120ms 后 smoothScrollToHeadingAnchor() 平滑滚动(80px 偏移,同目录;prefers-reduced-motion 降级为瞬时)
window.__scrollToHeadingAnchorsmoothScrollToHeadingAnchor() 的全局暴露,供 SearchModal 的同页锚点跳转复用

跨页锚点(/posts/xxx#slug)的平滑滚动由上述「跨页锚点点击委托」+ consumePendingScrollHash() 实现;配合 <head> 里一段内联「去 hash」脚本,直接打开收藏/分享的 # 深链接同样不闪现。完整流程见 07-页面与路由.md#84。

6. 全局事件与 data 属性协议速查

改组件或加组件时,沿用这些约定就能自动接入现有交互:

6.1 自定义事件

事件派发者监听者用途
open-searchBaseLayout(点击 [data-open-search])SearchModal打开搜索弹窗
theme-changestores/theme.ts 的 applyTheme(),detail 为 'light' | 'dark'comments/client.ts(转发给适配器 onThemeChange)、ProfileCard 预览分支(立刻把克隆件的 .dark / .light 换过来,不等下一次指针移动或定时器)主题切换通知
profile-card-clock-settleProfileCard 时钟分支的 onfinish(在 settle() 之后派发)同文件的 initProfileCardPreview()刷漆落定后立即把 is-clock / is-on 同步进克隆件;只靠 250ms 定时器会让方窗在动效结束后残留一帧旧画面
astro:page-loadAstro / lib/client 补发(模块在 DOMContentLoaded 前注册)lib/pageLifecycle.ts 的 onPageLoad、多处初始化页面(含 Swup 换页)就绪
astro:after-swapSwup / AstroTOC 按钮显隐同步(BaseLayout)、主题重应用(theme.ts)、关闭搜索弹窗(SearchModal)。注意:目录弹窗 #toc-modal 无人监听此事件DOM 已替换
astro:before-swapSwup / Astro移除容器外残留的 #toc-affix(只 remove,append 由 PostPage 的 initPage() 负责)、重置导航状态、卸载评论适配器DOM 即将替换

6.2 data-* 属性

属性值消费者
data-nav-path目标路径;特殊值 __fallback__BaseLayout.syncNavState()
data-nav-mobile存在即标记移动端菜单项额外加 bg-surface-hover
data-theme-toggle存在即可stores/theme.ts 点击委托
data-clock-toggle / data-clock-layer存在即标记ProfileCard(开关)与 DigitalClock(时钟层)自用的挂载点;克隆件里刻意剥掉这两个(连同 data-stats),避免外部脚本把克隆当成第二个挂载点
data-pet / data-pet-face / data-pet-slide / data-pet-eyes / data-pet-eye存在即标记lib/client/pet.ts:宠物根节点与各层(本体 / 滑出 / 视线 / 眼睛)挂载点,纯组件内部协议;另有 JS 写入的 data-pet-ready="1" 作幂等标记
data-pet-card / data-pet-dismiss存在即标记lib/client/pet.ts:长按气泡与其「关闭」按钮(confirmHide());气泡靠 is-open / is-closing 类做开合动画
data-pet-restore存在即标记同时被 ProfileCard.astro(按钮)与 lib/client/pet.ts(bindRestore() 绑点击、syncRestoreBtn() 切 hidden)使用;不是 data-pet 的子节点,因此刻意不放进宠物的 DOM 协议里
pet-off / pet-assembling / is-leaving加在 <html> / 加在 .pet 上的状态类Pet.css 消费:pet-off 隐藏整块(含气泡),pet-assembling(首帧守卫与聚合)与 is-leaving(消解)让本体给方格层让位
data-open-search存在即可BaseLayout 点击委托
data-scroll-menu存在即可BaseLayout 点击委托(切换整合菜单展开/收起)
data-backtop存在即可BaseLayout 点击委托
data-comment-script适配器名comments/client.ts
data-statssite-pv / site-uv / article-pv / article-uvstats/client.ts
data-stats-path文章路径(如 /posts/hello)stats/client.ts 分组用祖先属性
data-toc-target标题 slugPostPage 的 TOC 高亮与滚动
data-pagefind-body存在即标记Pagefind:只索引该容器(在 PostPage 的 .post-content 上)
data-pagefind-filtertagPagefind:标签过滤器(在文章页标签链接上)
data-default-themeauto / light / darkstores/theme.ts
data-stats-script插件名stats/client.ts

新增常驻脚本的正确姿势:

在 .astro 里写 <script>,并用 onPageLoad() 包裹初始化:

ts
123
import { onPageLoad } from '@/lib/pageLifecycle'
function initMyWidget() { /* 幂等初始化 */ }
onPageLoad(initMyWidget)

onPageLoad 会同时监听 astro:page-load 并在「本次加载已经触发过」时立即执行,避免 Swup 换页后脚本不跑。