Firefly 博客实战:给文章页加阅读进度条与圆环百分比
- 1导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
- 2博客性能优化全记录:Lighthouse 报告拆解到帧率根因排查(A–F 六项实战)
- 3Firefly 博客实战:从零搭建任意设备可用的发布台(Vercel + GitHub API)
- 4评论区回复时表情面板被截断:一次 overflow 裁剪排查实录
- 5Firefly 博客实战:给文章页加阅读进度条与圆环百分比本文
- 6Firefly 博客接入友链朋友圈:从零开始的完整部署教程
- 7从零开始搭建个人图床
- 8文章中添加音乐播放器
- 9基于Firefly主题的列表封面位置改造
- 10基于rehype的博客PDF嵌入组件实现
- 11Twikoo评论系统部署踩坑记录:Vercel域名无法访问的解决之路
- 12这是我的第一篇BLOG

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 节流),阅读进度不是自己注册监听器,而是挂进现有管线,这是全部性能设计的前提:
- 调用点卡在布局新鲜期:
scrollFunction先读scrollTop(布局脏时浏览器在此强制计算一次),紧接着读rect命中同一次新鲜布局——增量回流成本为 0 - 文字只在整数变化时写:百分比从整数维度最多写 100 次/全程;徽章 CSS 带
contain: content,文字变更的布局失效被隔离在徽章子树内,不污染正文布局。圆环的dashoffset是 SVG 属性,只标记绘制不触发布局,可以逐帧写 - 非文章页近乎零成本:靠
isConnected+null === null短路,首页每帧只多一次getElementById哈希查找(与scrollFunction里已有的 navbar 查询同量级)
3. Swup 适配(这个博客的特殊点)
本站用 Swup 做软导航,DOM 会局部替换,处理不好会出现”换页后进度条卡在旧值”:
| 元素 | 位置 | 跨页行为 |
|---|---|---|
#reading-progress-bar | Layout.astro | 不在 Swup 容器内,元素持久,引用不失效 |
#reading-progress-badge | FloatingControls.astro | 同上,持久 |
#post-container | #swup-container 内 | 每次换页被替换,靠 isConnected 自愈重查 |
再加两条 CSS:切页期间(html.is-page-transitioning)同时隐藏细条和徽章,避免旧页百分比残值闪现;scrollFunction 在过渡期本来就提前 return,visit:end 后恢复调用并刷新。
4. 三个补测时机
有些场景没有滚动事件,需要主动补算:
- 图片加载 / 代码块展开 / 加密文章解密 → 正文高度变了但用户没滚 →
ResizeObserver盯正文容器 - 移动端地址栏收起 → 视口高度变了但正文没变 → 单独听
resize - 沉浸阅读切换 → 正文位置变了但可能停在同一滚动位置 → 监听
immersiveReadingChange事件,立即算一次 + 400ms 过渡结束后再算一次
二、改动总览
| # | 文件 | 改动 |
|---|---|---|
| 1 | src/types/siteConfig.ts | 类型:加 readingProgress: boolean |
| 2 | src/config/siteConfig.ts | 配置:加 readingProgress: true |
| 3 | src/utils/reading-progress.ts | 新文件,核心逻辑 |
| 4 | src/utils/scroll-utils.ts | 滚动管线接入(2 处) |
| 5 | src/utils/layout-init.ts | 初始化调用(2 处) |
| 6 | src/layouts/Layout.astro | 顶部条 DOM(1 行) |
| 7 | src/styles/transition.css | 顶部条样式(文件末尾追加) |
| 8 | src/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色、圆头线帽)。整只 SVGrotate(-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. 静态检查
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. 手动功能验证
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 回滚。
复现时建议只对自己的文件做只读校验:
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 出现在三个地方,改一个必须同步改全部:
- 模板
viewBox="0 0 36 36"与r="15" - 核心模块
const RING_C = 2 * Math.PI * 15 - 样式
.rp-ring-value { stroke-dasharray: 94.2478 }(= 2π×15)
任何一处不一致,圆环都会填不满或超过 100%。
坑 6:类名撞了 Tailwind 工具类(真实踩坑)
初版给 SVG 用的类名是 .ring,结果徽章外围出现一圈橙色直角描边,与预期的圆角按钮完全不像。排查过程值得记录:
- 先怀疑自己的
border/border-radius没生效——抓取页面编译后的 scoped CSS,规则完整输出,排除 - 再全局搜索
.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 顶部条 DOMsrc/styles/transition.css +31 顶部条样式src/components/controls/FloatingControls.astro +107 圆环徽章 DOM + 样式(本文即为该功能的完整实现记录,欢迎对照复现。)
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!




