视频加载失败

Firefly 博客实战:给文章页加阅读进度条与圆环百分比

4907 字
25 分钟
Firefly 博客实战:给文章页加阅读进度条与圆环百分比
Firefly 博客实战:给文章页加阅读进度条与圆环百分比

Firefly 博客实战:给文章页加阅读进度条与圆环百分比#

本文记录一次完整的功能实现:为基于 Astro + Swup 的 Firefly 博客添加阅读进度——文章页顶部一条随滚动生长的细条,右下角悬浮按钮栈最上方一个与现有按钮同规格的方框,内部 SVG 圆环实时填充百分比。

功能特点:

  • 纯客户端计算:零网络请求、零第三方依赖,不拖慢加载
  • 精准语义:只统计正文(横幅、评论区、推荐文章不计入)
  • UI 与现有按钮一致:3rem 方框、圆角、--card-bg 背景、毛玻璃、细边框,尺寸断点跟随 FloatingButton
  • Swup 兼容:切页不残留旧值、软导航后自动重新绑定
  • 可配置开关:siteConfig.post.readingProgress 一键启停

改动规模:7 个文件修改 + 1 个新文件,净增约 100 行。本文给出每一处改动的精确位置和完整代码,可对照逐条复现。


一、设计思路(先读懂再动手)#

1. 进度怎么算#

进度语义定义为:正文进入视口底边记 0%,正文底边到达视口底边记 100%:

p = clamp((viewportHeight - rect.top) / rect.height, 0, 1)

其中 rect 是正文容器 #post-container 的 getBoundingClientRect()。这个公式的好处:

  • 横幅高度、评论区长度、推荐文章数量都不影响进度
  • 短文章(高度小于视口)也能自然收敛到 0~1

顶部细条用 transform: scaleX(p) 驱动;圆环用 SVG stroke-dashoffset = 周长 × (1 - p) 驱动——两者都不触发布局。

2. 性能三板斧#

这个博客的滚动管线是 scrollFunction(scroll 事件 rAF 节流),阅读进度不是自己注册监听器,而是挂进现有管线,这是全部性能设计的前提:

  1. 调用点卡在布局新鲜期:scrollFunction 先读 scrollTop(布局脏时浏览器在此强制计算一次),紧接着读 rect 命中同一次新鲜布局——增量回流成本为 0
  2. 文字只在整数变化时写:百分比从整数维度最多写 100 次/全程;徽章 CSS 带 contain: content,文字变更的布局失效被隔离在徽章子树内,不污染正文布局。圆环的 dashoffset 是 SVG 属性,只标记绘制不触发布局,可以逐帧写
  3. 非文章页近乎零成本:靠 isConnected + null === null 短路,首页每帧只多一次 getElementById 哈希查找(与 scrollFunction 里已有的 navbar 查询同量级)

3. Swup 适配(这个博客的特殊点)#

本站用 Swup 做软导航,DOM 会局部替换,处理不好会出现”换页后进度条卡在旧值”:

元素位置跨页行为
#reading-progress-barLayout.astro不在 Swup 容器内,元素持久,引用不失效
#reading-progress-badgeFloatingControls.astro同上,持久
#post-container#swup-container 内每次换页被替换,靠 isConnected 自愈重查

再加两条 CSS:切页期间(html.is-page-transitioning)同时隐藏细条和徽章,避免旧页百分比残值闪现;scrollFunction 在过渡期本来就提前 return,visit:end 后恢复调用并刷新。

4. 三个补测时机#

有些场景没有滚动事件,需要主动补算:

  • 图片加载 / 代码块展开 / 加密文章解密 → 正文高度变了但用户没滚 → ResizeObserver 盯正文容器
  • 移动端地址栏收起 → 视口高度变了但正文没变 → 单独听 resize
  • 沉浸阅读切换 → 正文位置变了但可能停在同一滚动位置 → 监听 immersiveReadingChange 事件,立即算一次 + 400ms 过渡结束后再算一次

二、改动总览#

