跳转到内容

完整示例:番茄钟

这是内置番茄钟插件的完整源码 —— static 运行时最小形态:一个 manifest、一个 HTML,挂在右抽屉常驻。适合作为你第一个插件的起点,配合5 分钟第一个插件读。

{
"id": "xinghan.pomodoro",
"name": "番茄钟",
"version": "0.1.1",
"description": "专注计时 —— 25 分钟番茄工作法,常驻右抽屉,不打断心流",
"author": "Xinghan",
"runtime": "static",
"ui": "ui",
"market": { "featured": true, "category": "效率", "tags": ["timer", "focus", "pomodoro"] },
"capabilities": [],
"contributes": {
"views": [
{ "id": "timer", "title": "番茄钟", "icon": "clock", "location": "right-drawer", "entry": "ui/index.html" }
]
}
}

注意它连 SDK 都没引 —— 纯静态页面就是合法插件。需要宿主上下文/主题跟随时再加两座桥(见主题与语言)。

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>番茄钟</title>
<!-- 首帧主题(CSS 前):读宿主 iframe src 的 ?xht=,消除挂载闪白;theme/init 再实时切 -->
<script>
try {
var u = new URLSearchParams(location.search).get('xht')
var dark = u === 'dark' || (u !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)
if (dark) document.documentElement.classList.add('dark')
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
} catch (e) {}
</script>
<style>
/* 跟随宿主主题:theme/init 时宿主把 --background/--foreground/... setProperty 到 :root,
下面用 var(宿主token, 字面回退) 引用 —— 收到前用回退,收到后自动跟随宿主。
用 --pm-* 命名,避免和宿主 token(--muted/--accent 等)重名被 inline 覆盖。
--pm-accent 是番茄钟品牌红,不跟随宿主(宿主 primary 是黑白单色)。 */
:root {
--pm-bg: var(--background, #ffffff);
--pm-fg: var(--foreground, #18181b);
--pm-muted: var(--muted-foreground, #71717a);
--pm-ring: var(--border, #e4e4e7);
--pm-btn: var(--muted, #f4f4f5);
--pm-accent: #ef4444;
}
.dark {
--pm-bg: var(--background, #1c1c20);
--pm-fg: var(--foreground, #fafafa);
--pm-muted: var(--muted-foreground, #a1a1aa);
--pm-ring: var(--border, #3f3f46);
--pm-btn: var(--muted, #27272a);
--pm-accent: #f87171;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--pm-bg);
color: var(--pm-fg);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 20px; padding: 24px; user-select: none;
}
.modes { display: flex; gap: 6px; }
.mode {
border: none; background: var(--pm-btn); color: var(--pm-muted);
padding: 5px 12px; border-radius: 999px; font-size: 12px; cursor: pointer;
}
.mode.active { background: var(--pm-accent); color: #fff; }
.dial {
position: relative; width: 200px; height: 200px;
display: flex; align-items: center; justify-content: center;
}
svg { position: absolute; inset: 0; transform: rotate(-90deg); }
.track { stroke: var(--pm-ring); }
.prog { stroke: var(--pm-accent); stroke-linecap: round; transition: stroke-dashoffset 1s linear; }
.time { font-size: 44px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: 1px; }
.controls { display: flex; gap: 10px; }
button.ctrl {
border: 1px solid var(--pm-ring); background: var(--pm-btn); color: var(--pm-fg);
padding: 8px 20px; border-radius: 10px; font-size: 14px; cursor: pointer; font-weight: 500;
}
button.ctrl.primary { background: var(--pm-accent); border-color: var(--pm-accent); color: #fff; }
.rounds { color: var(--pm-muted); font-size: 12px; }
</style>
</head>
<body>
<div class="modes">
<button class="mode active" data-min="25">专注</button>
<button class="mode" data-min="5">短休</button>
<button class="mode" data-min="15">长休</button>
</div>
<div class="dial">
<svg viewBox="0 0 200 200">
<circle class="track" cx="100" cy="100" r="90" fill="none" stroke-width="8" />
<circle class="prog" id="prog" cx="100" cy="100" r="90" fill="none" stroke-width="8"
stroke-dasharray="565.5" stroke-dashoffset="0" />
</svg>
<div class="time" id="time">25:00</div>
</div>
<div class="controls">
<button class="ctrl primary" id="toggle">开始</button>
<button class="ctrl" id="reset">重置</button>
</div>
<div class="rounds" id="rounds">已完成 0 个番茄 🍅</div>
<script>
// 宿主主题跟随:theme/init(挂载)/theme/update(切换)推 mode + tokens,应用到 :root
window.addEventListener('message', function (e) {
var d = e.data
if (!d || typeof d !== 'object') return
if (d.type === 'theme/init' || d.type === 'theme/update') {
var root = document.documentElement
root.classList.toggle('dark', d.mode === 'dark')
root.style.colorScheme = d.mode === 'dark' ? 'dark' : 'light'
if (d.tokens) for (var k in d.tokens) { try { root.style.setProperty(k, d.tokens[k]) } catch (x) {} }
}
})
const C = 2 * Math.PI * 90
let total = 25 * 60, left = total, timer = null, done = 0
const $ = (id) => document.getElementById(id)
const fmt = (s) => `${String((s / 60) | 0).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
function render() {
$('time').textContent = fmt(left)
$('prog').style.strokeDashoffset = C * (1 - left / total)
}
function tick() {
if (left > 0) { left--; render() }
else {
clearInterval(timer); timer = null; $('toggle').textContent = '开始'
done++; $('rounds').textContent = `已完成 ${done} 个番茄 🍅`
try { new Audio('data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQ==').play() } catch (e) {}
}
}
$('toggle').onclick = () => {
if (timer) { clearInterval(timer); timer = null; $('toggle').textContent = '继续' }
else { timer = setInterval(tick, 1000); $('toggle').textContent = '暂停' }
}
$('reset').onclick = () => { clearInterval(timer); timer = null; left = total; render(); $('toggle').textContent = '开始' }
document.querySelectorAll('.mode').forEach((b) => {
b.onclick = () => {
document.querySelector('.mode.active').classList.remove('active')
b.classList.add('active')
clearInterval(timer); timer = null; $('toggle').textContent = '开始'
total = left = +b.dataset.min * 60; render()
}
})
render()
</script>
</body>
</html>