完整示例:番茄钟
这是内置番茄钟插件的完整源码 —— static 运行时最小形态:一个 manifest、一个 HTML,挂在右抽屉常驻。适合作为你第一个插件的起点,配合5 分钟第一个插件读。
plugin.json
Section titled “plugin.json”{ "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" } ] }}ui/index.html(完整源码)
Section titled “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>