#文件改动
1src/types/siteConfig.ts类型:加 readingProgress: boolean
2src/config/siteConfig.ts配置:加 readingProgress: true
3src/utils/reading-progress.ts新文件,核心逻辑
4src/utils/scroll-utils.ts滚动管线接入(2 处)
5src/utils/layout-init.ts初始化调用(2 处)
6src/layouts/Layout.astro顶部条 DOM(1 行)
7src/styles/transition.css顶部条样式(文件末尾追加)
8src/components/controls/FloatingControls.astro圆环徽章 DOM + 样式

三、分步实现#

Step 1:配置开关(2 个文件)#

src/types/siteConfig.ts —— 在 post 类型区块中,generateOgImages: boolean; 之后添加:

// 是否显示阅读进度(顶部进度条 + 百分比 + 预计剩余时间)
readingProgress: boolean;

src/config/siteConfig.ts —— 在 post 配置区块中,generateOgImages: true, 之后添加:

// 阅读进度:文章页顶部细条 + 悬浮百分比与预计剩余时间(纯客户端计算,无网络请求)
readingProgress: true,

SiteConfig 是严格类型,只改 config 不改类型会直接 type-check 报错,两处必须成对加。

Step 2:核心模块(新文件)#

新建 src/utils/reading-progress.ts,完整内容如下(可整段复制):

/**
* 阅读进度(顶部细条 + 右下角圆环百分比)。
*
* 进度语义:正文进入视口底边为 0%,正文底边到达视口底边为 100%,
* 即 p = clamp((viewportHeight - rect.top) / rect.height, 0, 1)——
* 横幅、评论区、推荐文章不计入。
*
* 每帧成本:一次 getBoundingClientRect + 算术。scrollFunction 先读 scrollTop
*(布局脏时已在此强制计算),随后本函数的 rect 读取命中同一次新鲜布局,不触发额外回流。
* 百分比文字仅在整数变化时写入,且 badge 带 contain:content,文字变更的布局失效
* 被限制在 badge 子树内,不污染正文布局;圆环 dashoffset 走 SVG 属性(不触发布局)。
* 正文元素被 Swup 替换后靠 isConnected 自愈重新查询,非文章页每帧仅一次
* getElementById 哈希查找(与 scrollFunction 中已有的 navbar 查询同量级)。
*/
let initialized = false;
let enabled = false;
let bar: HTMLElement | null = null;
let badge: HTMLElement | null = null;
let pctEl: HTMLElement | null = null;
let ringEl: SVGCircleElement | null = null;
let article: HTMLElement | null = null;
let observer: ResizeObserver | null = null;
let active = false;
let lastP = -1;
let lastPct = -1;
/** 圆环周长 2πr(r=15,与 SVG viewBox 一致) */
const RING_C = 2 * Math.PI * 15;
/** Swup 换页/解密后旧引用断开 → 重查并重绑 ResizeObserver;非文章页 null===null 直接短路 */
function resolveArticle(): void {
if (article?.isConnected) return;
const next = document.getElementById("post-container");
if (next === article) return;
article = next;
lastPct = -1;
if (observer) {
observer.disconnect();
if (article) observer.observe(article);
}
}
function setActive(next: boolean): void {
if (active === next) return;
active = next;
lastPct = -1;
bar?.classList.toggle("visible", next);
badge?.classList.toggle("show", next);
}
export function initReadingProgress(): void {
if (initialized) return;
initialized = true;
// 配置关闭时 DOM 不渲染;查询不到即永久空转
bar = document.getElementById("reading-progress-bar");
badge = document.getElementById("reading-progress-badge");
pctEl = document.getElementById("reading-progress-pct");
ringEl = document.getElementById(
"reading-progress-ring",
) as SVGCircleElement | null;
if (!bar || !badge || !pctEl || !ringEl) return;
enabled = true;
if (typeof ResizeObserver !== "undefined") {
// 正文高度变化(图片加载、代码块展开、解密)后无滚动也能重算
observer = new ResizeObserver(() => updateReadingProgress());
}
// 移动端地址栏收起只改视口高度、不改正文尺寸 → 单独听 resize
window.addEventListener("resize", updateReadingProgress, { passive: true });
// 沉浸阅读切换改变正文位置且可能落在同一滚动位置(无 scroll 事件)→ 立即 + 过渡结束后各算一次
document.addEventListener("immersiveReadingChange", () => {
updateReadingProgress();
window.setTimeout(updateReadingProgress, 400);
});
updateReadingProgress();
}
/** 由 scrollFunction 每帧调用;页面过渡期间 scrollFunction 提前返回,不会执行到这里 */
export function updateReadingProgress(): void {
// enabled 为 true 时下列元素必然存在,显式判空仅为满足 TS 收窄
if (!enabled || !bar || !badge || !pctEl || !ringEl) return;
resolveArticle();
if (!article) {
setActive(false);
return;
}
const rect = article.getBoundingClientRect();
if (rect.height <= 0) return;
const p = Math.min(
1,
Math.max(0, (window.innerHeight - rect.top) / rect.height),
);
setActive(true);
// transform/dashoffset 只影响合成与绘制,不触发布局,逐帧写入开销可忽略
if (p !== lastP) {
lastP = p;
bar.style.transform = `scaleX(${p})`;
ringEl.style.strokeDashoffset = String(RING_C * (1 - p));
}
// 百分比文字仅整数变化时写,配合 contain:content 把回流限制在徽章内
const pct = Math.round(p * 100);
if (pct !== lastPct) {
lastPct = pct;
pctEl.textContent = `${pct}%`;
}
}

