视频加载失败

在Astro网站添加番茄闹钟功能

1773 字
9 分钟
在Astro网站添加番茄闹钟功能

给网站做一个番茄闹钟功能,出现在首页右边侧边栏中,每个番茄时间为25分钟,休息5分钟,并且可以指定目标番茄数量,番茄专注 和开始还是番茄数量达标都有音效提示,但是番茄数量达标使用的音效不一样。

一、新建计时器组件(核心逻辑)#

创建路径:src/components/widget/Pomodoro.svelte

<script lang="ts">
/**
* 番茄钟组件 - 首页右侧边栏
* 专注时长默认 25 分钟(可用 +/− 按钮按 5 分钟一档手动调节),休息 5 分钟
* 可设置目标番茄数;状态存 localStorage,刷新后自动恢复
*/
import { onMount } from "svelte";
// 专注/休息默认时长(分钟)
const FOCUS_MINUTES = 25;
const BREAK_MINUTES = 5;
const BREAK_SECONDS = BREAK_MINUTES * 60;
// 手动调节专注时长的档位
const STEP = 5; // 每次加减 5 分钟
const MIN_FOCUS = 5; // 最短 5 分钟
const MAX_FOCUS = 120; // 最长 120 分钟
const STORAGE_KEY = "firefly-pomodoro";
type Mode = "idle" | "focus" | "break";
let mode: Mode = $state("idle"); // 当前模式:idle(空闲)/ focus(专注)/ break(休息)
// 用户自定义的专注时长(分钟),默认 25
let focusMinutes = $state(FOCUS_MINUTES); // 自定义专注时长(分钟)
let secondsLeft = $state(FOCUS_MINUTES * 60);
let running = $state(false); // 是否正在倒计时
let completed = $state(0); // 已完成番茄数
let target = $state(4); // 默认目标数量
let timer: ReturnType<typeof setInterval> | null = null;
let deadline = 0; // 本轮结束时刻(时间戳,毫秒),用于跨刷新精确计时
// ---------- 音效(Web Audio API 生成,无需音频文件) ----------
let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext {
if (!audioCtx) {
audioCtx = new AudioContext();
}
if (audioCtx.state === "suspended") {
audioCtx.resume();
}
return audioCtx;
}
function playTone(
freq: number,
startAt: number,
duration: number,
type: OscillatorType = "sine",
volume = 0.3,
) {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
const t = ctx.currentTime + startAt;
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(volume, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + duration + 0.05);
}
// 专注/休息开始:两声短促的“叮”
function playStartSound() {
playTone(880, 0, 0.15, "sine");
playTone(1174.66, 0.15, 0.2, "sine");
}
// 达成目标:上升的胜利和弦(明显不同)
function playGoalSound() {
[523.25, 659.25, 783.99, 1046.5].forEach((f, i) => {
playTone(f, i * 0.15, 0.25, "triangle", 0.35);
});
}
// ---------- 时长调节 ----------
// 把数值约束在 [5, 120] 之间
function clampFocus(v: number): number {
if (!Number.isFinite(v)) return FOCUS_MINUTES;
return Math.min(MAX_FOCUS, Math.max(MIN_FOCUS, v));
}
// 每次点击 +/− 调整 5 分钟(只在空闲状态可用)
function adjustFocus(delta: number) {
if (mode !== "idle") return;
const next = clampFocus(focusMinutes + delta);
if (next === focusMinutes) return;
focusMinutes = next;
secondsLeft = next * 60; // 大号倒计时同步刷新
saveState();
}
// ---------- 持久化 ----------
function saveState() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
mode,
running,
deadline: running ? deadline : 0,
remaining: running ? 0 : secondsLeft,
completed,
target,
focusMinutes,
}),
);
} catch {
// 忽略隐私模式等写入失败
}
}
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const s = JSON.parse(raw);
mode = s.mode === "focus" || s.mode === "break" ? s.mode : "idle";
completed = Math.max(0, Number(s.completed) || 0);
target = Math.max(1, Number(s.target) || 1);
const restoredMinutes = Number(s.focusMinutes);
focusMinutes = Number.isFinite(restoredMinutes)
? clampFocus(restoredMinutes)
: FOCUS_MINUTES;
if (s.running && typeof s.deadline === "number" && s.deadline > 0) {
const remain = Math.ceil((s.deadline - Date.now()) / 1000);
if (remain > 0) {
// 还没到点:按真实剩余时间继续
secondsLeft = remain;
deadline = s.deadline;
startClock();
} else {
// 页面关闭期间这一轮已经走完:结算一次,下一阶段从当前时刻重新开始
restoreAfterElapsed();
}
} else {
// 空闲状态直接显示完整时长;暂停状态沿用剩余秒数
secondsLeft =
mode === "idle"
? focusMinutes * 60
: Number(s.remaining) > 0
? Number(s.remaining)
: focusMinutes * 60;
}
} catch {
// 读取失败则使用默认状态
}
}
function restoreAfterElapsed() {
if (mode === "focus") {
completed += 1;
if (completed >= goal) {
mode = "idle";
secondsLeft = focusMinutes * 60;
} else {
mode = "break";
startTimer(BREAK_SECONDS); // 休息从现在重新计时
}
} else if (mode === "break") {
mode = "focus";
startTimer(focusMinutes * 60); // 用当前自定义的专注时长
}
saveState();
}
// ---------- 计时器 ----------
function startClock() {
if (timer) clearInterval(timer);
timer = setInterval(tick, 250);
running = true;
}
function stopClock() {
if (timer) {
clearInterval(timer);
timer = null;
}
running = false;
}
function startTimer(durationSec: number) {
deadline = Date.now() + durationSec * 1000;
secondsLeft = durationSec;
startClock();
saveState();
}
function tick() {
const remain = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
if (remain !== secondsLeft) {
secondsLeft = remain;
saveState(); // 每秒落盘一次,刷新到哪一秒都能续上
}
if (remain <= 0) {
handleFinished();
}
}
function handleFinished() {
stopClock();
if (mode === "focus") {
completed += 1;
if (completed >= goal) {
playGoalSound();
mode = "idle";
secondsLeft = focusMinutes * 60;
} else {
playStartSound(); // 休息开始
mode = "break";
startTimer(BREAK_SECONDS);
}
} else if (mode === "break") {
playStartSound(); // 专注开始
mode = "focus";
startTimer(focusMinutes * 60); // 用当前自定义的专注时长
}
saveState();
}
// 开始专注(从空闲状态)
function startFocus() {
playStartSound();
completed = 0;
mode = "focus";
startTimer(focusMinutes * 60);
}
// 暂停 / 继续
function togglePause() {
if (mode === "idle") return;
if (running) {
secondsLeft = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
stopClock();
saveState();
} else {
startTimer(secondsLeft);
}
}
// 重置
function reset() {
stopClock();
mode = "idle";
secondsLeft = focusMinutes * 60;
completed = 0;
saveState();
}
const goal = $derived(Math.max(1, Math.floor(Number(target) || 1)));
const modeLabel = $derived(
mode === "focus" ? "专注中" : mode === "break" ? "休息中" : "空闲",
);
const timeClass = $derived(
mode === "focus"
? "text-(--primary)"
: mode === "break"
? "text-emerald-500"
: "text-neutral-900 dark:text-neutral-100",
);
function format(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
// 挂载时恢复状态;页面隐藏/关闭时再存一次,保证刷新不丢
onMount(() => {
loadState();
const persistNow = () => saveState();
document.addEventListener("visibilitychange", persistNow);
window.addEventListener("pagehide", persistNow);
return () => {
stopClock();
document.removeEventListener("visibilitychange", persistNow);
window.removeEventListener("pagehide", persistNow);
};
});
</script>
<div class="flex flex-col gap-3">
<div class="text-center">
<div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400">
{modeLabel}
</div>
<div class="text-4xl font-black tabular-nums {timeClass}">
{format(secondsLeft)}
</div>
</div>
{#if mode === "idle"}
<!-- 专注时长调节:只在空闲时显示,点击 ±5 分钟 -->
<div class="flex items-center justify-center gap-2 text-sm">
<span class="text-neutral-500 dark:text-neutral-400">专注时长</span>
<button
class="btn-plain flex h-8 w-8 items-center justify-center rounded-lg text-lg font-bold
leading-none active:scale-90 disabled:pointer-events-none disabled:opacity-40"
onclick={() => adjustFocus(-STEP)}
disabled={focusMinutes <= MIN_FOCUS}
aria-label="减少专注时长 5 分钟"
>
</button>
<span
class="w-10 text-center font-bold tabular-nums
text-neutral-900 dark:text-neutral-100"
>
{focusMinutes}
</span>
<button
class="btn-plain flex h-8 w-8 items-center justify-center rounded-lg text-lg font-bold
leading-none active:scale-90 disabled:pointer-events-none disabled:opacity-40"
onclick={() => adjustFocus(STEP)}
disabled={focusMinutes >= MAX_FOCUS}
aria-label="增加专注时长 5 分钟"
>
+
</button>
<span class="text-neutral-500 dark:text-neutral-400">分钟</span>
</div>
{/if}
<div class="flex items-center justify-center gap-2 text-sm">
<span class="text-neutral-500 dark:text-neutral-400">目标</span>
<input
type="number"
min="1"
max="99"
bind:value={target}
class="w-14 rounded-lg border border-neutral-300 bg-transparent px-2 py-1
text-center text-sm text-neutral-900 dark:border-neutral-600 dark:text-neutral-100"
/>
<span class="text-neutral-500 dark:text-neutral-400">个番茄</span>
</div>
<div class="flex items-center justify-center gap-1 text-sm text-neutral-500 dark:text-neutral-400">
<span>已完成</span>
<span class="font-bold text-(--primary)">{completed}</span>
<span>/ {goal}</span>
<span>🍅</span>
</div>
<div class="flex items-center justify-center gap-2">
{#if mode === "idle"}
<button
class="btn-regular rounded-lg h-9 px-4 font-bold active:scale-95"
onclick={startFocus}
>
开始专注
</button>
{:else}
<button
class="btn-regular rounded-lg h-9 px-4 font-bold active:scale-95"
onclick={togglePause}
>
{running ? "暂停" : "继续"}
</button>
<button
class="btn-plain rounded-lg h-9 px-3 font-bold active:scale-95"
onclick={reset}
>
重置
</button>
{/if}
</div>
</div>

二、新建挂件外壳(带标题的卡片)#

创建路径: src/components/widget/Pomodoro.astro

---
import WidgetLayout from "@/components/common/WidgetLayout.astro";
import Pomodoro from "@/components/widget/Pomodoro.svelte";
import type { WidgetComponentConfig } from "@/types/config";
interface Props {
class?: string;
style?: string;
widgetConfig?: WidgetComponentConfig;
}
const { class: className, style, widgetConfig } = Astro.props;
const showTitle = widgetConfig?.showTitle !== false;
---
<WidgetLayout
name="番茄钟"
showTitle={showTitle}
id="pomodoro"
class={className}
style={style}
>
<Pomodoro client:visible />
</WidgetLayout>
---

三、在 SideBar.astro 里注册组件#

文件路径: src/components/layout/SideBar.astro

改动 1 —— 顶部 import 区(第 8 行 import Music … 和第 9 行 import Profile … 之间):#

import Pomodoro from "@/components/widget/Pomodoro.astro";

改动 2 —— componentMap 对象里(第 55 行 dynamic: Dynamic, 之后):#

pomodoro: Pomodoro,

四、在右侧栏配置里开启#

文件路径: src/config/sidebarConfig.ts

在 rightComponents 数组里,插到 dynamic (第 111 行)后、stats 前:#

{
// 组件类型:番茄钟组件
type: "pomodoro",
// 是否启用该组件
enable: true,
// 组件位置
position: "top",
// 是否在文章详情页显示(false = 文章页不显示,首页会显示)
showOnPostPage: false,
},

文章分享

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

在Astro网站添加番茄闹钟功能
https://firefly.cuteleaf.cn/posts/pomodoro/
作者
DenebOla
发布于
2026-09-05
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
DenebOla
右手端汤 左握星屑
公告
动态会实时更新我的网站变动。
分类
标签
最新动态
站点统计
文章
4
分类
3
标签
4
总字数
1,487
运行时长
0
最后活动
0 天前
站点信息
构建平台
Cloudflare Workers
博客版本
Firefly v6.16.4
文章许可
CC BY-NC-SA 4.0