模块导出两个函数:initReadingProgress(一次性初始化)和 updateReadingProgress(每帧调用)。模块级缓存了 DOM 引用、正文引用、ResizeObserver 实例和两个”上次写入值”(lastP / lastPct),全部写入都先比对再落 DOM。

两个细节:

  • getElementById 返回类型是 HTMLElement,赋给 SVGCircleElement 需要显式断言(as SVGCircleElement | null),否则 type-check 报 TS2322
  • 圆环周长常量 RING_C = 2π×15 ≈ 94.2478 必须与 Step 6 中 SVG 的 r="15" 保持一致,改半径要同步改这里和 CSS 里的 stroke-dasharray

Step 3:接入滚动管线(src/utils/scroll-utils.ts)#

两处改动。

第一处,文件顶部 import 区,加入:

import { updateReadingProgress } from "@/utils/reading-progress";

第二处,scrollFunction 函数内,找到这段:

const scrollTop = document.documentElement.scrollTop;
const bannerHeight = window.innerHeight * (BANNER_HEIGHT / 100);
const navbarElement = document.getElementById("navbar");
// 根据滚动位置动态更新侧边栏 sticky 间距
updateSidebarStickySpacing();

在 navbarElement 之后、updateSidebarStickySpacing() 之前插入调用:

const scrollTop = document.documentElement.scrollTop;
const bannerHeight = window.innerHeight * (BANNER_HEIGHT / 100);
const navbarElement = document.getElementById("navbar");
// 阅读进度:紧跟 scrollTop 读取(布局已新鲜),后续 DOM 写入前完成 rect 测量
updateReadingProgress();
// 根据滚动位置动态更新侧边栏 sticky 间距
updateSidebarStickySpacing();

位置很关键:必须在所有 DOM 写入之前完成 rect 测量。scrollTop 读取已经把布局算干净了,此时 getBoundingClientRect 不会触发新的回流;如果挪到函数末尾(一堆 class 写入之后),每帧会多付一次强制布局。

Step 4:初始化(src/utils/layout-init.ts)#

两处改动。

import 区加:

import { initReadingProgress } from "@/utils/reading-progress";

initLayout() 函数内,initScroll() 之后紧跟调用:

initScroll();
initReadingProgress();

initLayout 有 window.__fireflyLayoutInit 守卫只会执行一次,initReadingProgress 内部也有 initialized 双保险。注意顺序:initScroll() 注册监听后会立即调一次 scrollFunction,此时阅读进度尚未初始化、enabled 为 false 会静默跳过——紧接着的 initReadingProgress() 自己会补一次首帧计算,所以顺序必须是 initScroll 在前。

Step 5:顶部细条 DOM + 样式(2 个文件)#

src/layouts/Layout.astro —— 找到 Swup 导航进度条,在其后面加一行:

<div id="progress-bar"></div>
{siteConfig.post.readingProgress && <div id="reading-progress-bar" aria-hidden="true" />}

(siteConfig 该文件本来就有 import,无需新增。)

src/styles/transition.css —— 文件末尾追加:

/* --------------------------------------------------------------------------
4b. Reading Progress Bar (post pages)
-------------------------------------------------------------------------- */
#reading-progress-bar {
--reading-progress-height: 3px;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: var(--reading-progress-height);
/* 低于 #progress-bar(9999):切页时由导航进度条覆盖,同色无缝衔接 */
z-index: 9998;
pointer-events: none;
background: var(--primary);
transform-origin: left center;
transform: scaleX(0);
opacity: 0;
transition: opacity 0.3s ease;
will-change: transform;
}
#reading-progress-bar.visible {
opacity: 1;
}
/* 切页期间隐藏阅读进度条,避免旧页残值与导航进度条混显;visit:end 移除类后淡入 */
html.is-page-transitioning #reading-progress-bar {
opacity: 0;
}

样式要点:

  • z-index 9998 vs Swup 条的 9999:切页时导航进度条永远压在阅读条上面,两者同为 var(--primary) 颜色,视觉无缝
  • transition 只写了 opacity:transform 不能加过渡,否则进度条会”追不上”滚动
  • html.is-page-transitioning 规则优先级 (1,1,1) 高于 .visible 的 (1,1,0):切页时无需 JS 介入即可强制隐藏

Step 6:圆环徽章(src/components/controls/FloatingControls.astro)#

徽章放在右下角悬浮按钮栈的第一项(最顶部),样式与 FloatingButton(.floating-btn)同规格:3rem 方框、1rem 圆角、--card-bg 背景、12px 毛玻璃、细边框——远看就是第五个按钮,只是内部是圆环而非图标。

import 区无需改动(徽章不依赖 i18n),frontmatter 里 immersiveReadingEnabled 之后加一行:

const readingProgressEnabled = siteConfig.post.readingProgress;

模板里,.floating-controls-container 内第一项插入:

<div class="floating-controls-container">
{readingProgressEnabled && (
<div id="reading-progress-badge" class="reading-progress-badge card-base" aria-hidden="true">
<!-- 类名用 rp- 前缀:.ring 会撞 Tailwind 的 .ring 焦点环工具类(box-shadow 1px currentColor) -->
<svg class="rp-ring" viewBox="0 0 36 36">
<circle class="rp-ring-track" cx="18" cy="18" r="15" />
<circle id="reading-progress-ring" class="rp-ring-value" cx="18" cy="18" r="15" />
</svg>
<span id="reading-progress-pct">0%</span>
</div>
)}
<FloatingTOC headings={headings ?? []} encrypted={encrypted} />
<!-- ……其余原有内容不动…… -->

<style> 块开头(.floating-controls-container 规则之前)插入徽章样式:

/*
* 阅读进度圆环:与 .floating-btn 同规格的方框。背景/圆角/overflow 交给 card-base——
* 卡片透明度、卡片边框等全局设置都通过 .card-base 类挂钩,必须带上才能跟随这些设置。
* 本组件只补尺寸、毛玻璃、边框与内容布局。不可交互(pointer-events: none),无 hover。
*/
#reading-progress-badge {
display: none;
place-items: center;
position: relative;
width: 3rem;
height: 3rem;
flex-shrink: 0;
border: 1px solid rgba(0, 0, 0, 0.1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--primary);
pointer-events: none;
user-select: none;
/* 环与文字的布局失效限制在徽章子树内,不污染正文布局 */
contain: content;
}
#reading-progress-badge.show {
display: grid;
}
/* 切页期间隐藏,visit:end 移除类并刷新后重新显示,避免旧页百分比残值闪现 */
:global(html.is-page-transitioning) #reading-progress-badge {
display: none;
}
/* SVG 与数字叠放(同 FloatingButton 双图标的手法:grid 同格) */
#reading-progress-badge .rp-ring,
#reading-progress-badge #reading-progress-pct {
grid-area: 1 / 1;
}
.rp-ring {
width: 83%;
height: 83%;
/* 从 12 点方向起笔 */
transform: rotate(-90deg);
}
.rp-ring-track,
.rp-ring-value {
fill: none;
stroke-width: 3;
}
.rp-ring-track {
stroke: rgba(0, 0, 0, 0.1);
}
.rp-ring-value {
stroke: var(--primary);
stroke-linecap: round;
/* 周长 2πr(r=15)≈ 94.2478;初始 dashoffset = 周长 = 0% */
stroke-dasharray: 94.2478;
stroke-dashoffset: 94.2478;
}
#reading-progress-pct {
font-size: 0.6875rem;
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
}
:global(.dark) #reading-progress-badge {
border-color: rgba(255, 255, 255, 0.15);
}
:global(.dark) .rp-ring-track {
stroke: rgba(255, 255, 255, 0.15);
}
/* 尺寸断点跟随 FloatingButton(480 → 2.5rem,360 → 2rem,320 → 1.75rem) */
@media (max-width: 480px) {
#reading-progress-badge {
width: 2.5rem;
height: 2.5rem;
}
#reading-progress-pct {
font-size: 0.625rem;
}
}
@media (max-width: 360px) {
#reading-progress-badge {
width: 2rem;
height: 2rem;
}
#reading-progress-pct {
font-size: 0.5625rem;
}
}
@media (max-width: 320px) {
#reading-progress-badge {
width: 1.75rem;
height: 1.75rem;
}
#reading-progress-pct {
font-size: 0.5rem;
}
}

设计要点:

  • 必须带 card-base 类:本站”卡片透明度”滑块的生效链是 --card-transparent-opacity → --card-bg-transparent → main.css 中的 .wallpaper-transparent .card-base { background-color: var(--card-bg-transparent) !important }——透明背景、卡片边框等全局设置全部通过 .card-base 类挂钩。徽章的背景/圆角/overflow 因此直接交给 card-base 提供(--radius-large 也等于 1rem),组件自身只写尺寸、毛玻璃、边框和内容布局;漏掉这个类,徽章就会游离在透明度体系之外,和其余按钮表现不一致
  • 与 .floating-btn 同规格:3rem / 1rem 圆角 / --card-bg / blur(12px) / 1px 细边框 / 尺寸断点(480→2.5rem、360→2rem、320→1.75rem)全部对齐,放进按钮栈毫无违和
  • SVG 双圆环:rp-ring-track 是底环(淡色轨道),rp-ring-value 是进度环(--primary 色、圆头线帽)。整只 SVG rotate(-90deg) 让进度从 12 点方向起笔
  • 类名必须避开 Tailwind 工具类:最初用的 .ring 正好撞上 Tailwind 的 .ring(box-shadow: 0 0 0 1px currentColor),导致 SVG 外围出现一圈橙色直角描边——详见坑 6
  • stroke-dasharray + dashoffset:dasharray = 周长 把圆环变成一条整长虚线,dashoffset 从周长(完全看不见 = 0%)收到 0(完整闭合 = 100%)
  • 数字与环同格叠放:grid-area: 1 / 1——与 FloatingButton 双图标(default/toggled 两个 SVG 同格切换)同一手法
  • font-variant-numeric: tabular-nums:等宽数字防止 9%→10% 时文字宽度抖动
  • aria-hidden="true":纯视觉进度,避免读屏被频繁变化的数字打扰
  • 默认 display: none,.show 才 grid:非文章页徽章完全不占位,悬浮按钮栈不留空隙
  • Astro 作用域样式:.dark 和 html.is-page-transitioning 是全局祖先选择器,必须用 :global() 包裹,否则匹配不到

至此全部改动完成。


四、验证#

1. 静态检查#

Terminal window
pnpm type-check

预期:与本功能相关的文件 0 错误。注意本仓库存在一批存量错误(与本功能无关,改动前就存在),复现时不要误判:

  • en/ja/ko/ru/zh_TW.ts:缺 siteStatsTotalViews / siteStatsTotalVisitors 两个旧键(只有 zh_CN 有)
  • anime.astro / vndb.astro:引用了不存在的 I18nKey.anime*、siteConfig.anime、vndb.nsfw

如果在核心模块遇到 TS2322: Type 'HTMLElement | null' is not assignable to type 'SVGCircleElement',说明漏了 Step 2 里的 as SVGCircleElement | null 断言。

2. 手动功能验证#

Terminal window
pnpm dev

打开任意文章页(如 /posts/markdown-extended/),逐项核对:

#操作预期
1页面加载顶部无进度条、右下角无圆环徽章
2向下滚动顶部 3px 细条从左往右生长;徽章圆环从 12 点方向顺时针填充,中心数字 N% 实时跳动
3滚到正文结束(评论区上方)细条满宽、圆环闭合成整圆、数字停在 100%
4滚回顶部细条缩回、圆环清空、数字回 0%
5导航到首页徽章和细条消失
6从首页点回文章(Swup 软导航)徽章重新出现且数值正确,无旧值闪现
7两篇文章之间切换数值立即反映新文章,不残留上一篇的百分比
8切换暗色模式方框边框与底环切换为暗色配色(rgba(255,255,255,0.15))
9开启沉浸阅读再退出进度立即重算(监听了 immersiveReadingChange)
10窗口缩放 / 手机模拟器改视口进度立即重算;480px 以下徽章缩为 2.5rem、360px 缩为 2rem
11对比右下角其余按钮同尺寸、同圆角、同背景毛玻璃、同边框,视觉上是”第五个按钮”

3. 关闭功能验证#

把 src/config/siteConfig.ts 中 readingProgress 改为 false 后:细条和徽章的 DOM 都不再渲染,控制台无报错(initReadingProgress 查不到元素后静默空转)。


五、避坑记录#

坑 1:pnpm lint 会顺手改你的无关文件#

本仓库的 lint 脚本是 biome check --write ./src ./scripts——带 --write 的全局自动修复。实际执行时它顺带格式化了 21 个与本功能无关的文件(music-player、各种 config 等),不得不逐一 git checkout 回滚。

复现时建议只对自己的文件做只读校验:

Terminal window
npx biome check src/utils/reading-progress.ts src/utils/scroll-utils.ts

或者先 git stash 记下基线,跑完 lint 后用 git diff --stat 检查波及范围。

坑 2:i18n 加键必须六个语言文件同步#

本功能最终没有引入任何 i18n 文案(圆环只显示数字),但仓库里 pnpm type-check 会报一批 TS2739 存量错误——成因是 src/i18n/translation.ts 的 Translation 类型是全键映射:

export type Translation = {
[K in I18nKey]: string;
};

每加一个枚举键,六个语言文件必须同时补全,漏一个就报错(存量 siteStats* 错误正是只加了枚举和 zh_CN)。复现本文时不需要动 i18n,但以后加文案要记住这条。

坑 3:进度条的调用位置不能挪#

updateReadingProgress() 必须放在 scrollFunction 中所有 class 写入之前。本站滚动管线的约定是”先批量读、再批量写”(写操作 deferred 到嵌套 rAF),如果把 rect 测量挪到写操作之后,每帧都会强制重排一次——正是 swup-transitions.ts 注释里警告过的”大型文章 DOM 同步重排”问题。

坑 4:transition 属性别写成 all#

顶部条 CSS 若写 transition: all .3s,transform 也会被过渡——进度条会滞后于滚动约 300ms。必须显式只写 transition: opacity .3s。

坑 5:SVG 半径三处联动#

圆环半径 r=15 出现在三个地方,改一个必须同步改全部:

  1. 模板 viewBox="0 0 36 36" 与 r="15"
  2. 核心模块 const RING_C = 2 * Math.PI * 15
  3. 样式 .rp-ring-value { stroke-dasharray: 94.2478 }(= 2π×15)

任何一处不一致,圆环都会填不满或超过 100%。

坑 6:类名撞了 Tailwind 工具类(真实踩坑)#

初版给 SVG 用的类名是 .ring,结果徽章外围出现一圈橙色直角描边,与预期的圆角按钮完全不像。排查过程值得记录:

  1. 先怀疑自己的 border/border-radius 没生效——抓取页面编译后的 scoped CSS,规则完整输出,排除
  2. 再全局搜索 .ring 的定义——实锤:页面 CSS(Tailwind v4)里有工具类
.ring {
--tw-ring-shadow: ... 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-ring-shadow);
}

Tailwind 的 .ring 是焦点环工具类:box-shadow 0 0 0 1px currentColor。三个要素全部命中截图症状:

  • currentColor = 元素的 color = var(--primary) = 橙色
  • box-shadow 扩散出的轮廓是矩形(SVG 元素没有圆角)→ 直角
  • 扩散只 1px 且紧贴 SVG 边界 → 比按钮小一圈的”奇怪方框”

而且我 scoped 里的 .ring 规则只设了 width/height/transform,没设 box-shadow——两条规则各管各的属性,scoped 样式”生效了”也压不掉它。

修复:类名换 rp- 前缀(rp-ring / rp-ring-track / rp-ring-value),彻底避开 Tailwind 命名空间。JS 用的是 id="reading-progress-ring"(id 不与类名冲突),核心模块无需改动。

教训:这个项目全局启用 Tailwind,起 class 名前先搜一遍目标名字是否是 Tailwind utility(ring、grid、container、hidden、group……都是雷区);组件内自定义类统一加项目前缀最稳。


六、文件清单速查#

复现顺序建议:配置类型 → 核心模块 → 滚动管线 → 初始化 → 顶部条 → 圆环徽章 → 验证。

src/types/siteConfig.ts +2 类型开关
src/config/siteConfig.ts +2 配置开关
src/utils/reading-progress.ts 新增 核心逻辑
src/utils/scroll-utils.ts +4 每帧调用点
src/utils/layout-init.ts +2 初始化
src/layouts/Layout.astro +1 顶部条 DOM
src/styles/transition.css +31 顶部条样式
src/components/controls/FloatingControls.astro +107 圆环徽章 DOM + 样式

(本文即为该功能的完整实现记录,欢迎对照复现。)

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
Firefly 博客实战:给文章页加阅读进度条与圆环百分比
https://mstzuomu.space/posts/azuma.zuomu-blog-13/
作者
陌殊途左沐
发布于
2026-09-28
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
Firefly 博客接入友链朋友圈:从零开始的完整部署教程
技术分享手把手教你为博客搭建自动更新的友链朋友圈,含全部代码改动与实战踩坑排查
2
博客性能优化全记录:Lighthouse 报告拆解到帧率根因排查(A–F 六项实战)
技术分享从两份 Lighthouse 对比报告出发,完整记录六项性能优化的排查方法、根因定位与修复代码:字体子集化、封面图尺寸 API、CSS 内联、强制重排归零、以及"只有 Chrome 掉帧"的浏览器设置级根因,附全部可复现命令与工具脚本
3
导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
技术分享一次连续迭代的完整复盘——从卡片边框体系、悬浮胶囊导航,到三段结构拆分、下滑收拢动画,再到 PC 收拢态汉堡的控件快捷面板与"行点击原位弹卡"的两处根因修复,追加 PC 搜索控件图标化与收拢动画卡顿的逐帧诊断修复。每个批次改了哪些文件、为什么这么改、踩了哪些坑(CSS 层序、正圆公式、包含块、Vite 缓存、Playwright headless 动画冻结),附 git 重放与手工重做两条复现路径及验收清单
4
Firefly 博客实战:从零搭建任意设备可用的发布台(Vercel + GitHub API)
技术分享静态博客只能守在电脑前发文章?本文完整复盘如何用 Vercel 无框架函数 + GitHub API 搭一套带登录的发布后台——发文、改稿、删除、预览全部在浏览器里完成,附我们真实踩过的五个部署坑与安全边界设计,照着做可以从零复刻。
5
基于Firefly主题的列表封面位置改造
技术分享不想让封面挤在侧边?Firefly封面置顶记录
随机文章随机推荐

评论区

Profile Image of the Author
陌殊途左沐
热爱是拯救无趣人生的唯一途径
公告
欢迎来到我的博客!这里是左沐的个人空间,分享我的学习、生活和兴趣爱好。希望你能在这里找到有趣的内容,请不要对我的喜好做出评价哦!请勿使用公网访问本站。
分类
访客信息
加载中...
标签
最新动态
站点统计
文章
27
分类
2
标签
47
总字数
48,093
运行时长
0 天
最后活动
0 天前
总浏览量
-
访客数
-
文章目录