This commit is contained in:
2026-09-12 13:49:44 +08:00
commit a8b1decc48
62 changed files with 16061 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
out/
release/

88
README.md Normal file
View File

@@ -0,0 +1,88 @@
# Time
轻量 Electron 桌面应用:巨字时钟 + 多闹钟 + 番茄钟 + 通用倒计时,顶栏 + 4 个标签挤在同一个 ~440×400 小窗口里。
- **巨字时钟** —— `HH:MM:SS` 24 小时制;首页另含日期、下一闹钟、番茄 / 倒计时状态、已启用闹钟数
- **多闹钟** —— 支持 `once` / `daily`,列表按本地时刻升序;`once` 响过后自动停用,`daily` 保留并排到下一天同刻
- **番茄钟** —— `25 / 5 / 15` 三段式,每完成 4 轮 focus 后插一个长休;可暂停 / 继续 / 跳过 / 重置;底部展示今日完成数
- **通用倒计时** —— `HH:MM:SS` 自由设置自动记住上次时长归零后弹“完成”按钮8 秒无操作自动收起
- **6 套皮肤** —— Linear Dark、黄铜暗色、Solarized、Nord、Catppuccin Mocha、Vercel Light
- **4 张浮窗** —— 时钟 / 番茄钟 / 闹钟 / 倒计时各自一个独立 BrowserWindow启动期预创建 + `hide`,点击瞬开;用户拖过的位置会记住
- **系统集成** —— 顶栏4 个浮窗 toggle + 图钉置顶 + 齿轮)、托盘菜单、单实例锁、写盘失败 toast、主窗几何持久化、多显示器越界回退
提醒统一走系统通知(`Notification` API
---
## 快速开始
### 用户
1. 下载安装包或便携版(见 [打包](#打包)
2. 首次启动若被 Windows SmartScreen 拦截,点「更多信息 → 仍要运行」
3. 顶栏右侧依次是 4 个浮窗 toggle、图钉置顶、齿轮设置主面板 4 个标签:**时钟 / 番茄钟 / 倒计时 / 闹钟**
4. 浮窗右键 / 双击 / Esc / × / 触屏长按 都能关
### 开发者
```bash
npm install
npm run dev # electron-vite + HMR
npm run typecheck # node + web 两份 tsconfig 都过一遍
npm run build # 构建产物到 out/
npm run package # 打包 → release/NSIS 安装包 + portable 单文件)
```
需要 Node.js 20+。
---
## 键盘交互
| 范围 | 按键 | 行为 |
|---|---|---|
| 浮窗 | `Esc` | 关闭(实际是 hide预创建 BrowserWindow 不销毁) |
| 主窗标签条 | `←` / `→` | 切换上一个 / 下一个标签 |
| 主窗标签条 | `Home` / `End` | 跳到第一个 / 最后一个标签 |
| 闹钟 / 倒计时编辑器 | 滚轮 / `Shift` + 滚轮 | 时间步进 ±1 / ±5 |
---
## 数据存储
Windows 上是 `%APPDATA%/time/`macOS / Linux 类似路径):
- `app-state.json` —— 主状态(闹钟 / 番茄钟 / 倒计时 / 设置)
- `window-state.json` —— 主窗位置、大小(同步 `writeFileSync` 落盘)
`app-state.json` 写盘走 300ms 防抖 + 合并同批次 + `.tmp → rename` 原子提交;版本不匹配或解析失败时复制为 `.bak-v1` 后回退默认状态。`window-state.json` 不参与备份。
落盘前会剥离 `pomodoro.active` / `countdown.active` —— 重启后回到 idle避免“旧 endsAt 已过期”的假象;闹钟的 `active=enabled` 完全持久化,重启后调度器按规则继续排程。
---
## 打包
```bash
npm run package
```
产物落在 `release/`
- `Time Setup <version>.exe` —— NSIS 安装包(当前用户安装,可改安装目录)
- `Time-<version>-portable.exe` —— 免安装单文件
`productName = Time``appId = com.guan.time`,当前版本 `0.1.0`。未配置代码签名与自动更新。
---
## 已知限制
- 仅本地时区UI 仅中文
- 未配置代码签名 —— 首次启动会被 SmartScreen 拦截
- macOS / Linux 标题栏已分支macOS `hiddenInset` / Linux 系统 `default`),但仅在 Windows 上完整验证过打包
- 提醒只走系统通知;若系统级关闭了通知,闹钟 / 番茄钟阶段切换 / 倒计时归零都不会响
- 闹钟重复仅支持 `once` / `daily`;没有工作日 / 每周指定日
- 闹钟仅按本地时刻升序排列,**没有拖拽重排**
- 番茄钟持久化的 30 天完成历史(`byDate`)当前不展示在 UI 上;面板只显示今日完成数
- 没有“全局重置应用”入口 —— 只能分别重置番茄钟 / 倒计时到 idle

25
electron-builder.yml Normal file
View File

@@ -0,0 +1,25 @@
appId: com.guan.time
productName: Time
copyright: Copyright © 2026 guan
directories:
output: release
files:
- out/**/*
- package.json
- icon.ico
- "!**/*.map"
asar: true
win:
target:
- target: nsis
arch: [x64]
- target: portable
arch: [x64]
icon: icon.ico
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
language: 2052
portable:
artifactName: '${productName}-${version}-portable.exe'

43
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,43 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import { resolve } from 'node:path';
/** 生成 source map,但 electron-builder.yml 已把 *.map 排除在安装包外。
* 生产环境崩溃日志靠 `_electronViteSourceMaps` 关联(由 electron-builder 的
* sourcemap 处理),本应用先简单出 'hidden' map 留作本地调试。 */
const SOURCE_MAP_OPTS = { sourcemap: 'hidden' as const };
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
...SOURCE_MAP_OPTS,
rollupOptions: {
input: { index: resolve(__dirname, 'src/main/index.ts') }
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
...SOURCE_MAP_OPTS,
rollupOptions: {
input: { index: resolve(__dirname, 'src/preload/index.ts') }
}
}
},
renderer: {
root: 'src/renderer',
build: {
...SOURCE_MAP_OPTS,
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
'floating-pomodoro': resolve(__dirname, 'src/renderer/floating-pomodoro/index.html'),
'floating-clock': resolve(__dirname, 'src/renderer/floating-clock/index.html'),
'floating-alarms': resolve(__dirname, 'src/renderer/floating-alarms/index.html'),
'floating-countdown': resolve(__dirname, 'src/renderer/floating-countdown/index.html')
}
}
}
}
});

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

6312
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "time",
"version": "0.1.0",
"description": "桌面时钟 + 多闹钟 + 番茄钟 + 通用倒计时",
"main": "out/main/index.js",
"author": "guan",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"package": "electron-vite build && electron-builder --win",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"typecheck:web": "tsc --noEmit -p tsconfig.json",
"typecheck": "npm run typecheck:node && npm run typecheck:web"
},
"devDependencies": {
"@types/node": "^20.14.0",
"electron": "^31.7.7",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"typescript": "^5.5.4",
"vite": "^5.4.11"
}
}

142
src/main/appState.ts Normal file
View File

@@ -0,0 +1,142 @@
import type { AppState, ReminderConfig, Alarm, AlarmRepeat } from '../shared/types';
import { DEFAULT_STATE, DEFAULT_REMINDER, localDateKey } from '../shared/constants';
import { PUSH } from '../shared/constants';
import type { Storage } from './storage';
export type Mutator = (s: AppState) => void;
/** 写盘失败监听器:默认广播到所有窗口(与 onUpdate 的 onUpdate 通道解耦)。 */
export type StorageErrorListener = (e: { message: string }) => void;
/**
* 过滤非法闹钟项 + 补齐历史字段:
* - 盘上可能存在结构残缺的对象(如上一次进程崩溃写到一半),缺 id/time 的直接丢弃。
* - 老存档没有 repeat 字段 → 视为 'once'(保持历史行为:响一次就停用)。
* - repeat 字段为未知值(盘被外部编辑过)也回退 'once',避免调度器写脏数据。
*/
function normalizeAlarms(alarms: AppState['alarms'] | undefined): AppState['alarms'] {
if (!Array.isArray(alarms)) return [];
return alarms
.filter((a): a is AppState['alarms'][number] =>
!!a && typeof a.id === 'string' && typeof a.time === 'string'
)
.map((a): Alarm => {
const rep: AlarmRepeat = a.repeat === 'daily' ? 'daily' : 'once';
return a.repeat === rep ? a : { ...a, repeat: rep };
});
}
/**
* 补齐仍使用 version 1、但缺少后来新增字段的旧状态。
* 只在构造时跑一次——后续 update 不再重复克隆合并,字段形状已在首次跑完后定型。
*/
function withCompatibilityDefaults(next: Partial<AppState>): AppState {
const pomodoro = next.pomodoro ?? DEFAULT_STATE.pomodoro;
const history = pomodoro.history;
const date = history?.date || localDateKey();
const count = typeof history?.count === 'number' ? history.count : 0;
const byDate = history?.byDate ?? (history?.date ? { [history.date]: count } : {});
// reminder缺则补默认否则把历史上可能混入的 sound/beep/flash 等老通道过滤掉。
const settings = next.settings ?? DEFAULT_STATE.settings;
const reminder: ReminderConfig = {
...DEFAULT_REMINDER,
...settings.reminder,
behaviors: (settings.reminder?.behaviors ?? DEFAULT_REMINDER.behaviors)
.filter((b): b is 'notify' => b === 'notify')
};
return {
...DEFAULT_STATE,
...next,
alarms: normalizeAlarms(next.alarms),
settings: { ...settings, reminder },
pomodoro: {
...DEFAULT_STATE.pomodoro,
...pomodoro,
history: { date, count, byDate }
},
countdown: {
...DEFAULT_STATE.countdown,
...(next.countdown ?? {})
}
};
}
export class AppStateContainer {
private current: AppState;
private updateListeners: Array<(s: AppState) => void> = [];
private errorListeners: StorageErrorListener[] = [];
/**
* 串行化队列:并发的 update() 通过这条链排队,保证后续 mutate 看到的是
* 前一个 update 落盘 + current 切换之后的 this.current。
* 不串行的话:同一秒内多个 alarm 在 tickNow 里走 onFire -> state.update,
* 所有 mutator 都在同一份旧 current 的 clone 上跑,后写的覆盖前者。
* 经典表现:三个闹钟同分钟响,只有最后那个被 disabled,前两个仍 enabled,
* 下一次启动 schedular 会把它们重新排到下一天 —— "单次闹钟变成天天响"。
*/
private tail: Promise<void> = Promise.resolve();
constructor(private storage: Storage, initial: AppState) {
this.current = withCompatibilityDefaults(initial);
}
get(): AppState { return this.current; }
async update(mutator: Mutator): Promise<void> {
const work = async (): Promise<void> => {
// 等前一个 update 落盘 + current 切完后才 clone —— 后续 mutator 看到最新值
const draft = structuredClone(this.current);
mutator(draft);
const next = draft;
try {
await this.storage.write(next);
} catch (err) {
// 落盘失败:this.current 不变(下次启动读盘仍能拿到旧值),
// 但要把"写入失败"显式推到 renderer(默认 broadcaster 通过 PUSH.storageError)。
this.notifyStorageError(err);
throw err;
}
// 落盘成功,再原子切换 this.current 并广播:所有 listener 在同一 tick 看到 next。
this.current = next;
for (const l of this.updateListeners) {
try {
l(next);
} catch (err) {
// listener 异常不应打断 update 流程:其他 listener 应继续收到更新。
// eslint-disable-next-line no-console
console.error('[appState] update listener threw:', err);
}
}
};
// .then(onF, onF):无论 prev 是 fulfilled/rejected 都跑 work —— 让 chain
// 在上一个 update 写盘失败后仍能继续推进下一个 update。
const run = this.tail.then(work, work);
// tail 吞掉任何 rejection,这样调用方拿到的是他们自己的 run promise,
// 后续串行化不被一个失败永久卡住。
this.tail = run.catch(() => {});
return run;
}
/** 写盘失败广播。 */
private notifyStorageError(err: unknown): void {
const message = (err as Error)?.message || 'storage write failed';
for (const l of this.errorListeners) {
try { l({ message }); } catch { /* listener 异常不阻塞 */ }
}
}
/** 订阅 state 变更:同步触发(在 storage.write resolve 之前)。返回 unsubscribe。 */
onUpdate(fn: (s: AppState) => void): () => void {
this.updateListeners.push(fn);
return () => { this.updateListeners = this.updateListeners.filter(l => l !== fn); };
}
/** 订阅写盘失败。返回 unsubscribe。 */
onStorageError(fn: StorageErrorListener): () => void {
this.errorListeners.push(fn);
return () => { this.errorListeners = this.errorListeners.filter(l => l !== fn); };
}
}
/** 默认 broadcaster把所有错误 listener 通过 webContents.send 推到所有窗口。 */
export function installDefaultStorageErrorBroadcaster(
container: AppStateContainer,
send: (channel: string, payload: { message: string }) => void
): () => void {
return container.onStorageError((e) => send(PUSH.storageError, e));
}

143
src/main/countdown.ts Normal file
View File

@@ -0,0 +1,143 @@
import type { AppStateContainer } from './appState';
import type { AppState, CountdownActiveState } from '../shared/types';
export interface CountdownDeps {
/** active 变化时调用start/pause/resume/reset/fire 后)。 */
onPhaseChange: (active: CountdownActiveState | null) => void;
/** 归零时调用一次,触发通知与提示音。 */
onFire: () => void;
}
const TICK_MS = 250;
/**
* 通用单次倒计时。
* - start(durationMs):以指定时长开始;若当前已 paused则保留 remainingMs 重新启动。
* - pause / resume暂停 / 恢复。
* - reset清空 active清 timer。
* - 自然结束endsAt 到时):异步触发 onPhaseChange(null) + onFire()。
*
* 沿用 Pomodoro 的 inflight + activeVersion 模式:
* - inflight 把 fire / reset 串行化,避免 reset 期间又被 fire 覆盖。
* - activeVersion 防止"刚被 start 的状态被延迟 fire 反向覆盖"。
*/
export class Countdown {
private timer: NodeJS.Timeout | null = null;
private inflight: Promise<void> | null = null;
private activeVersion: number = 0;
private _active: CountdownActiveState | null = null;
constructor(private container: AppStateContainer, private deps: CountdownDeps) {}
get active(): CountdownActiveState | null { return this._active; }
start(durationMs: number): void {
if (!(durationMs > 0)) return;
// 与 pause/resume 一致从 this._active 读container 的写有 300ms 防抖延迟,
// 期间 this._active 已经是新值,从 container 读会拿到陈旧状态。
const prev = this._active;
const remainingMs = prev && prev.paused ? prev.remainingMs : durationMs;
const endsAt = Date.now() + remainingMs;
const active: CountdownActiveState = {
durationMs, endsAt, paused: false, remainingMs
};
this.activeVersion++;
this._active = active;
this.persist(st => { st.countdown.active = active; });
this.deps.onPhaseChange(active);
this.runTimer();
}
pause(): void {
const a = this._active;
if (!a || a.paused) return;
// 同 Pomodoro.pause暂停期间 tick 是空转停表resume() 会重新装上。
if (this.timer) { clearInterval(this.timer); this.timer = null; }
const remaining = Math.max(0, a.endsAt - Date.now());
const paused: CountdownActiveState = { ...a, paused: true, remainingMs: remaining };
this._active = paused;
this.persist(st => { st.countdown.active = paused; });
this.deps.onPhaseChange(paused);
}
resume(): void {
const a = this._active;
if (!a || !a.paused) return;
const endsAt = Date.now() + a.remainingMs;
const next: CountdownActiveState = { ...a, paused: false, endsAt };
this._active = next;
this.persist(st => { st.countdown.active = next; });
this.deps.onPhaseChange(next);
this.runTimer();
}
reset(): void {
if (this.timer) { clearInterval(this.timer); this.timer = null; }
this.activeVersion++;
this._active = null;
this.persist(st => { st.countdown.active = null; });
this.deps.onPhaseChange(null);
}
stop(): void {
if (this.timer) { clearInterval(this.timer); this.timer = null; }
// F-残留进程修复:同 pomodoro.stop(),清 inflight 让 in-flight 的 fire 微任务
// 跑完时不再持有实例强引用。
this.inflight = null;
}
/**
* fire-and-forget 落盘。见 Pomodoro.persist 的同名说明:
* `void promise` 不会挂 rejection handler写盘失败会升级成 unhandledRejection
* 并在 Node 20 / Electron 31 的默认策略下带崩主进程。
*/
private persist(mutator: (s: AppState) => void): void {
this.container.update(mutator).catch((err: unknown) => {
console.error('[countdown] persist failed:', (err as Error)?.message ?? err);
});
}
private runTimer(): void {
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => {
const a = this._active;
if (!a || a.paused) return;
if (a.endsAt - Date.now() <= 0) {
this.queue(() => this.fire(a));
}
}, TICK_MS);
}
private queue(work: () => Promise<void>): void {
// 不能 `void` 这个 promise——必须挂 catch handler理由见 Pomodoro.queue。
const next = (this.inflight ?? Promise.resolve())
.then(() => work())
.catch((err: unknown) => {
console.error('[countdown] queue work failed:', (err as Error)?.message ?? err);
});
this.inflight = next;
// chain 完成后清空 inflight:避免长时间使用时 inflight 变成 N 层深链
// (内存泄漏成比例增长)。期间已调度的下一个 fire 仍持自己的引用,串行性保留。
next.finally(() => {
if (this.inflight === next) this.inflight = null;
});
}
private async fire(_prev: CountdownActiveState): Promise<void> {
const versionAtStart = this.activeVersion;
// 先 yield让 start() / reset() 有机会在 fire 真正开始"破坏"之前插入并 bump version。
// 这一步至关重要 —— 若没有 yieldcapture 与 check 之间是同一个同步块,
// activeVersion 与 versionAtStart 始终相等guard 形同虚设(见 Round 2 的教训)。
await Promise.resolve();
if (this.activeVersion !== versionAtStart) return;
this._active = null;
if (this.timer) { clearInterval(this.timer); this.timer = null; }
// write (300ms debounce window — start/reset can land here)
await this.container.update(st => { st.countdown.active = null; });
// guard 2write 期间若版本变了,说明 start/reset 已抢在 fire 之前;
// 此时 fire 的 broadcast 会让 UI 看到"active 又变 null",并触发 spurious onFire。
if (this.activeVersion !== versionAtStart) return;
this.deps.onPhaseChange(null);
this.deps.onFire();
}
}

View File

@@ -0,0 +1,84 @@
// 浮窗卡片的排布几何 —— 纯函数,不碰 Electron。
// 抽出来的理由:这是"多个浮窗不重叠"的全部判据,而窗口管理器本身需要真实
// BrowserWindow 才能跑。放在这里就能用普通 node 脚本对着各种开/关/拖动序列
// 断言"任意两张可见卡片不重叠",不必启动 Electron。
import type { FloatingKind } from '../shared/types';
/** 浮窗小卡片的尺寸表。
* CSS 里 #root 是 margin:0 的实心卡片,所以窗口矩形 = 视觉矩形,可直接拿来做碰撞检测。 */
export const FLOATING_SIZE: Record<FloatingKind, { w: number; h: number }> = {
pomodoro: { w: 120, h: 120 },
clock: { w: 200, h: 80 },
alarms: { w: 240, h: 150 },
countdown:{ w: 200, h: 120 }
};
/** 卡片距工作区边缘的留白 / 卡片之间的最小间隙。
* 顶部留白专门调大,避免卡片贴在窗口标题栏上挡住其他应用的关闭/最小化按钮。 */
export const FLOATING_EDGE = 16;
export const FLOATING_TOP_OFFSET = 56;
export const FLOATING_GAP = 12;
export type Rect = { x: number; y: number; width: number; height: number };
export type Point = { x: number; y: number };
/** 两个矩形是否冲突。gap 内也算冲突 —— 卡片挨着贴边同样难分辨,一并避开。 */
export function rectsCollide(a: Rect, b: Rect, gap: number): boolean {
return a.x < b.x + b.width + gap && a.x + a.width + gap > b.x &&
a.y < b.y + b.height + gap && a.y + a.height + gap > b.y;
}
/** 在工作区里找一块不与 obstacles 冲突的空位:从右上角起自上而下排一列,
* 这一列排不下就向左开新列。整块工作区都放不下时返回 null。 */
export function packFloating(w: number, h: number, area: Rect, obstacles: Rect[]): Point | null {
const bottom = area.y + area.height - FLOATING_EDGE;
let right = area.x + area.width - FLOATING_EDGE;
while (right - w >= area.x + FLOATING_EDGE) {
const x = right - w;
let y = area.y + FLOATING_TOP_OFFSET;
while (y + h <= bottom) {
const hits = obstacles.filter(o => rectsCollide({ x, y, width: w, height: h }, o, FLOATING_GAP));
if (hits.length === 0) return { x, y };
// 跳到挡路的那几块里最靠下的下边缘之后。hits 非空 ⇒ y 严格递增,循环必然收敛。
y = Math.max(...hits.map(o => o.y + o.height)) + FLOATING_GAP;
}
// 本列排不下 → 贴到与本列水平相交的最左障碍左侧开新列(同样保证 right 严格递减)。
const colHits = obstacles.filter(o => x < o.x + o.width + FLOATING_GAP && right + FLOATING_GAP > o.x);
right = colHits.length ? Math.min(...colHits.map(o => o.x)) - FLOATING_GAP : x - FLOATING_GAP;
}
return null;
}
/** 兜底落点:工作区已被浮窗填满(小屏 / 卡片被拖得到处都是)时重叠无法避免,
* 那就退回右上角并按已开卡片数右下级联,至少让人看出是几张不同的卡片。 */
export function cascadeFallback(w: number, h: number, area: Rect, opened: number): Point {
const step = 24 * opened;
return {
x: Math.max(area.x, area.x + area.width - FLOATING_EDGE - w - step),
y: Math.min(area.y + FLOATING_TOP_OFFSET + step, Math.max(area.y, area.y + area.height - h))
};
}
/** 新浮窗的落点:优先"既不压其它浮窗、也不压主窗";主窗挤满右侧时退而只避让浮窗;
* 屏幕真的塞满了才回到右上角(此时重叠已不可避免)。 */
export function spawnPoint(w: number, h: number, area: Rect, floats: Rect[], main: Rect[]): Point {
return packFloating(w, h, area, [...floats, ...main])
?? packFloating(w, h, area, floats)
?? cascadeFallback(w, h, area, floats.length);
}
/** 用户拖出来的落点现在还能用吗?两条硬性要求:
* - 整张卡片落在某块显示器的 workArea 内(外接屏拔掉后旧位置会飞到看不见的地方);
* - 不与任何可见浮窗真重叠。
* 这里故意用 gap=0 而不是 FLOATING_GAP:自动排布要留空隙好看,但用户手动贴边摆的
* 两张卡片(间距 4px)是他自己的选择,不该被判定为"冲突"再弹走。 */
export function canPlaceAt(rect: Rect, workAreas: Rect[], obstacles: Rect[]): boolean {
const onScreen = workAreas.some(a =>
rect.x >= a.x && rect.y >= a.y &&
rect.x + rect.width <= a.x + a.width &&
rect.y + rect.height <= a.y + a.height
);
if (!onScreen) return false;
return !obstacles.some(o => rectsCollide(rect, o, 0));
}

318
src/main/index.ts Normal file
View File

@@ -0,0 +1,318 @@
import { app, BrowserWindow } from 'electron';
import { join, dirname } from 'node:path';
import { promises as fs, mkdirSync, writeFileSync } from 'node:fs';
import { Storage } from './storage';
import { AppStateContainer, installDefaultStorageErrorBroadcaster } from './appState';
import { AlarmScheduler } from './scheduler';
import { Pomodoro } from './pomodoro';
import { Countdown } from './countdown';
import { WindowManager } from './windows';
import { WindowState } from './windowState';
import { installTray } from './tray';
import { registerIpc } from './ipc';
import { Notifier } from './notifier';
import { PUSH } from '../shared/constants';
import { formatLocalHMS } from '../shared/time';
import { setIsAppQuitting } from './quitState';
async function ensureDataFile(): Promise<string> {
// 始终写到 userData避免落在 asar / 安装目录这种只读位置。
const dir = app.getPath('userData');
await fs.mkdir(dir, { recursive: true });
return join(dir, 'app-state.json');
}
function windowStatePath(): string {
// 跟 app-state.json 放同目录,方便备份/迁移
return join(app.getPath('userData'), 'window-state.json');
}
// 提升到模块级,让 will-quit 拿得到setInterval 是 refed 的,
// 不显式 clearInterval 会让 Electron 退出延迟 ~1sscheduler 1s tick
let clockTimer: NodeJS.Timeout | null = null;
let trayInstance: (import('electron').Tray & { refresh?: () => void }) | null = null;
let pomodoro: Pomodoro | null = null;
let countdown: Countdown | null = null;
let scheduler: AlarmScheduler | null = null;
let notifier: Notifier | null = null;
let storage: Storage | null = null;
let state: AppStateContainer | null = null;
let windows: WindowManager | null = null;
// will-quit 里如果 storage 还在防抖窗口内 → preventDefault + flush + app.quit。
// 第二次 will-quit 会再次进入同一段代码 → 死循环。用一个 guard 锁住。
let finalFlushDone = false;
// 单实例锁:自启 + 手动图标双开会写竞态F-21 修复)。
// 第二个实例启动时 attempt 失败 → app.quit(),主实例通过 second-instance 事件
// 拿到唤醒机会(让用户感知到"已经开过")。
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
// 第二次启动:主实例拿到唤醒机会,把已经在跑的主窗提到前台。
windows?.openMainAndRoute();
});
}
app.whenReady().then(async () => {
// 抢不到单实例锁时不再启动主流程
if (!gotSingleInstanceLock) return;
try {
const filePath = await ensureDataFile();
storage = new Storage(filePath);
const initial = await storage.read();
state = new AppStateContainer(storage, initial);
// 2026-08 精简后 Notifier 只走系统通知;不再需要声音/全屏闪的任何依赖。
notifier = new Notifier();
scheduler = new AlarmScheduler(() => state!.get(), {
onFire: (a) => {
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) w.webContents.send(PUSH.alarmFired, a);
});
notifier!.fire(a).catch((err: unknown) => {
console.error('[main] notifier.fire failed:', (err as Error)?.message ?? err);
});
// repeat='daily':保留 enabledscheduler 已为它排好下一天的同刻。
// repeat='once'(默认 / 老存档):响过即停用,下次想用就在列表里手动打开。
// state.update() 串行化保证同 tick 多 alarm 触发不会互相覆盖
// (见 AppStateContainer.tail 的说明)。
if (a.repeat !== 'daily') {
state!.update(s => {
const hit = s.alarms.find(x => x.id === a.id);
if (hit) hit.enabled = false;
}).catch((err: unknown) => {
console.error('[main] disable fired alarm failed:', (err as Error)?.message ?? err);
});
}
}
});
pomodoro = new Pomodoro(state, {
onPhaseChange: (active, rounds, prevPhase) => {
// 阶段切换时统一触发提醒:从 focus → break"该休息")或 break → focus"继续专注")。
// prevPhase 由 Pomodoro.transition 注入第一次进入prevPhase == active.phase
// 视为 start(),不触发提醒(避免点开始就立刻响一下)。
//
// 关键:必须用 `active` / `rounds` 参数,不能从 state!.get() 重读 ——
// start / pause / resume 这几条路径的 persist() 是 fire-and-forget,
// 写到 disk + 把 state.current 切到新值要等 300ms 防抖 + writeAtomic,
// 但 onPhaseChange 是同步紧接着 persist() 立即触发的。此时
// state.get().pomodoro.active 仍是旧值(null / paused),拿这个推给
// 渲染端会让浮窗永远停在 "开始点击时的状态"——主窗因为有 storageChanged
// 兜底(写盘完成后才到)看不出来,浮窗没有这个兜底就直接裂了。
// transition() 自身 await 了 container.update,理论上重新读也对,
// 但参数本来就是权威值,统一用参数最稳。
if (active && prevPhase && prevPhase !== active.phase) {
// 必须挂 catch未捕获的 rejection 在 Node 20 / Electron 31 默认策略下
// 会带崩主进程(详见 Pomodoro.persist 的注释)。
notifier!.firePomodoro(state!.get().settings, prevPhase, active.phase)
.catch((err: unknown) => console.error('[main] firePomodoro failed:', (err as Error)?.message ?? err));
}
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) w.webContents.send(PUSH.pomodoroPhaseChanged, active, rounds);
});
}
});
countdown = new Countdown(state, {
onPhaseChange: (active) => {
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) w.webContents.send(PUSH.countdownPhaseChanged, active);
});
},
onFire: () => {
notifier!.fireCountdown(state!.get().settings)
.catch((err: unknown) => console.error('[main] fireCountdown failed:', (err as Error)?.message ?? err));
}
});
const winMgr = new WindowManager(() => state!.get());
windows = winMgr; // 暴露给 will-quit 持久化
// 主窗几何同步写盘(由 attachCloseToTray 在 isAppQuitting=true 时调用)。
// 必须用 writeFileSync异步写入 will-quit 之后没有窗口保存。
winMgr.setGeometrySaver((geom) => {
mkdirSync(dirname(windowStatePath()), { recursive: true });
writeFileSync(windowStatePath(), JSON.stringify(geom, null, 2));
});
// 读出上次保存的主窗位置/尺寸。如果第一次启动(文件不存在)则传 null
// WindowManager.openMain 会用 fallback居中到主屏
const ws = new WindowState();
await ws.load(windowStatePath());
winMgr.openMain(ws.get());
// 预创建 4 个浮窗(show:false)—— 这是"点击浮窗按钮瞬开"的核心:
// 之前是点开时才 new BrowserWindow + loadFile,~150-300ms 冷启动。
// 现在 BrowserWindow 进程在启动期就绪,hide → show 只需 OS 一次窗口显示调用。
// 时机选择:state 已就绪(alwaysOnTop 可读) + mainWin 已构造(多显示器选屏) +
// IPC 还未注册(避免 preload 求值时 race)。fire-and-forget,不阻塞主窗 ready-to-show。
void winMgr.precreateAllFloating();
// alwaysOnTop 即时生效F-8 修复):通过 settings 面板改 alwaysOnTop
// 时 onUpdate 同步把状态推到主窗,避免"已置顶但 OS 层没置顶"。
// 一次 update 现在只发一条 PUSH.storageChanged —— 渲染端的 store 会从
// snapshot 派生 topbar.alwaysOnTop省掉一次独立的 topbarChanged IPC。
// 唯一例外winToggleAlwaysOnTop 的 handler 自己触发了一次 PUSH.topbarChanged
// 给所有窗口(让"点击后立刻看到 UI 翻转"),这里保留它即可。
let prevAlwaysOnTop: boolean = state!.get().settings.alwaysOnTop === true;
state!.onUpdate((s) => {
const want = s.settings.alwaysOnTop === true;
const mainWin = winMgr.getMainWindow();
if (mainWin && want !== prevAlwaysOnTop) {
mainWin.setAlwaysOnTop(want);
}
prevAlwaysOnTop = want;
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) w.webContents.send(PUSH.storageChanged, s);
});
// 让浮窗的 opacity / onTop 立即生效(保存设置后无需重启浮窗)
winMgr.onStateChanged();
});
// 写盘失败broadcast 到所有窗口。渲染端用 toast 提示。
installDefaultStorageErrorBroadcaster(state!, (channel, payload) => {
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) w.webContents.send(channel, payload);
});
});
registerIpc({ state: state!, scheduler: scheduler!, pomodoro: pomodoro!, countdown: countdown!, windows: winMgr, notifier: notifier! });
// 时钟推送:每秒一次。基于本地时区构造 HH:MM:SS避免 UTC % 86400
// 在东八区少 8 小时的 bug。
const pushClock = (): void => {
// 所有窗口都已关闭时直接跳过getAllWindows 在 Chromium 里是 O(n) 扫描,
// 主进程 1Hz 唤醒时仍跑一遍没意义。窗口从 0 变到 1用户重开主窗
// storageChanged 已经会把新 state 推过去,所以这里省去也没副作用。
const wins = BrowserWindow.getAllWindows();
if (wins.length === 0) return;
const time = formatLocalHMS(new Date());
for (const w of wins) {
// 只发给可见窗口:主窗被 close → hide 后 Chromium 会按
// backgroundThrottling 把渲染进程节流,IPC send 队列挂;
// 显示时若挂着一堆 1Hz 时钟包会造成"恢复后秒跳"伪影(M-9 修复)。
if (w.isDestroyed() || !w.isVisible()) continue;
try {
w.webContents.send(PUSH.clockTick, time);
} catch (err) {
// webContents.send 在 webContents 已 disposed 但 isDestroyed() 还未来得及
// 翻 true 的瞬间会抛 "IPC channel closed" —— 静默吞掉只让日志可读,
// 不影响其它窗口推送。
console.warn('[main] pushClock send failed:', (err as Error)?.message ?? err);
}
}
};
// 立即推一次,避免渲染进程启动后到首个 interval最多 1s之间显示 "--:--:--" 占位符
pushClock();
clockTimer = setInterval(pushClock, 1000);
trayInstance = installTray({
openMain: () => winMgr.openMainAndRoute(),
openSettings: () => winMgr.openMainAndRoute('settings'),
// 托盘菜单点击 toggle 语义:开了就关,关了就开
toggleFloating: (kind) => {
if (winMgr.isFloatingOpen(kind)) winMgr.closeFloating(kind);
else winMgr.openFloating(kind);
},
isFloatingOpen: (kind) => winMgr.isFloatingOpen(kind),
quit: () => { setIsAppQuitting(true); app.quit(); }
});
// 浮窗开关变化时同步刷新托盘菜单(更新 checkbox 标记)。
// WindowsManager 已经在 openFloating / closed 时广播 floatingWindowsChanged 给所有窗口。
// 托盘菜单是 OS 级组件,不在 webContents 推送范围内 —— 单独 refresh。
winMgr.onFloatingChange = () => trayInstance?.refresh?.();
scheduler.start();
} catch (err) {
// 任一步失败都不应该让 promise 静默吞掉:打印并退出,避免半成品运行。
console.error('[main] startup failed:', err);
// 半启动失败兜底清理(M-8):已经构造的 timer/tray/scheduler 必须停,
// 否则 1Hz 定时器继续空跑、孤儿 tray 出现 → 用户看到奇怪的图标或高 CPU 占用。
countdown?.stop();
pomodoro?.stop();
scheduler?.stop();
if (clockTimer) { clearInterval(clockTimer); clockTimer = null; }
if (trayInstance && !trayInstance.isDestroyed()) trayInstance.destroy();
setIsAppQuitting(true);
app.quit();
}
}).catch((err) => {
console.error('[main] whenReady rejected:', err);
setIsAppQuitting(true);
app.quit();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => {
setIsAppQuitting(true);
countdown?.stop();
// 显式销毁预创建的 4 个浮窗 BrowserWindow,避免依赖 Electron 默认 close 阶段
// (will-quit 之前 window-all-closed 不一定会触发 —— 主窗 hide 模式下不 quit)。
// 不在这里 clear 浮窗 Map,反正进程结束。
if (windows) {
windows.destroyAllFloating();
// 主窗单独销毁 —— hide 状态下 Electron 不一定触发 'closed' 事件,
// 主动 destroy 兜底(F-残留进程修复)。
windows.destroyMainWindow();
}
// 关键(F-残留进程修复):tray 必须在 before-quit 立即销毁,而不是等到 will-quit。
// 早先 tray 在 will-quit 才 destroy,而 will-quit 中常常 preventDefault 等 storage.flush()。
// flush 是 async,如果磁盘 IO 卡住或 finally 没跑到,tray 实例就没释放 —— Windows 上
// 会留下"幽灵图标"直到下次系统刷新(鼠标 hover 系统托盘),同时主进程因 tray 还活着
// 而被 Electron 持有,即便没有 timer 也不会退出。before-quit 阶段没有 await,同步
// destroy 一定生效。
if (trayInstance && !trayInstance.isDestroyed()) trayInstance.destroy();
trayInstance = null;
});
app.on('will-quit', (e) => {
// 顺序:先停倒计时/pomodoro timer再清 notifier当前已是 no-op
// 再清 scheduler 1s tick最后清 clock tick。
countdown?.stop();
pomodoro?.stop();
notifier?.cancelAll();
if (scheduler) { scheduler.stop(); }
if (clockTimer) { clearInterval(clockTimer); clockTimer = null; }
// tray 已在 before-quit 销毁;此处再做一次幂等保护(防止 before-quit 走过的退出路径
// 比如 gotSingleInstanceLock=false 直接 app.quit 跳过 before-quit 的副作用)。
if (trayInstance && !trayInstance.isDestroyed()) trayInstance.destroy();
trayInstance = null;
// 主窗几何持久化已搬到 close 事件attachCloseToTray 在 isAppQuitting=true 时
// 直接走 saveMainGeometryOnClose → geometrySaver → writeFileSync—— will-quit
// 触发时主窗已经被销毁getMainGeometry() 返回 null所以这里不再写。
// 把 300ms 防抖窗口内未落盘的 state 立即落盘F-3.3 修复):
// 否则用户改了设置立即退出,会丢这次写。
// will-quit 之后是同步阶段async flush 不会等待——必须 preventDefault + 等 flush
// 完成后再 app.quit() 一次,否则这次写就丢了。
// guard 防止 preventDefault + 再次 app.quit() 触发第二轮 will-quit 进入同一分支
// 造成无限递归(每次都拿到 storage.queue 为空但仍走整段逻辑)。
if (storage && !finalFlushDone) {
finalFlushDone = true;
e.preventDefault();
// F-残留进程修复:flush 兜底 timer。如果磁盘 IO 卡住 / 写盘路径抛同步异常 /
// 不小心把 storage 句柄漏到了下一次循环,storage.flush() 的 promise 可能永远不
// resolve,`.finally(() => app.quit())` 就不跑,主进程挂起 —— 此时所有 timer
// 已停,task manager 看就是"CPU 0% 但进程不退"。1500ms 兜底后强制 app.exit()
// (硬退出,跳过任何还没走完的 handler;此时该写的都已经 flush 或即将 flush,
// 丢一次写比进程永远挂着好)。
const forceExit = setTimeout(() => {
// eslint-disable-next-line no-console
console.warn('[main] flush 兜底超时,强制 app.exit()');
app.exit(0);
}, 1500);
// unref 让这个 timer 不阻止 Node 自身在没人 hold 时退出(虽然 app.quit 已会 hold)
forceExit.unref();
storage.flush()
.catch((err) => { console.warn('[main] final flush failed:', (err as Error).message); })
.finally(() => {
clearTimeout(forceExit);
app.quit();
});
}
});

275
src/main/ipc.ts Normal file
View File

@@ -0,0 +1,275 @@
import { ipcMain, BrowserWindow, Menu, shell } from 'electron';
import { randomUUID } from 'node:crypto';
import type { AppStateContainer } from './appState';
import type { AlarmScheduler } from './scheduler';
import type { Pomodoro } from './pomodoro';
import type { Countdown } from './countdown';
import type { WindowManager } from './windows';
import type { Notifier } from './notifier';
import type { Settings, Alarm, FloatingKind, Behavior, AlarmRepeat } from '../shared/types';
import { IPC, PUSH } from '../shared/constants';
export interface IpcDeps {
state: AppStateContainer;
scheduler: AlarmScheduler;
pomodoro: Pomodoro;
countdown: Countdown;
windows: WindowManager;
notifier: Notifier;
}
/** 从 renderer 收到的 patch 里挑出白名单字段;剥掉 id/createdAt/updatedAt 等
* 受信任字段,防止恶意 renderer 篡改自身归属或时间戳。注意:行为约定走 ['notify']
* —— 老存档保留的 sound/volume 字段已不再写入新值。 */
function sanitizeAlarmPatch(patch: Partial<Alarm>): Partial<Alarm> {
const out: Partial<Alarm> = {};
if (typeof patch.label === 'string') out.label = patch.label;
if (typeof patch.time === 'string') out.time = patch.time;
if (typeof patch.enabled === 'boolean') out.enabled = patch.enabled;
if (patch.repeat === 'once' || patch.repeat === 'daily') out.repeat = patch.repeat;
if (Array.isArray(patch.behavior)) {
out.behavior = patch.behavior.filter((b): b is Behavior => b === 'notify');
}
return out;
}
/** Settings 白名单:UI 可以覆盖的字段。任何未知键都丢弃。 */
const SETTINGS_KEYS = new Set<keyof Settings>(['alwaysOnTop', 'themeId', 'reminder']);
function sanitizeSettingsPatch(patch: Partial<Settings>): Partial<Settings> {
const out: Partial<Settings> = {};
for (const k of Object.keys(patch) as (keyof Settings)[]) {
if (SETTINGS_KEYS.has(k)) (out as Record<string, unknown>)[k] = patch[k];
}
return out;
}
export function registerIpc(deps: IpcDeps): void {
ipcMain.handle(IPC.clockGetState, () => deps.state.get());
// 注意scheduler.reschedule* 必须放在 update() **之后**。
// AppStateContainer.update 是先跑 mutator、之后才把 this.current 换成新对象,
// 所以在 mutator 里调度器读到的还是旧 state —— 早先 alarmUpdate 改时间时
// fires 会被写成旧时刻,且 tickNow 不会纠正(条目已存在),要重启才生效。
ipcMain.handle(IPC.alarmAdd, (_e, draft: Omit<Alarm, 'id' | 'createdAt' | 'updatedAt'>) => {
const now = Date.now();
// 提前把 id 生成好:用闭包捕获,让 update 闭包与返回 promise 共享同一 id。
// 这样可以避免"返回 list[list.length - 1]"在并发下错把别的 id 还回去。
const id = randomUUID();
// repeat 缺省视为 'once':老调用方(/未来忘了传值的渲染端)也能正常落盘。
const repeat: AlarmRepeat = draft.repeat === 'daily' ? 'daily' : 'once';
return deps.state.update(s => {
const a: Alarm = { ...draft, id, repeat, createdAt: now, updatedAt: now };
s.alarms.push(a);
}).then(() => {
deps.scheduler.rescheduleOne(id);
return deps.state.get().alarms.find(a => a.id === id);
});
});
ipcMain.handle(IPC.alarmUpdate, (_e, id: string, patch: Partial<Alarm>) => {
const safe = sanitizeAlarmPatch(patch);
return deps.state.update(s => {
const idx = s.alarms.findIndex(a => a.id === id);
if (idx >= 0) {
const cur = s.alarms[idx]!;
s.alarms[idx] = { ...cur, ...safe, updatedAt: Date.now() };
}
}).then(() => {
deps.scheduler.rescheduleOne(id);
return deps.state.get().alarms.find(a => a.id === id);
});
});
ipcMain.handle(IPC.alarmDelete, (_e, id: string) => {
return deps.state.update(s => {
s.alarms = s.alarms.filter(a => a.id !== id);
}).then(() => { deps.scheduler.rescheduleOne(id); });
});
ipcMain.handle(IPC.pomoStart, () => deps.pomodoro.start());
ipcMain.handle(IPC.pomoPause, () => deps.pomodoro.pause());
ipcMain.handle(IPC.pomoResume, () => deps.pomodoro.resume());
ipcMain.handle(IPC.pomoSkip, () => deps.pomodoro.skip());
ipcMain.handle(IPC.pomoReset, () => deps.pomodoro.reset());
ipcMain.handle(IPC.cdStart, (_e, ms: number) => deps.countdown.start(ms));
ipcMain.handle(IPC.cdPause, () => deps.countdown.pause());
ipcMain.handle(IPC.cdResume, () => deps.countdown.resume());
ipcMain.handle(IPC.cdReset, () => deps.countdown.reset());
ipcMain.handle(IPC.cdSetDuration, (_e, ms: number) => {
// ms 必须为正有限数;否则抛错回到渲染端,UI 在 try/catch 里弹 toast,
// 避免「输入了无效值,UI 没反应」的静默失败体验。
if (!Number.isFinite(ms) || !(ms > 0)) {
throw new Error(`cdSetDuration: ms 必须是正数,收到 ${ms}`);
}
return deps.state.update(s => { s.countdown.lastDurationMs = ms; });
});
/**
* 用户在倒计时 fired 状态点"完成"时,让主进程把仍在跑的提醒路径停掉。
* 当前唯一通道是系统通知cancelAll 已是 no-op保留 IPC 名字以兼容
* 渲染端调用,未来若恢复声音/全屏闪也无需再改协议。
*/
ipcMain.handle(IPC.notifierCancel, async () => {
deps.notifier.cancelAll();
});
ipcMain.handle(IPC.settingsSet, (_e, patch: Partial<Settings>) => {
const safe = sanitizeSettingsPatch(patch);
return deps.state.update(s => { s.settings = { ...s.settings, ...safe }; });
});
// 注意handler 的返回值会被结构化克隆送回渲染进程BrowserWindow 克隆不了
// invoke 会 reject "An object could not be cloned.")。这里只做副作用,
// 需要窗口 id 的场景用 floatingWindowId()。
// 切换开了就关关了就开topbar 按钮 / 浮窗自身关闭按钮反向操作时用)。
// 这里只暴露 toggle —— 4 个独立的 openFloating* 入口在 2025-08 已确认无 renderer
// 调用方,Tray 与 topbar 都走 toggle曾经的"openClock"等一对一入口被删除。
ipcMain.handle(IPC.winToggleFloating, (_e, kind: FloatingKind) => {
if (deps.windows.isFloatingOpen(kind)) deps.windows.closeFloating(kind);
else deps.windows.openFloating(kind);
});
// 关闭 / 右键菜单 — 全部按 kind 分派(默认 pomodoro 保持向后兼容)。
// 注意:早先的 winMoveFloatingBy IPC 已删除 —— 浮窗拖动已由
// -webkit-app-region: drag 完全交给 OS,无 JS 干预路径。如果以后再次需要
// 程序化位移,再起一个独立 IPC 即可。
// 预创建+hide 模式:此处调 hide 而非 close,保留 BrowserWindow 实例,下次 toggle 瞬开。
// 真正的 destroy 只在 OS 层触发(cmd+w / app.quit),由 windows.ts 的 'closed' 监听兜底。
ipcMain.handle(IPC.winCloseFloating, (_e, kind: FloatingKind = 'pomodoro') => {
deps.windows.hideFloating(kind);
});
ipcMain.handle(IPC.winFloatingContextMenu, (_e, kind: FloatingKind = 'pomodoro') => {
const id = deps.windows.floatingWindowId(kind);
if (id === null) return;
const win = BrowserWindow.fromId(id);
if (!win || win.isDestroyed()) return;
const menu = buildFloatingContextMenu(kind, win, deps);
menu.popup({ window: win });
});
ipcMain.handle(IPC.winToggleAlwaysOnTop, async () => {
const cur = deps.state.get().settings.alwaysOnTop === true;
const next = !cur;
await deps.state.update(s => { s.settings.alwaysOnTop = next; });
// 显式调用 WindowManager.setAlwaysOnTop:只影响主窗+浮窗,编辑器保持 OS 默认。
// index.ts state.onUpdate 链路会再触发一次(主要负责 mainWin + broadcast),
// 这里负责确保调用方立即生效,且不会误改 editor。
deps.windows.setAlwaysOnTop(next);
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) w.webContents.send(PUSH.topbarChanged, { alwaysOnTop: next });
});
});
// Windows 自定义标题栏按钮minimize / maximize / close
// 这些只能影响 focused window因为隐藏式标题栏的"窗口"概念是 OS 级的,
// 不像"toggleAlwaysOnTop"那样要对所有窗口生效。
ipcMain.handle(IPC.winMinimize, () => { BrowserWindow.getFocusedWindow()?.minimize(); });
ipcMain.handle(IPC.winToggleMaximize, () => {
const w = BrowserWindow.getFocusedWindow();
if (!w) return;
if (w.isMaximized()) w.unmaximize();
else w.maximize();
});
ipcMain.handle(IPC.winClose, () => {
const w = BrowserWindow.getFocusedWindow();
// 复用"关闭按钮 → 隐藏到托盘"的逻辑:直接在窗口上 e.preventDefault() 不行,
// 必须模拟一次 close 事件让 attachCloseToTray 的 handler 决定是否真的关。
w?.close();
});
/** 用系统默认浏览器打开外链:仅接受 http(s) 协议,避免 file:// / shell:
* 等被滥用做本地命令注入。shell.openExternal 自身有白名单校验,但 renderer
* 是不可信源,再做一道拦截把面缩到最小。返回值用 boolean 而不是 throw
* 渲染端拿到 false 时显示 toast不打断主流程。 */
ipcMain.handle(IPC.shellOpenExternal, async (_e, url: unknown): Promise<boolean> => {
if (typeof url !== 'string') return false;
if (!/^https?:\/\//i.test(url)) return false;
// shell.openExternal 返回 Promise<void>:成功 resolve、失败 reject。
// 直接 return 这个 promise 会让 ipc 序列化出 undefined渲染端拿到非 true
// 就当失败,于是明明打开成功了还弹 toast —— 经典踩坑。
// 正确做法是 await 一下再返回 boolean成功 true、失败协议被拒 / OS
// 没装浏览器 / 用户取消等false。
try {
await shell.openExternal(url);
return true;
} catch {
return false;
}
});
}
/** 按 kind 构造浮窗右键菜单。每个浮窗菜单内容不同:
* - pomodoro开始/暂停、跳过、重置
* - countdown开始/暂停、重置
* - clock / alarms只有"打开主窗口" + "关闭浮窗"
* - alarms多了"新建闹钟" —— 跳转主窗到 alarms 面板,让用户在内联编辑器里填
*
* labels 走中文(与现有 ipc.ts 风格一致i18n 仅在渲染端用)。 */
function buildFloatingContextMenu(
kind: FloatingKind,
win: BrowserWindow,
deps: IpcDeps
): Menu {
// 预创建+hide 模式:菜单"关闭浮窗"走 hide 而非 close,保留 BrowserWindow 实例,
// 菜单 label 仍是"关闭浮窗"对用户透明,语义保持(视觉消失 + 状态广播)。
const close = () => { if (!win.isDestroyed()) win.hide(); };
// 共用 openMainAndRoute —— 替代原先 12 行 did-finish-load 样板。
const openMain = (route?: 'clock' | 'alarms' | 'settings') => {
deps.windows.openMainAndRoute(route);
};
const openNewAlarm = () => { openMain('alarms'); };
if (kind === 'pomodoro') {
const active = deps.state.get().pomodoro.active;
const paused = !!active?.paused;
const hasActive = !!active;
return Menu.buildFromTemplate([
{ label: hasActive ? (paused ? '继续' : '暂停') : '开始', click: () => {
if (!hasActive) deps.pomodoro.start();
else if (paused) deps.pomodoro.resume();
else deps.pomodoro.pause();
}
},
{ label: '跳过', enabled: hasActive, click: () => deps.pomodoro.skip() },
{ label: '重置', enabled: hasActive, click: () => deps.pomodoro.reset() },
{ type: 'separator' },
{ label: '打开主窗口', click: () => openMain() },
{ label: '关闭浮窗', click: close }
]);
}
if (kind === 'countdown') {
const a = deps.state.get().countdown.active;
const paused = !!a?.paused;
const hasActive = !!a;
return Menu.buildFromTemplate([
{ label: hasActive ? (paused ? '继续' : '暂停') : '开始', click: () => {
if (!hasActive) {
// 没在跑:取上次时长启动;空时长则不开
const ms = deps.state.get().countdown.lastDurationMs;
if (ms > 0) deps.countdown.start(ms);
} else if (paused) deps.countdown.resume();
else deps.countdown.pause();
}
},
{ label: '重置', enabled: hasActive, click: () => deps.countdown.reset() },
{ type: 'separator' },
{ label: '打开主窗口', click: () => openMain() },
{ label: '关闭浮窗', click: close }
]);
}
if (kind === 'alarms') {
return Menu.buildFromTemplate([
{ label: '新建闹钟', click: openNewAlarm },
{ type: 'separator' },
{ label: '打开主窗口', click: () => openMain() },
{ label: '关闭浮窗', click: close }
]);
}
// kind === 'clock'
return Menu.buildFromTemplate([
{ label: '打开主窗口', click: () => openMain() },
{ label: '关闭浮窗', click: close }
]);
}

67
src/main/notifier.ts Normal file
View File

@@ -0,0 +1,67 @@
import { Notification } from 'electron';
import type { Settings } from '../shared/types';
/**
* 通用"提醒"执行器。
*
* 经过 2026-08 的精简,只保留"系统通知"一条路径 —— 全屏闪、铃声、蜂鸣都已下线。
* 提醒时机仍按 alarm.scheduler / countdown / pomodoro 各自的 hook 触发,
* 所有路径统一走到这里的 fire(),文案 + 紧急度也由调用方注入。
*/
export class Notifier {
/**
* 闹钟到达Alarm 类型已不再带声音/全屏闪字段,统一走系统通知。
*/
async fire(alarm: { label: string; time: string }): Promise<void> {
await this.runNotify({ label: alarm.label, body: `到点了 · ${alarm.time}` });
}
/** 倒计时归零:派一个固定的 "倒计时 / 时间到" 文案。 */
async fireCountdown(_settings: Settings): Promise<void> {
await this.runNotify({ label: '倒计时', body: '时间到' });
}
/** 番茄钟阶段切换break ↔ focus 各自一句话。 */
async firePomodoro(
_settings: Settings,
_prevPhase: 'focus' | 'shortBreak' | 'longBreak',
nextPhase: 'focus' | 'shortBreak' | 'longBreak'
): Promise<void> {
const isBreak = nextPhase !== 'focus';
await this.runNotify({
label: '番茄钟',
body: isBreak ? '该休息一下了' : '继续专注'
});
}
/**
* 进程退出 / 用户点"完成":保留 cancelAll 接口签名。
* 当前没有会持续播放的声音或全屏窗口,函数本身是 no-op但调用方仍然在
* (保留下来的 IPC 通道)调它,未来若再加提醒手段无需再改协议。
*/
cancelAll(): void {
// no-op
}
private async runNotify(input: NotifyInput): Promise<void> {
if (!Notification.isSupported()) return;
try {
const n = new Notification({
title: input.label || '提醒',
body: input.body,
urgency: 'critical'
});
n.show();
} catch (err) {
// 通知失败时记录 —— 单纯吞掉会让"为什么没响"完全无可观察(MS-9 修复)。
// 不向上抛错:失败不应影响其它提醒路径并存。
// eslint-disable-next-line no-console
console.error('[notifier] Notification 创建失败:', (err as Error)?.message ?? err);
}
}
}
interface NotifyInput {
label: string;
body: string;
}

288
src/main/pomodoro.ts Normal file
View File

@@ -0,0 +1,288 @@
import type { AppState, PomodoroActiveState, PomodoroPhase } from '../shared/types';
import { POMODORO_DURATIONS_MS, ROUNDS_BEFORE_LONG_BREAK, localDateKey } from '../shared/constants';
import type { AppStateContainer } from './appState';
export interface PomodoroDeps {
/**
* 阶段切换时调用。
* - active: 当前 active 状态null 表示已重置)
* - completedRounds: 累计完成的 focus 轮数
* - prevPhase: 切换前的阶段(用于判断"是否真的切换"——start() 时 active?.phase
* == prevPhase是 start 而不是 transitionnull 表示 reset
*/
onPhaseChange: (
active: PomodoroActiveState | null,
completedRounds: number,
prevPhase: PomodoroPhase | null
) => void;
}
function durationFor(phase: PomodoroPhase): number {
return POMODORO_DURATIONS_MS[phase];
}
function nextPhase(current: PomodoroPhase, round: number): PomodoroPhase {
if (current === 'focus') {
return round % ROUNDS_BEFORE_LONG_BREAK === 0 ? 'longBreak' : 'shortBreak';
}
return 'focus';
}
export class Pomodoro {
private timer: NodeJS.Timeout | null = null;
private inflight: Promise<void> | null = null;
private resetting: boolean = false;
/**
* 每次 start() 自增。transition 在写入前 / 写入后比对:
* - 若 start 在 transition 期间被调用version 改变transition
* 会跳过 onPhaseChange 广播(避免把刚 start 的状态立刻又翻页);
* - 同时 transition 末尾用此标志保护 onPhaseChange 不被 stale 调用。
*/
private activeVersion: number = 0;
constructor(private container: AppStateContainer, private deps: PomodoroDeps) {}
start(): void {
// 同步设 state调用方pause/resume/skip/测试)能立刻读到新 active
// 这是沿用旧版的行为契约AppStateContainer.update 内部把 this.current
// 的赋值放在 await 之前)。
//
// 若正处于 reset() 中resetting=true推迟启动避免被 reset 的延迟写覆盖。
// reset 完成后会清空 active用户点 start() 时 resetting 一般已为 false。
// 但万一 reset 仍在 pending 链上 → 这次 start 推迟到 reset 写完之后再处理。
if (this.resetting) {
const pending = this.inflight ?? Promise.resolve();
pending.then(() => { this.start(); }).catch(() => { /* rethrow 也不会让用户重试,吞掉 */ });
return;
}
const s = this.container.get();
const prev = s.pomodoro.active;
const round = prev?.currentRound ?? 1;
const phase: PomodoroPhase = prev?.phase ?? 'focus';
// 若已有 paused 状态start 视为 "重新开始该阶段":保留剩余时间
// (不要把它重置到满时长,否则用户暂停 5 分钟后点"开始"会丢 5 分钟)。
// 没有 paused → 走全新时长。
const remainingMs = prev && prev.paused ? prev.remainingMs : durationFor(phase);
const endsAt = Date.now() + remainingMs;
const active: PomodoroActiveState = {
phase, endsAt, paused: false, remainingMs, currentRound: round
};
this.activeVersion++;
this.persist(st => { st.pomodoro.active = active; });
// start() 不是 phase 切换prevPhase 传当前 active.phase让监听者自己判
// prev === curstart还是 prev !== curtransition
this.deps.onPhaseChange(active, this.container.get().pomodoro.completedRounds, phase);
this.runTimer();
}
pause(): void {
const a = this.container.get().pomodoro.active;
if (!a || a.paused) return;
// 暂停期间 tick 只会命中 `if (!a || a.paused) return`,纯属空转。
// 停表resume() 会通过 runTimer() 重新装上。
if (this.timer) { clearInterval(this.timer); this.timer = null; }
const remaining = Math.max(0, a.endsAt - Date.now());
this.persist(s => {
if (s.pomodoro.active) {
s.pomodoro.active = { ...s.pomodoro.active, paused: true, remainingMs: remaining };
}
});
// pause 不改 phaseprevPhase 保持与当前一致,避免误触发"切换"提醒。
this.deps.onPhaseChange(
{ ...a, paused: true, remainingMs: remaining },
this.container.get().pomodoro.completedRounds,
a.phase
);
}
resume(): void {
const a = this.container.get().pomodoro.active;
if (!a || !a.paused) return;
const endsAt = Date.now() + a.remainingMs;
this.persist(s => {
if (s.pomodoro.active) {
s.pomodoro.active = { ...s.pomodoro.active, paused: false, endsAt, remainingMs: a.remainingMs };
}
});
// resume 同上phase 不变。
this.deps.onPhaseChange(
{ ...a, paused: false, endsAt },
this.container.get().pomodoro.completedRounds,
a.phase
);
this.runTimer();
}
skip(): void {
if (!this.container.get().pomodoro.active) return;
const run = (): Promise<void> => {
const cur = this.container.get().pomodoro.active;
if (!cur) return Promise.resolve();
// skip() 不算完成counted=false 让 transition 不增加 completedRounds / history。
// 否则刚开 5 秒跳过 focus 就被记为完成一次,用户的心智模型就崩了。
return this.transition(cur.phase, cur.currentRound, false);
};
const next = (this.inflight ?? Promise.resolve())
.then(run)
.catch((err: unknown) => {
console.error('[pomodoro] skip failed:', (err as Error)?.message ?? err);
});
this.inflight = next;
// 清空 inflight 引用防止链增长,理由见 queue()。
next.finally(() => {
if (this.inflight === next) this.inflight = null;
});
}
reset(): void {
if (this.timer) { clearInterval(this.timer); this.timer = null; }
this.resetting = true;
// 与 start / skip / transition 同样 bump version让任何仍在 in-flight 的
// transition() 在 await 写盘返回后看到 version 不一致,主动 return
// 避免"reset 已把状态写回 idle但旧 transition 又把它覆盖成 nextPhase"。
this.activeVersion++;
// 必须把 then(...) 串到 this.inflight否则 start() 在 resetting=true 期间
// 排队时读到的 pending = Promise.resolve()inflight 仍是 nullstart 立即
// 通过 microtask 重入,再次看到 resetting=true 又排队 → 永久自旋。
// 300ms storage 防抖期间这个微任务循环会独占 macrotask让 setTimeout 永远
// 跑不到、resetting 永远不解锁 → 主进程死锁。
const next = (this.inflight ?? Promise.resolve()).then(async () => {
const prev = this.container.get().pomodoro.active;
// reset 同时清掉 completedRounds 与当前 active —— "重置" 应当让累计也归零,
// 否则下一次 start 进入 focus #2 时completedRounds=6 % 4 = 2 仍按旧累计
// 判断长休时机,与 UI pill 错位。
await this.container.update(s => {
s.pomodoro.active = null;
s.pomodoro.completedRounds = 0;
});
// reset 传 prevPhase如果有 active让监听者区分"reset 到 idle"与"还没开始"。
this.deps.onPhaseChange(null, 0, prev?.phase ?? null);
}).catch((err: unknown) => {
// reset 失败时让 onPhaseChange(null) 仍广播 —— 上一行的 await 才是关键。
// 同时把 resetting 解锁,否则后续 start 永远进 pending.start() 链。
console.error('[pomodoro] reset failed:', (err as Error)?.message ?? err);
}).finally(() => {
// resetting 必须在 .finally 中清,确保即使中间的 .catch 也会触发
// (否则 reset() 异常会永久卡住后续 start)。
this.resetting = false;
});
this.inflight = next;
// 清空 inflight 引用防止链增长。
next.finally(() => {
if (this.inflight === next) this.inflight = null;
});
}
/** 进程退出前调用,避免 setInterval 阻止退出。 */
stop(): void {
if (this.timer) { clearInterval(this.timer); this.timer = null; }
// F-残留进程修复:退出时清空 inflight 链引用,避免 in-flight 的 transition() 还在
// 跑微任务(可能还在 await this.container.update)却持有对实例的强引用,导致
// Node 觉得"事件循环还有回调",即便 timer 停了也不退出。
// 此时已经在 will-quit / before-quit 路径上,任何后续 transition 都不再需要执行。
this.inflight = null;
this.resetting = false;
}
/**
* fire-and-forget 落盘。
* 不能写成 `void this.container.update(...)` —— `void` 不会给 promise 挂
* rejection handler写盘失败时会变成 unhandledRejectionNode 20 / Electron 31
* 默认 `--unhandled-rejections=throw`,磁盘满或权限失败就会直接把主进程带崩。
* 失败本身已经由 AppStateContainer 的 onStorageError 广播到 UI 弹 toast
* 这里只需要保证它不会逃逸成未捕获拒绝。
*/
private persist(mutator: (s: AppState) => void): void {
this.container.update(mutator).catch((err: unknown) => {
console.error('[pomodoro] persist failed:', (err as Error)?.message ?? err);
});
}
private runTimer(): void {
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => {
const a = this.container.get().pomodoro.active;
if (!a || a.paused) return;
const left = a.endsAt - Date.now();
if (left <= 0) {
this.queue(() => this.transition(a.phase, a.currentRound));
}
}, 1000);
}
/** 把 transition 排队到 inflight 链上。供 runTimer 内部使用。 */
private queue(work: () => Promise<void>): void {
// 不能 `void` 这个 promise——必须挂 catch handler否则 transition 抛错时
// 会变成 unhandledRejection并在 Node 20 / Electron 31 的默认策略下带崩主进程。
// 错误已经由 AppStateContainer 的 onStorageError 广播到 UI 弹 toast
// 这里只需保证它不会逃逸成未捕获拒绝。
const next = (this.inflight ?? Promise.resolve())
.then(() => work())
.catch((err: unknown) => {
console.error('[pomodoro] queue work failed:', (err as Error)?.message ?? err);
});
this.inflight = next;
// chain 完成后清空 inflight:每次 transition 之后 this.inflight 不再持有
// 上一帧的 promise 链引用,避免长时间使用下 inflight 变成 N 层深链
// (泄漏成比例)。期间已调度的下一个 transition 仍持自己的引用,串行性不受影响。
next.finally(() => {
if (this.inflight === next) this.inflight = null;
});
}
private async transition(phase: PomodoroPhase, round: number, counted: boolean = true): Promise<void> {
const versionAtStart = this.activeVersion;
let completedRounds = this.container.get().pomodoro.completedRounds;
// 只有 "自然完成一个 focus" 才 +1skip() 调 transition 时 counted=false
// 用户没真做完这一轮,不该记入完成数 / 历史。
if (phase === 'focus' && counted) completedRounds += 1;
// 用 currentRound 而不是 completedRounds 判断长休:
// completedRounds 是 monotonic 累计值(且 reset 后未清零),用它会让
// 完成 6 个 focus 后再开始 → 第 2 个就直接进 longBreak与 UI 第 1 轮 pill 冲突。
// currentRound 是 1..ROUNDS_BEFORE_LONG_BREAK 循环值,与 pill 同步。
const nextPhaseName = nextPhase(phase, phase === 'focus' ? (counted ? completedRounds : round) : round);
// currentRound 是"本轮循环里的第几个番茄",契约是 1..ROUNDS_BEFORE_LONG_BREAK。
// 长休结束就是一个循环走完 → 回到 1否则进 focus 时 +1休息阶段保持不变。
// (早先是无条件 round + 1永不回绕UI 会显示"第 5 轮""第 6 轮"…)
const nextRound = nextPhaseName !== 'focus'
? round
: phase === 'longBreak' ? 1 : Math.min(round + 1, ROUNDS_BEFORE_LONG_BREAK);
const endsAt = Date.now() + durationFor(nextPhaseName);
const active: PomodoroActiveState = {
phase: nextPhaseName, endsAt, paused: false, remainingMs: durationFor(nextPhaseName), currentRound: nextRound
};
// 今日历史:完成一个 focus 才 +1跨天自动重置skip 不计。
const today = localDateKey();
const prev = this.container.get().pomodoro.history;
const newCount = (phase === 'focus' && counted)
? (prev.date === today ? prev.count + 1 : 1)
: (prev.date === today ? prev.count : 0);
// byDate滚动 30 天历史(避免无限增长)。完成 focus 时 +1跨天同样正确skip 不计。
const byDate = { ...(prev.byDate ?? {}) };
if (phase === 'focus' && counted) {
byDate[today] = (byDate[today] ?? 0) + 1;
// 修剪:只保留最近 30 天
const keys = Object.keys(byDate).sort();
const cutoff = new Set<string>();
for (let i = Math.max(0, keys.length - 30); i < keys.length; i++) {
cutoff.add(keys[i]!);
}
for (const k of Object.keys(byDate)) {
if (!cutoff.has(k)) delete byDate[k];
}
}
const newHistory = { date: today, count: newCount, byDate };
await this.container.update(s => {
s.pomodoro.active = active;
s.pomodoro.completedRounds = completedRounds;
s.pomodoro.history = newHistory;
});
// 若 start() 在我们写入之后被调用version 已变,跳过广播;
// 否则 start 设置的 state 会被我们的广播"反向"覆盖。
// start() / reset() 都会 bump activeVersiontransition 写完之后版本不一致,
// 说明它已被新的 start/reset 抢在前面 → 不要把刚被覆盖的状态再 broadcast 出去。
// reset 还会带 resetting=true这里同样视为"我已被 reset 取代"。
if (this.resetting || this.activeVersion !== versionAtStart) return;
// transition把 prevPhase 一并传出去,让监听者明确"刚切了阶段",可触发提醒。
this.deps.onPhaseChange(active, completedRounds, phase);
}
}

12
src/main/quitState.ts Normal file
View File

@@ -0,0 +1,12 @@
// App-wide "正在退出" 标志位。
// 之前由 ./index 持有windows.ts 反向 import 它 → 循环依赖rollup 双重打包后
// IPC handler 会注册两次。独立成模块后两边都单向 import问题消失。
let isQuitting = false;
export function getIsAppQuitting(): boolean {
return isQuitting;
}
export function setIsAppQuitting(v: boolean): void {
isQuitting = v;
}

View File

@@ -0,0 +1,24 @@
/**
* 渲染进程入口解析 —— 与 Electron 解耦,方便单测。
*
* electron-vite 在 dev 模式下会把 dev server 地址写进 process.env.ELECTRON_RENDERER_URL
* (形如 http://localhost:5173无尾斜杠。多入口项目里各入口分别位于
* `<devUrl>/index.html`、`<devUrl>/floating-pomodoro/index.html` 等。
* 历史 bugdev 分支写死加载 dev server 根目录,导致任何窗口都渲染主界面。
*/
export type RendererTarget =
| { kind: 'url'; url: string }
| { kind: 'file'; file: string; query: Record<string, string> | undefined };
export function resolveRendererTarget(
file: string,
query?: Record<string, string>,
devServerUrl: string | undefined = process.env.ELECTRON_RENDERER_URL
): RendererTarget {
if (devServerUrl) {
const base = devServerUrl.replace(/\/+$/, '');
const search = query ? '?' + new URLSearchParams(query).toString() : '';
return { kind: 'url', url: `${base}/${file}${search}` };
}
return { kind: 'file', file, query };
}

125
src/main/scheduler.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { Alarm, AppState } from '../shared/types';
import { nextFireAt } from '../shared/time';
export interface SchedulerDeps {
onFire: (alarm: Alarm) => void;
}
const TICK_MS = 1000;
export class AlarmScheduler {
private timer: NodeJS.Timeout | null = null;
private fires: Map<string, number> = new Map(); // id → nextFireAt
private lastFire: Map<string, number> = new Map(); // id → 上次 fired ts
constructor(private getState: () => AppState, private deps: SchedulerDeps) {}
start(): void {
// rescheduleAll 内部已通过 syncTimer 自动启停 1Hz 定时器:
// 没闹钟就不必每 1s 唤醒一次进程 —— Node / Electron 里 setInterval
// 是 refed 的,会拖住退出且每秒钟进一次 microtask。
if (this.timer) return;
this.rescheduleAll();
}
stop(): void {
if (this.timer) { clearInterval(this.timer); this.timer = null; }
// 退出时清空排程/去重缓存(F-残留进程修复):之前 stop 只 clearInterval,
// fires / lastFire Map 一直挂在实例上,实例本身又被 tray / windows 持有 → 微任务
// 链或 Map 迭代中的闭包让 Node 觉得"还有活儿",即便 timer 停了也不退出。
// 退出路径 stop 后不会再访问这两个 Map,直接清零最安全。
this.fires.clear();
this.lastFire.clear();
}
rescheduleAll(now: number = Date.now()): void {
const alarms = this.getState().alarms;
this.fires.clear();
// 批量重排时一并清掉 60s 去重窗口:保持与 rescheduleOne 一致 —— 否则
// 用户批量编辑后即点启用,刚被 fire 又 last-recorded 的 alarm 仍受抑制。
this.lastFire.clear();
for (const a of alarms) {
if (!a.enabled) continue;
this.fires.set(a.id, nextFireAt(a, new Date(now)));
}
this.syncTimer();
}
rescheduleOne(id: string, now: number = Date.now()): void {
const a = this.getState().alarms.find(x => x.id === id);
if (!a) {
this.fires.delete(id); this.lastFire.delete(id);
this.syncTimer();
return;
}
if (!a.enabled) {
this.fires.delete(id);
this.syncTimer();
return;
}
// 启用 → 清掉上次的 60s 去重时间戳。否则用户刚 disable → 60s 内 re-enable
// 会被 1-minute dedup 误吞F-3.2 修复)。
this.lastFire.delete(id);
this.fires.set(id, nextFireAt(a, new Date(now)));
this.syncTimer();
}
/** 根据 fires.size 自动启停 1Hz 定时器:没活儿时彻底停摆,不浪费唤醒。 */
private syncTimer(): void {
if (this.fires.size > 0) {
if (!this.timer) this.timer = setInterval(() => this.tick(), TICK_MS);
} else if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/** 供单元测试用的同步 tick */
tickNow(now: number = Date.now()): void {
// 防御性清掉 disabled alarm 的 fires 条目:未来若加批量 import / 第三方写
// 路径忘了调 rescheduleOne 同步 statestale fires 不会让 disabled 闹钟
// 仍然触发F-1 修复)。
// 先快照 alarm 列表:onFire 是同步回调,监听者(IPC bridge)可能更新 state —
// 若不快照,for-of 持有的数组引用在 update() 之后会指向旧对象,后续 read
// 会拿到 stale `seen` 集合、清错条目。
const alarms = this.getState().alarms.slice();
const seen = new Set<string>();
for (const a of alarms) {
seen.add(a.id);
if (!a.enabled) {
this.fires.delete(a.id);
continue;
}
const next = this.fires.get(a.id);
if (next === undefined) {
this.fires.set(a.id, nextFireAt(a, new Date(now)));
continue;
}
if (now >= next) {
const last = this.lastFire.get(a.id) ?? 0;
if (now - last < 60 * 1000) continue;
this.lastFire.set(a.id, now);
// repeat='daily':触发后立刻重排到明天的同一时刻(用 next + 60s 做"现在"
// 传入 nextFireAt让它落到下一个 wall-clock 同点)。'once' 老行为保持
// 不变:撤掉 fires 条目,由 onFire 监听者(主进程)把 enabled=false 写回。
// 不立刻重排 daily,本方法开头的 `next === undefined` 分支会在下一秒
// 把它重新排到第二天,但那是 onFire 已经派发之后的事,中间会有一秒
// 的"无 fires 条目"窗口 — tickNow 同一轮 for 循环里其他 enabled 闹钟
// 没被影响,但 syncTimer 看到 fires.size 从 N 降到 N-1,如果刚好降到 0
// 会把 1Hz 定时器停掉,daily 那条就再也排不回来了。
// 这里显式立刻把 daily 的下一时刻写进 fires,保持 timer 至少有一条。
if (a.repeat === 'daily') {
this.fires.set(a.id, nextFireAt(a, new Date(now + 60_000)));
} else {
this.fires.delete(a.id);
}
this.deps.onFire(a);
}
}
// 报警钟被删后,残留的 fires 条目也要清掉。
for (const id of Array.from(this.fires.keys())) {
if (!seen.has(id)) this.fires.delete(id);
}
}
private tick(): void { this.tickNow(); }
}

174
src/main/storage.ts Normal file
View File

@@ -0,0 +1,174 @@
import { promises as fs } from 'node:fs';
import type { FileHandle } from 'node:fs/promises';
import { dirname } from 'node:path';
import type { AppState } from '../shared/types';
import { DEFAULT_STATE, STATE_VERSION } from '../shared/constants';
interface Queued {
state: AppState;
resolve: () => void;
reject: (e: Error) => void;
}
const DEBOUNCE_MS = 300;
// 备份后缀带上当前版本号(从常量取),将来 STATE_VERSION 升级时自动区分
// .bak-v1 / .bak-v2 —— 老存档多次升级不会被互相覆盖(L-12 修复)。
const BAK_SUFFIX = `.bak-v${STATE_VERSION}`;
export class Storage {
private filePath: string;
private timer: NodeJS.Timeout | null = null;
private queue: Queued[] = [];
private running = false;
/** 当前在途的 drain。flush() 靠它避免"正在写时直接返回"导致丢最后一次写。 */
private inflight: Promise<void> | null = null;
constructor(filePath: string) {
this.filePath = filePath;
}
async read(): Promise<AppState> {
try {
await fs.mkdir(dirname(this.filePath), { recursive: true });
// 上次退出若落在 writeAtomic 的 sync 与 rename 之间,会留下一个 orphan .tmp;
// 这里主动清理,避免 state 文件与 .tmp 共存造成混乱(M-11)。
const tmp = this.filePath + '.tmp';
try { await fs.unlink(tmp); } catch { /* 不存在是正常情况 */ }
const raw = await fs.readFile(this.filePath, 'utf-8');
const parsed = JSON.parse(raw) as AppState;
if (parsed.version !== STATE_VERSION) {
// 版本不匹配:不要立刻 renamerename 是不可逆的,让用户失去备份的机会),
// 改用 copyFile 留 .bak-v1 副本,再读旧文件兜底默认值。
// 上层 AppStateContainer 看到的是 fallback 状态,用户启动后能继续用,
// 旧文件躺在 .bak-v1 里,他们可以手动迁移。
try {
await fs.copyFile(this.filePath, this.filePath + BAK_SUFFIX);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[storage] 版本不匹配备份失败:', (e as Error)?.message ?? e);
}
return structuredClone(DEFAULT_STATE);
}
// 迁移:旧版本(无 countdown 字段)补默认值
if (!(parsed as Partial<AppState>).countdown) {
(parsed as AppState).countdown = { active: null, lastDurationMs: 5 * 60 * 1000 };
}
return parsed;
} catch (err) {
// 读失败(文件损坏 / 解析失败):尝试备份到 .bak-v1 后 fallback 到默认。
// eslint-disable-next-line no-console
console.error('[storage] state 读失败,回退默认:', (err as Error)?.message ?? err);
try {
await fs.rename(this.filePath, this.filePath + BAK_SUFFIX);
} catch (renameErr) {
// 备份失败(通常文件不存在)也记录,这样静默损坏不会被永久忽视。
// eslint-disable-next-line no-console
console.warn('[storage] rename 到 .bak-v1 失败(可能是首次启动):', (renameErr as Error)?.message ?? renameErr);
}
// 深拷贝DEFAULT_STATE 是模块级共享常量,直接交出去会让调用方
// (或未来任何忘了克隆的写路径)把默认值本身改掉。
return structuredClone(DEFAULT_STATE);
}
}
async write(state: AppState): Promise<void> {
return new Promise((resolve, reject) => {
this.queue.push({ state, resolve, reject });
this.schedule();
});
}
/**
* 立即排空队列。退出路径will-quit用它把 300ms 防抖窗口内的最后一次写落盘。
*
* 必须 await 正在飞行的那次 draindrain() 首行是 `if (this.running) return;`
* 若退出瞬间刚好有一次写在途中,直接 await drain() 会立刻返回,
* 调用方随即 app.quit(),排队中的最新状态就永久丢了。
*/
async flush(): Promise<void> {
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
// 先等在途的那次写完成(它结束时会把 running 置回 false
await this.inflight?.catch(() => { /* 失败已通知原调用方,这里只关心"跑完了" */ });
// 再把它跑完之后仍排在队列里的最新状态写下去。
await this.drain();
}
private schedule(): void {
if (this.timer) return;
this.timer = setTimeout(() => {
this.timer = null;
void this.drain();
}, DEBOUNCE_MS);
}
private async drain(): Promise<void> {
if (this.running) return;
if (this.queue.length === 0) return;
this.running = true;
// 记下本次在途的 drain让 flush() 能等它跑完(见 flush 的说明)。
const run = this.doDrain();
this.inflight = run;
try {
await run;
} finally {
if (this.inflight === run) this.inflight = null;
}
}
private async doDrain(): Promise<void> {
// 合并:取最新一条(其余丢掉,下一次 write 已覆盖它们)
const latest = this.queue[this.queue.length - 1]!;
// 先快照整批writeAtomic 之后 this.queue 会被清空。
const batch = this.queue;
this.queue = [];
try {
await this.writeAtomic(latest.state);
// 同一批里所有 write() 调用都应当 resolve前面的被"合并"覆盖,
// 它们的 state 已经在 latest.state 里写盘 → 调用方拿到"成功"语义。
// 不分别 reject 否则 UI 会出现 spurious 失败提示。
for (const q of batch) q.resolve();
} catch (err) {
const e = err as Error;
// 通知原 write() 调用者失败;之后不要 re-throw——
// schedule() 内部用 `void this.drain()` 调用re-throw 会变成
// unhandledRejection之前 storage.test.ts 正是用 unhandledRejection
// 监听器在测这个),且在生产环境里也只是噪音。
// 同一批里所有 write() 共享同一次落盘结果,全部 reject 同一个错误。
for (const q of batch) q.reject(e);
} finally {
this.running = false;
if (this.queue.length > 0) this.schedule();
}
}
private async writeAtomic(state: AppState): Promise<void> {
await fs.mkdir(dirname(this.filePath), { recursive: true });
const tmp = this.filePath + '.tmp';
// 落盘前剥离一次性任务的 active 状态:
// - countdown.active倒计时归零即清再启自然从 idle 开始,持久化无意义。
// - pomodoro.active番茄钟没结束就重启 → 旧 endsAt 已过、显示 00:00、tick 永远
// 不触发 transition让 UI 卡死。启动时 Pomodoro.runTimer 不会被自动调,
// 所以不如直接清空,让用户点开始重新计时。
// Countdown 的 fire 路径走的是 setInterval + 自身 transition 调度,
// 不会受进程重启影响Pomodoro 同理不依赖磁盘状态。)
const toWrite: AppState = structuredClone(state);
delete (toWrite.countdown as { active?: unknown }).active;
delete (toWrite.pomodoro as { active?: unknown }).active;
let fh: FileHandle | null = null;
try {
fh = await fs.open(tmp, 'w');
await fh.writeFile(JSON.stringify(toWrite, null, 2), 'utf-8');
// 确保数据落盘后再 rename —— 否则 rename 后进程崩溃会留下新文件名但旧内容。
await fh.sync();
await fh.close();
fh = null;
await fs.rename(tmp, this.filePath);
} catch (err) {
if (fh) {
try { await fh.close(); } catch { /* 关闭失败不影响主错误 */ }
}
try { await fs.unlink(tmp); } catch { /* tmp may not exist */ }
throw err;
}
}
}

76
src/main/tray.ts Normal file
View File

@@ -0,0 +1,76 @@
import { Tray, Menu, nativeImage, app } from 'electron';
import { join } from 'node:path';
import type { FloatingKind } from '../shared/types';
export interface TrayDeps {
/**
* 托盘图标路径。留空时使用项目自带的 `icon.ico`。
* `icon.ico` 多分辨率16/32/48/64/128/256已被 electron-builder.yml
* `files:` 列表打进 app.asardev 模式 `app.getAppPath()` 同样指向项目根,
* 两种场景用 `app.getAppPath()` 都能解析到。
*/
iconPath?: string;
openMain: () => void;
/** 唤起主窗并让 renderer 切到 settings 面板("设置…" 菜单项用) */
openSettings: () => void;
/** 4 个浮窗入口。点击 toggle开了就关关了就开。 */
toggleFloating: (kind: FloatingKind) => void;
/** 浮窗状态查询 —— 用于菜单 checkbox 标记。 */
isFloatingOpen: (kind: FloatingKind) => boolean;
quit: () => void;
}
/** 解析托盘图标路径。优先用调用方传入的 `deps.iconPath`,否则回退到
* 项目自带的 `icon.ico`dev 时 `app.getAppPath()` = 项目根packaged 时
* 指向 app.asar 内根icon.ico 已被 electron-builder.yml `files:` 打进 asar。*/
function resolveTrayIconPath(override?: string): string {
if (override && override.length > 0) return override;
return join(app.getAppPath(), 'icon.ico');
}
/** 从 `icon.ico` 加载并 resize 到 Windows 托盘标准 16×16。`.ico` 是多帧
* 图标16/32/48/64/128/256`nativeImage.createFromPath` 在 Windows 上
* 对托盘用途会选最大帧Shell 缩到 16×16 时边缘发糊。显式 resize 让 Chromium
* 走高质量重采样,结果清晰;同时作为约定 —— 托盘图标就该 16×16。*/
function loadTrayIcon(path: string) {
const raw = nativeImage.createFromPath(path);
if (raw.isEmpty()) return raw;
return raw.resize({ width: 16, height: 16, quality: 'best' });
}
export function installTray(deps: TrayDeps): Tray {
const iconPath = resolveTrayIconPath(deps.iconPath);
const img = loadTrayIcon(iconPath);
const tray = new Tray(img.isEmpty() ? nativeImage.createEmpty() : img);
tray.setToolTip('Time');
// 4 个浮窗平铺在主菜单,每个用 checkbox 类型;点击 = toggle打开/关闭)。
// 已打开的浮窗在菜单里打勾。菜单在 openFloating / closed 时由 refresh() 重建。
const build = () => Menu.buildFromTemplate([
{ label: '打开主窗口', click: deps.openMain },
{ label: '设置…', click: deps.openSettings },
{ type: 'separator' },
{ label: '浮窗时钟', type: 'checkbox', checked: deps.isFloatingOpen('clock'),
click: () => deps.toggleFloating('clock') },
{ label: '浮窗番茄钟', type: 'checkbox', checked: deps.isFloatingOpen('pomodoro'),
click: () => deps.toggleFloating('pomodoro') },
{ label: '浮窗倒计时', type: 'checkbox', checked: deps.isFloatingOpen('countdown'),
click: () => deps.toggleFloating('countdown') },
{ label: '浮窗闹钟', type: 'checkbox', checked: deps.isFloatingOpen('alarms'),
click: () => deps.toggleFloating('alarms') },
{ type: 'separator' },
{ label: '退出', click: deps.quit }
]);
tray.setContextMenu(build());
// 浮窗开关状态变化时,从外部调用以重建菜单(更新 checkbox 标记)。
// Electron 的 MenuItem 状态在 setContextMenu 之后是冻结的 —— 想改 checked
// 必须重新 build + setContextMenu。
// guard:destroyAllFloating 触发 'closed' → broadcastFloatingState → onFloatingChange
// 可能在 will-quit 阶段 tray 已 destroy 后再被调;setContextMenu on destroyed tray 抛错。
const refresh = () => { if (!tray.isDestroyed()) tray.setContextMenu(build()); };
tray.on('click', deps.openMain);
return Object.assign(tray, { refresh });
}

23
src/main/windowState.ts Normal file
View File

@@ -0,0 +1,23 @@
import { promises as fs } from 'node:fs';
import type { WindowGeometry } from '../shared/types';
/**
* 主窗几何信息的读取side。
*
* 只负责"启动时把上次保存的位置/尺寸读进来"——写盘不在这里:
* 退出时必须同步落盘will-quit 之后没有异步窗口),由 index.ts 直接用
* writeFileSync 处理。早先这里还有 save() / applyTo() 两个方法,
* 从来没有调用方,已删除。
*/
export class WindowState {
private geom: WindowGeometry | null = null;
async load(filePath: string): Promise<void> {
try {
const raw = await fs.readFile(filePath, 'utf-8');
this.geom = JSON.parse(raw);
} catch { this.geom = null; }
}
get(): WindowGeometry | null { return this.geom; }
}

521
src/main/windows.ts Normal file
View File

@@ -0,0 +1,521 @@
import { BrowserWindow, app, screen } from 'electron';
import { join } from 'node:path';
import type { AppState, FloatingKind, Route, WindowGeometry } from '../shared/types';
import { PUSH } from '../shared/constants';
import { getIsAppQuitting } from './quitState';
import { resolveRendererTarget } from './rendererTarget';
import { FLOATING_SIZE, canPlaceAt, spawnPoint, type Rect } from './floatingLayout';
// 主窗 / Windows 任务栏图标:项目根目录的 icon.icoelectron-builder.yml 里
// `win.icon: icon.ico` 指向同路径。dev 模式启动时 cwd 是项目根,直接解析即可。
const preloadMain = join(__dirname, '../preload/index.js');
// 三平台统一的标题栏策略:
// - macOS: hiddenInset —— 交通灯按钮内缩进左上角,标题栏主体可拖拽
// - Windows: hidden —— 隐藏系统 frame自绘标题栏CSS 里实现)
// - Linux: default —— KDE/GNOME 主题差异大,自绘成本不值;用系统标题栏
// Windows 上 hidden 时必须自绘 min/max/close 按钮,否则窗口无法操作。
// 这里通过 frame: false 配合 -webkit-app-region: drag 自绘顶栏。
const isMac = process.platform === 'darwin';
const isWin = process.platform === 'win32';
const titleBarStyle: 'default' | 'hiddenInset' | 'hidden' =
isMac ? 'hiddenInset' : isWin ? 'hidden' : 'default';
const frame = isWin ? false : true;
// 浮窗尺寸表 / 边距 / 碰撞与排布算法都在 ./floatingLayout —— 纯函数,
// 不依赖 Electron可以脱离窗口系统单独验证"任意两张卡片不重叠"。
// 校验保存的位置是否还在当前已连接的显示器范围内。
// 断开外接显示器后窗口落在不可见区域是个老问题。
// 边界用 ±10 像素:少到能容忍外接屏 DPI 缩放差异,多到不会让完全离屏的窗口通过。
function isGeometryOnScreen(geom: WindowGeometry): boolean {
const displays = screen.getAllDisplays();
const SLOP = 10;
return displays.some(d =>
geom.x >= d.bounds.x - SLOP &&
geom.y >= d.bounds.y - SLOP &&
geom.x + geom.width <= d.bounds.x + d.bounds.width + SLOP &&
geom.y + geom.height <= d.bounds.y + d.bounds.height + SLOP
);
}
/** 给任意 BrowserWindow 装上导航守卫。这是 deep-defense:
* - setWindowOpenHandler deny-all:阻止 window.open 跳出新窗口(包括打 `<a target="_blank">`);
* - will-navigate preventDefault:阻止任何 top-level 跳转(包括拖拽 URL 落到 frameless 窗口)。
* 本应用纯本地渲染,允许所有导航是入侵面;CSP 是第一道防线,这里补一道。*/
function attachNavigationGuards(win: BrowserWindow): void {
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
win.webContents.on('will-navigate', (e, url) => {
// 主窗应只跑本地 file://(或 dev 环境 http://localhost:*)。其余一律拦截,
// 同时记录到 console,方便调试时区分"用户拖了 URL"还是"页面程序性跳转"。
const allowed =
url.startsWith('file://') ||
(process.env.NODE_ENV === 'development' && url.startsWith('http://localhost'));
if (!allowed) {
// eslint-disable-next-line no-console
console.warn('[windows] blocked navigation:', url);
e.preventDefault();
}
});
}
export class WindowManager {
private mainWin: BrowserWindow | null = null;
private floatingWins = new Map<FloatingKind, BrowserWindow>();
// 缓存最近一次生效的浮窗 alwaysOnTop 状态(按 kind避免每次 state change 都触发
// Electron 的 setAlwaysOnTop值不变时也会跑一次 IPC徒增噪音
private lastFloatingOnTop = new Map<FloatingKind, boolean>();
/** 用户手动拖动到的浮窗位置(按 kind)。由 'move' 事件记录 —— 只有真的被拖过的
* 卡片才进这张表,show 时优先复用,实现"用户拖到哪就停哪"。
* 注意:复用前必须过 canPlaceAt 校验,否则会把卡片放回已被别的卡片占住的位置。 */
private userPlaced = new Map<FloatingKind, { x: number; y: number }>();
/** 最近一次由程序 setPosition 写入的位置(按 kind)。'move' 事件不区分来源,
* 用它把"我们自己摆的"过滤掉,剩下的才是用户拖动。 */
private lastProgrammaticPos = new Map<FloatingKind, { x: number; y: number }>();
/** 浮窗开/关变化钩子。托盘菜单用它来 refresh更新 checkbox 标记)。
* 默认 noop调用方index.ts注入 trayInstance.refresh 即可。 */
onFloatingChange: () => void = () => {};
/** 主窗几何写入函数由调用方index.ts注入writeFileSync 同步落盘。 */
private geometrySaver: ((geom: { x: number; y: number; width: number; height: number }) => void) | null = null;
constructor(private getState: () => AppState) {}
openMain(initialGeom?: WindowGeometry | null): BrowserWindow {
if (this.mainWin && !this.mainWin.isDestroyed()) {
this.mainWin.show(); this.mainWin.focus(); return this.mainWin;
}
const s = this.getState().settings;
const display = screen.getPrimaryDisplay();
// 决定初始位置/尺寸saved geom 在屏 + 合理尺寸 → 用;否则 fallback
// Tab 布局topbar(36) + tabstrip(28) + padding(20) + 各面板内容。
// 默认 440×400 —— 更小巧的近正方形小卡片。倒计时面板受
// `@media (max-height:560)` 控制,圆环/输入框已压到 140/40
// 在 400 高度下内容约 334px留 ~22px 富余;番茄钟 / 时钟 / 闹钟列表更宽松。
const fallback = { width: 440, height: 400 };
// 也加一个上界:旧版本 460×520 的保存值会被当作"过大"丢掉,回到新默认。
// 否则升级后窗口还是旧尺寸,"小卡片"改不生效。
const MAX_W = 520, MAX_H = 440;
const useSaved = initialGeom && isGeometryOnScreen(initialGeom) &&
initialGeom.width >= 360 && initialGeom.width <= MAX_W &&
initialGeom.height >= 380 && initialGeom.height <= MAX_H;
const win = new BrowserWindow({
width: useSaved ? initialGeom!.width : fallback.width,
height: useSaved ? initialGeom!.height : fallback.height,
minWidth: 360, minHeight: 380,
backgroundColor: '#08080B',
show: false,
autoHideMenuBar: true,
alwaysOnTop: s.alwaysOnTop,
title: 'Time',
titleBarStyle,
frame,
// Windows 任务栏图标:必须显式指定,否则 dev 模式 / 某些打包场景
// 会沿用 Electron 默认图标。浮窗设置了 skipTaskbar: true无需此字段。
// 用 app.getAppPath() 解析dev = 项目根packaged = app.asar 根,
// icon.ico 已被 electron-builder.yml `files:` 打进 asar两种场景都能
// 拿到同一个文件。比裸写 'icon.ico'(依赖 cwd更稳。
icon: join(app.getAppPath(), 'icon.ico'),
// 圆角 14 + 阴影,营造"小卡片"视觉。
// 桌面端 native frame 不会真的做圆角,但 hiddenInset + 暗色背景 + 紧凑尺寸
// 已经接近 macOS 卡片效果Windows 下 frame 仍为矩形,依赖内边距和卡片背景
// 暗示"卡片"。
webPreferences: { preload: preloadMain, contextIsolation: true, nodeIntegration: false, sandbox: true }
});
attachNavigationGuards(win);
if (useSaved) {
win.setPosition(initialGeom!.x, initialGeom!.y);
} else {
// 居中到主屏
const wb = win.getBounds();
win.setPosition(
Math.round(display.bounds.x + (display.bounds.width - wb.width) / 2),
Math.round(display.bounds.y + (display.bounds.height - wb.height) / 2)
);
}
win.once('ready-to-show', () => win.show());
this.attachCloseToTray(win);
void this.loadRenderer(win, 'index.html');
this.mainWin = win;
// 主窗销毁后清空 this.mainWin 引用,避免 getMainGeometry() 等读到死引用。
win.once('closed', () => {
if (this.mainWin === win) this.mainWin = null;
});
return win;
}
/** 浮窗小卡片统一入口 —— 退化为 showFloating 的别名以保持 IPC 接口兼容。
* 实际预创建在 whenReady 后由 precreateAllFloating() 完成,首次 toggle 时 show
* 复用现有 BrowserWindow,无需重新 new + loadFile(后者是 ~150-300ms 延迟主因)。 */
openFloating(kind: FloatingKind): BrowserWindow {
const w = this.showFloating(kind);
// 旧签名要求返回 BrowserWindow;hide 状态下的 win 仍合法可返回。
return w;
}
/** 显示/复用指定 kind 的浮窗(预创建 + hide 模式的核心入口):
* - 预创建已完成 → 优先保留用户手动拖到的位置,否则用计算落点;然后 show + focus
* - 预创建未完成(罕见,如竞态/外部直接调)→ 走 precreateFloating 兜底并 await
* - 重新打开时同步当前 alwaysOnTop(用户可能在浮窗隐藏期间改了主窗设置)
* 帧预算:10-30ms(主要是 Electron show 本身的系统调用)。 */
showFloating(kind: FloatingKind): BrowserWindow {
let w = this.floatingWins.get(kind);
if (!w || w.isDestroyed()) {
// 兜底:正常路径下预创建已在启动期完成;这里是"用户疯狂点 toggle"的极端兜底。
// 同步路径:precreateFloating 内部已 sync 构造 BrowserWindow + sync set 到
// floatingWins map,然后才启动 loadFile(不 await)。此处直接 get 拿,无需 await。
// 防御性:如果未来有人在 floatingWins.set 之前加 await,get 会拿到 undefined,
// 下方 throw 走 reject;此处不去 await 整个 promise,避免阻塞 show。
this.precreateFloating(kind).catch((err) => {
// eslint-disable-next-line no-console
console.error(`[windows] precreate ${kind} failed:`, err);
});
w = this.floatingWins.get(kind);
if (!w || w.isDestroyed()) {
throw new Error(`showFloating: precreate failed for kind=${kind}`);
}
}
// 落点决策:用户亲手拖过的位置优先,但必须"仍然放得下"——
// 既不能压住此刻可见的其它卡片,也不能落在已断开的显示器上。
// 任一条不满足就退回 floatingSpawnPoint 重新排一个不重叠的空位。
const b = w.getBounds();
const obstacles = this.openFloatingRects(kind);
const desired = this.userPlaced.get(kind);
const keepDesired = !!desired && canPlaceAt(
{ x: desired.x, y: desired.y, width: b.width, height: b.height },
screen.getAllDisplays().map(d => d.workArea),
obstacles
);
const pos = keepDesired ? desired! : this.floatingSpawnPoint(kind);
this.moveFloating(kind, w, pos.x, pos.y);
w.show();
w.focus();
this.onStateChanged();
this.broadcastFloatingState();
return w;
}
/** 给浮窗设位置,并登记"这是程序摆的"—— 'move' 监听器据此把它与用户拖动区分开。 */
private moveFloating(kind: FloatingKind, win: BrowserWindow, x: number, y: number): void {
this.lastProgrammaticPos.set(kind, { x, y });
win.setPosition(x, y);
}
/** 隐藏指定 kind 的浮窗(预创建模式的核心入口):
* - 不 destroy BrowserWindow,只 hide → 保留预创建的所有开销,下次 show 瞬开
* - 已 hide 时 noop,避免冗余 broadcast
* - broadcastFloatingState 会推 false 给 topbar 按钮,按钮亮态自动熄灭
* 位置无需在这里记录:用户拖动由 'move' 事件实时写进 userPlaced。 */
hideFloating(kind: FloatingKind): void {
const w = this.floatingWins.get(kind);
if (!w || w.isDestroyed()) return;
if (!w.isVisible()) return;
w.hide();
this.broadcastFloatingState();
}
/** 关闭指定 kind 的浮窗 —— 现已退化为 hideFloating 别名(老 IPC 路径仍兼容)。
* 历史上是 win.close() 真正销毁,改成 hide 后浮窗 BrowserWindow 常驻,
* 关闭 = 视觉消失 + 状态广播。OS 销毁(`app.quit`/`cmd+w`)仍走 'closed' 事件兜底。 */
closeFloating(kind: FloatingKind): void {
this.hideFloating(kind);
}
/** 应用退出时显式销毁所有预创建浮窗,避免依赖 Electron 默认 close 的不确定性。 */
destroyAllFloating(): void {
for (const win of this.floatingWins.values()) {
if (!win.isDestroyed()) win.destroy();
}
this.floatingWins.clear();
this.lastFloatingOnTop.clear();
this.userPlaced.clear();
this.lastProgrammaticPos.clear();
}
/**
* 应用退出时显式销毁主窗。
*
* 必要性(F-残留进程修复):主窗的 close 事件由 attachCloseToTray 处理,
* 在 isAppQuitting=true 时不拦截、让窗口真正销毁 —— 但这依赖 Electron 走到
* window 关闭阶段。如果主窗当前处于 hide 状态(用户点 × 收进托盘后再点 tray 退出),
* Electron 关闭隐藏窗口的路径有时不会触发 'closed' 事件,主窗 BrowserWindow 进程
* 残留,task manager 看就是"应用退出后 GPU/网络子进程还在"。这里在
* destroyAllFloating 之后主动 destroy 主窗兜底,顺序与 before-quit 的执行栈一致
* (先浮窗再主窗,避免主窗先 destroy 触发 'closed' 中对浮窗 Map 的访问)。
*/
destroyMainWindow(): void {
const w = this.mainWin;
if (!w || w.isDestroyed()) return;
// destroy 之前先尝试同步保存几何:attachCloseToTray 的 close 事件在 isAppQuitting=true
// 时会调 saveMainGeometryOnClose,但 Electron 的 destroy() 触发 'close' 的路径在
// hide 状态下可能被吞。这里直接调一次 —— 双重保险,保证退出生效前最后一次位置进盘。
try {
this.saveMainGeometryOnClose();
} catch {
// saveMainGeometryOnClose 内部已 catch,这里再兜一层无副作用
}
w.destroy();
this.mainWin = null;
}
/** 预创建单个浮窗(内部方法):show:false + loadFile 不 await,fire-and-forget。
* - frame:false + transparent:true:圆角透明卡片,无系统装饰
* - alwaysOnTop 与设置里的 alwaysOnTop 同源(不要硬编码 true),避免用户关闭主窗置顶后浮窗仍被钉住
* - skipTaskbar:true + resizable:false:永远悬浮、不进任务栏、不可拉伸
* - hasShadow:false:阴影交给 CSS box-shadow 渲染(避免 OS 阴影与设计冲突)
* - show:false:启动期不要闪烁/不要被用户看到空白卡片 */
private async precreateFloating(kind: FloatingKind): Promise<BrowserWindow> {
const existing = this.floatingWins.get(kind);
if (existing && !existing.isDestroyed()) return existing;
const size = FLOATING_SIZE[kind];
// 预创建阶段 4 个 hide 浮窗同时存在,includeHidden=true 让 spawn 把其它 hide
// 卡也算 obstacle(避免 4 张叠在右上角)。show 阶段传默认 false。
const spawn = this.floatingSpawnPoint(kind, true);
// 与 getState() 的 alwaysOnTop 保持一致:创建时一次性"对齐"用户期望,
// 缓存也用同一值初始化,onStateChanged() 下次只有在真变化时才会触发 IPC。
const wantOnTop = this.getState().settings.alwaysOnTop === true;
const win = new BrowserWindow({
width: size.w, height: size.h,
frame: false, transparent: true,
alwaysOnTop: wantOnTop,
skipTaskbar: true,
resizable: false,
hasShadow: false,
show: false,
x: spawn.x,
y: spawn.y,
webPreferences: { preload: preloadMain, contextIsolation: true, nodeIntegration: false, sandbox: true }
});
attachNavigationGuards(win);
this.lastFloatingOnTop.set(kind, wantOnTop);
this.lastProgrammaticPos.set(kind, spawn);
this.floatingWins.set(kind, win);
// 用户拖动登记:'move' 不区分来源,凡是与我们最后一次 setPosition 对得上的
// 都是程序自己摆的(±2px 容差留给 DPI 缩放取整),其余才算用户拖动。
// 只认可见状态下的移动 —— hide 期间的 setPosition 一律是程序行为。
win.on('move', () => {
if (win.isDestroyed() || !win.isVisible()) return;
const b = win.getBounds();
const last = this.lastProgrammaticPos.get(kind);
if (last && Math.abs(last.x - b.x) <= 2 && Math.abs(last.y - b.y) <= 2) return;
this.userPlaced.set(kind, { x: b.x, y: b.y });
});
// hide 路径不触发 'closed':该事件仅在 OS 真正销毁时触发(app.quit / cmd+w)。
// 此处清 Map 即可作为"实例已销毁,下次 toggle 走重建分支"的兜底。
win.once('closed', () => {
if (this.floatingWins.get(kind) === win) {
this.floatingWins.delete(kind);
this.lastFloatingOnTop.delete(kind);
this.lastProgrammaticPos.delete(kind);
this.broadcastFloatingState();
}
});
// fire-and-forget:不等 loadFile 完成,允许预创建并行进行。
void this.loadRenderer(win, `floating-${kind}/index.html`);
return win;
}
/** 启动期一次性预创建 4 个浮窗(state 就绪后、IPC 注册前)。
* - 内部用 Promise.allSettled 包裹,单个 kind 失败不阻断其它
* - 不抛错给调用方(whenReady 块);失败信息走 console.error
* - 4 个 BrowserWindow 并行 loadFile,~100ms 总耗时(主窗先 ready-to-show) */
async precreateAllFloating(): Promise<void> {
const kinds: FloatingKind[] = ['pomodoro', 'clock', 'alarms', 'countdown'];
const results = await Promise.allSettled(kinds.map((k) => this.precreateFloating(k)));
for (let i = 0; i < results.length; i++) {
const r = results[i]!;
if (r.status === 'rejected') {
// eslint-disable-next-line no-console
console.error(`[windows] precreate ${kinds[i]} failed:`, r.reason);
}
}
}
/** 新浮窗的落点(排布算法见 ./floatingLayout.spawnPoint)。
* - includeHidden:预创建阶段(此时所有 4 个 hide)传 true,让 spawn 错开;
* 运行时(show)传 false,避免 hide 卡片挡 spawn。 */
private floatingSpawnPoint(kind: FloatingKind, includeHidden = false): { x: number; y: number } {
const { w, h } = FLOATING_SIZE[kind];
// workArea 而非 bounds绕开任务栏 / Dock卡片不会藏到任务栏底下。
const area = this.floatingDisplay().workArea;
const floats = this.openFloatingRects(kind, includeHidden);
const main = this.mainWin && !this.mainWin.isDestroyed() && this.mainWin.isVisible()
? [this.mainWin.getBounds()] : [];
return spawnPoint(w, h, area, floats, main);
}
/** 浮窗出现在主窗所在的那块屏;主窗不存在时用主屏。多显示器下别让卡片飞到另一块屏。 */
private floatingDisplay(): Electron.Display {
const w = this.mainWin;
if (!w || w.isDestroyed()) return screen.getPrimaryDisplay();
const b = w.getBounds();
return screen.getDisplayNearestPoint({
x: Math.round(b.x + b.width / 2),
y: Math.round(b.y + b.height / 2)
});
}
/** 已打开浮窗的实时矩形 —— 取 getBounds 而非建窗时的落点,用户拖动过也能正确避让。
* - 默认仅计入 visible 的浮窗:hide 的卡片不挡 spawn(它们都登记了 spawn 占位,
* 但视觉上不可见,若计入会让新 show 误判"撞到自己")。
* - 预创建阶段 includeHidden=true:hide 状态也要计入,否则 4 个同时预创建时
* spawn 完全重叠(全在右上角),首次 show 时叠在一起。 */
private openFloatingRects(exclude?: FloatingKind, includeHidden = false): Rect[] {
const out: Rect[] = [];
for (const [kind, win] of this.floatingWins) {
if (kind === exclude || win.isDestroyed()) continue;
if (!includeHidden && !win.isVisible()) continue;
out.push(win.getBounds());
}
return out;
}
/** 当前浮窗是否对用户可见(预创建+hide 模式下,实例常驻但只有 show 过的才"开着")。
* 供托盘菜单 checkbox 标记 + IPC toggleFloating 分支判断。
* 用 isVisible() 而非 !isHidden():hide→show 切换时 isVisible() 干净,且 show:false
* 初始状态也是 invisible,不会让"刚预创建还没 show"被误报为关闭。 */
isFloatingOpen(kind: FloatingKind): boolean {
const w = this.floatingWins.get(kind);
return !!(w && !w.isDestroyed() && w.isVisible());
}
/** 浮窗开关状态广播到所有窗口topbar 按钮 / 托盘菜单 checked 同步)。
* payload 用 4 个 boolean 而非 Map避免结构化克隆在 renderer 端还得转。 */
private broadcastFloatingState(): void {
const state: Record<FloatingKind, boolean> = {
pomodoro: this.isFloatingOpen('pomodoro'),
clock: this.isFloatingOpen('clock'),
alarms: this.isFloatingOpen('alarms'),
countdown:this.isFloatingOpen('countdown')
};
for (const w of BrowserWindow.getAllWindows()) {
if (w.isDestroyed()) continue;
w.webContents.send(PUSH.floatingWindowsChanged, state);
}
// 同步通知 OS 级组件(托盘菜单的 checkbox
this.onFloatingChange();
}
/** 兼容旧 API —— 默认返回番茄钟浮窗 id。 */
floatingWindowId(kind: FloatingKind = 'pomodoro'): number | null {
const w = this.floatingWins.get(kind);
return w && !w.isDestroyed() ? w.id : null;
}
/** 当前主窗的位置 + 尺寸,用于持久化。窗口已销毁时返回 null。 */
getMainGeometry(): WindowGeometry | null {
const w = this.mainWin;
if (!w || w.isDestroyed()) return null;
const b = w.getBounds();
return { x: b.x, y: b.y, width: b.width, height: b.height };
}
/** 主窗对象的引用(用于 indexing 回调、avoid 缓存过期引用)。已销毁时返回 null。 */
getMainWindow(): BrowserWindow | null {
if (this.mainWin && !this.mainWin.isDestroyed()) return this.mainWin;
return null;
}
/**
* 把主窗当前几何位置写盘。同步写will-quit 之后没有异步窗口,
* 异步写入会丢。本次修复:原来在 will-quit 里写,但 will-quit 触发时主窗已被
* before-quit → 窗口关闭事件销毁getMainGeometry() 返回 null永远写不到。
* 现在统一在 close 事件里写:
* - 用户点 ×attachCloseToTray 拦下并 hide几何未变 → 跳过;
* - 真正退出isAppQuitting=trueattachCloseToTray 不拦,窗口 close 事件里写一次;
* - 重启触发 mainWin 重建 → 旧窗口的 close 事件写一次,新窗口 ready 后 loadFile
* 旧值自然被覆盖。
*/
saveMainGeometryOnClose(): void {
const w = this.mainWin;
if (!w || w.isDestroyed()) return;
const b = w.getBounds();
const geom = { x: b.x, y: b.y, width: b.width, height: b.height };
// 由调用方attachCloseToTray传入 fs 写入回调,避免 windows.ts 反向依赖 index.ts。
try {
this.geometrySaver?.(geom);
} catch (err) {
console.warn('[windows] save main geometry failed:', (err as Error).message);
}
}
/** 注入几何写入函数(构造时绑定,写盘逻辑在 index.ts。 */
setGeometrySaver(fn: (geom: { x: number; y: number; width: number; height: number }) => void): void {
this.geometrySaver = fn;
}
/**
* 唤起主窗并把目标 route 推到渲染端。
* - 主窗还没创建 → openMain(null) 创建;
* - 主窗已存在 → show + focus;
* - 渲染端还没 readydid-finish-load 后再发 PUSH.openRoute,避免丢 push;
* - 已有现成主窗:直接发。
*
* 单一入口替换原先在 tray / second-instance / floating-context-menu 各自的
* "openMain + 等 did-finish-load + 发 push" 样板。
*/
openMainAndRoute(route?: Route): BrowserWindow {
const w = this.openMain(null);
w.show();
w.focus();
if (route) {
if (w.webContents.isLoading()) {
w.webContents.once('did-finish-load', () => {
if (!w.isDestroyed()) w.webContents.send(PUSH.openRoute, route);
});
} else {
w.webContents.send(PUSH.openRoute, route);
}
}
return w;
}
/** 由 index.ts 在 state.onUpdate 回调里调用,刷新所有浮窗的置顶跟随主窗设置。 */
onStateChanged(): void {
const want = this.getState().settings.alwaysOnTop === true;
for (const [kind, win] of this.floatingWins) {
if (win.isDestroyed()) continue;
if (this.lastFloatingOnTop.get(kind) !== want) {
win.setAlwaysOnTop(want);
this.lastFloatingOnTop.set(kind, want);
}
}
}
/** 给 ipc.winToggleAlwaysOnTop 调用:只改变主窗 + 浮窗的置顶,编辑器独立保留 OS 默认。
* 与 onStateChanged 不同:这里不通过 state,直接强制覆盖(因为 toggle 路径要先
* 改 settings 然后才到这里,会与 onStateChanged 的赋值叠加,所以这里只设 OS 层并
* 同步 lastFloatingOnTop 缓存)。 */
setAlwaysOnTop(want: boolean): void {
const main = this.mainWin;
if (main && !main.isDestroyed()) main.setAlwaysOnTop(want);
for (const [kind, win] of this.floatingWins) {
if (win.isDestroyed()) continue;
win.setAlwaysOnTop(want);
this.lastFloatingOnTop.set(kind, want);
}
}
/** 主窗口关闭按钮:始终隐藏到托盘;真退出前同步落几何。 */
private attachCloseToTray(win: BrowserWindow): void {
win.on('close', (e) => {
if (getIsAppQuitting()) {
// 真正退出前保存几何此时窗口尚未销毁getBounds() 有效。
// 必须在 close 而非 will-quit后者触发时窗口已被销毁。
this.saveMainGeometryOnClose();
return;
}
e.preventDefault();
win.hide();
});
}
private async loadRenderer(win: BrowserWindow, file: string, query?: Record<string, string>): Promise<void> {
const target = resolveRendererTarget(file, query);
if (target.kind === 'url') {
await win.loadURL(target.url);
} else {
await win.loadFile(join(__dirname, '../renderer', target.file), target.query ? { query: target.query } : undefined);
}
}
}

111
src/preload/index.ts Normal file
View File

@@ -0,0 +1,111 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC, PUSH } from '../shared/constants';
import type {
Alarm, Settings, AppState, PomodoroActiveState, CountdownActiveState, FloatingKind, Route
} from '../shared/types';
const api = {
/** 平台标识:渲染端用 window.api.platform === 'win32' 判断要不要显示自定义标题栏 */
platform: process.platform,
clock: { getState: () => ipcRenderer.invoke(IPC.clockGetState) },
alarms: {
add: (a: Omit<Alarm, 'id' | 'createdAt' | 'updatedAt'>) => ipcRenderer.invoke(IPC.alarmAdd, a) as Promise<Alarm>,
update: (id: string, patch: Partial<Alarm>) => ipcRenderer.invoke(IPC.alarmUpdate, id, patch) as Promise<Alarm | undefined>,
remove: (id: string) => ipcRenderer.invoke(IPC.alarmDelete, id) as Promise<void>
},
pomodoro: {
start: () => ipcRenderer.invoke(IPC.pomoStart),
pause: () => ipcRenderer.invoke(IPC.pomoPause),
resume: () => ipcRenderer.invoke(IPC.pomoResume),
skip: () => ipcRenderer.invoke(IPC.pomoSkip),
reset: () => ipcRenderer.invoke(IPC.pomoReset)
},
countdown: {
start: (ms: number) => ipcRenderer.invoke(IPC.cdStart, ms),
pause: () => ipcRenderer.invoke(IPC.cdPause),
resume: () => ipcRenderer.invoke(IPC.cdResume),
reset: () => ipcRenderer.invoke(IPC.cdReset),
setDuration: (ms: number) => ipcRenderer.invoke(IPC.cdSetDuration, ms) as Promise<void>
},
/**
* 渲染端"完成"按钮点完后调它:主进程把仍在跑的提醒路径停掉。
* 当前唯一通道是系统通知cancelAll 已是 no-op保留 IPC 名字是为了不破坏
* 渲染端的调用点,未来若恢复声音/全屏闪也无需再改协议。
*/
notifier: {
cancel: () => ipcRenderer.invoke(IPC.notifierCancel) as Promise<void>
},
settings: {
set: (patch: Partial<Settings>) => ipcRenderer.invoke(IPC.settingsSet, patch) as Promise<void>
},
windows: {
// 切换语义开了就关关了就开topbar 按钮 / 浮窗自身关闭按钮反向操作时用)。
// 独立的 openFloating(kind) API 不再暴露 —— topbar 与 tray 都走这条 toggle。
toggleFloating: (kind: FloatingKind) => ipcRenderer.invoke(IPC.winToggleFloating, kind) as Promise<void>,
toggleAlwaysOnTop: () => ipcRenderer.invoke(IPC.winToggleAlwaysOnTop),
// kind 参数:默认 'pomodoro' 保持向后兼容
closeFloating: (kind: FloatingKind = 'pomodoro') =>
ipcRenderer.invoke(IPC.winCloseFloating, kind) as Promise<void>,
showFloatingContextMenu: (kind: FloatingKind = 'pomodoro') =>
ipcRenderer.invoke(IPC.winFloatingContextMenu, kind) as Promise<void>,
minimize: () => ipcRenderer.invoke(IPC.winMinimize) as Promise<void>,
toggleMaximize: () => ipcRenderer.invoke(IPC.winToggleMaximize) as Promise<void>,
close: () => ipcRenderer.invoke(IPC.winClose) as Promise<void>
},
/** 系统级入口:调用成功返回 true已交给 OS 默认程序),失败 false。
* 当前唯一调用方是设置面板"关于"区打开开发者主页。 */
shell: {
openExternal: (url: string) => ipcRenderer.invoke(IPC.shellOpenExternal, url) as Promise<boolean>
},
on: {
clockTick: (cb: (now: string) => void) => {
const h = (_: unknown, now: string) => cb(now);
ipcRenderer.on(PUSH.clockTick, h); return () => { ipcRenderer.off(PUSH.clockTick, h); };
},
alarmFired: (cb: (a: Alarm) => void) => {
const h = (_: unknown, a: Alarm) => cb(a);
ipcRenderer.on(PUSH.alarmFired, h); return () => { ipcRenderer.off(PUSH.alarmFired, h); };
},
pomodoroPhaseChanged: (cb: (active: PomodoroActiveState | null, rounds: number) => void) => {
const h = (_: unknown, active: PomodoroActiveState | null, rounds: number) => cb(active, rounds);
ipcRenderer.on(PUSH.pomodoroPhaseChanged, h); return () => { ipcRenderer.off(PUSH.pomodoroPhaseChanged, h); };
},
countdownPhaseChanged: (cb: (active: CountdownActiveState | null) => void) => {
const h = (_: unknown, a: CountdownActiveState | null) => cb(a);
ipcRenderer.on(PUSH.countdownPhaseChanged, h);
return () => { ipcRenderer.off(PUSH.countdownPhaseChanged, h); };
},
storageChanged: (cb: (s: AppState) => void) => {
const h = (_: unknown, s: AppState) => cb(s);
ipcRenderer.on(PUSH.storageChanged, h); return () => { ipcRenderer.off(PUSH.storageChanged, h); };
},
topbarChanged: (cb: (s: { alwaysOnTop: boolean }) => void) => {
const h = (_: unknown, s: { alwaysOnTop: boolean }) => cb(s);
ipcRenderer.on(PUSH.topbarChanged, h);
return () => { ipcRenderer.off(PUSH.topbarChanged, h); };
},
// 写盘失败UI 用 toast 提示F-3 修复。message 为人类可读描述。
storageError: (cb: (s: { message: string }) => void) => {
const h = (_: unknown, s: { message: string }) => cb(s);
ipcRenderer.on(PUSH.storageError, h);
return () => { ipcRenderer.off(PUSH.storageError, h); };
},
// 主进程主动让渲染端切到指定路由:托盘菜单"设置…" / 单实例唤醒时使用。
// 实际推送的 route 是 Route 的子集(主窗可去的全部面板),这里用最小可用集合
// 即可——renderer 收到后直接交给 store.route.set,类型由 Store 二次校验。
openRoute: (cb: (route: Route) => void) => {
const h = (_: unknown, route: Route) => cb(route);
ipcRenderer.on(PUSH.openRoute, h);
return () => { ipcRenderer.off(PUSH.openRoute, h); };
},
// 4 个浮窗的开关状态广播topbar 按钮同步 on/off 状态 + aria-pressed
floatingWindowsChanged: (cb: (open: Record<FloatingKind, boolean>) => void) => {
const h = (_: unknown, open: Record<FloatingKind, boolean>) => cb(open);
ipcRenderer.on(PUSH.floatingWindowsChanged, h);
return () => { ipcRenderer.off(PUSH.floatingWindowsChanged, h); };
}
}
};
contextBridge.exposeInMainWorld('api', api);
export type AppApi = typeof api;

109
src/renderer/App.ts Normal file
View File

@@ -0,0 +1,109 @@
import { store, bootstrap } from './store';
import { renderTopbar } from './components/Topbar';
import { renderTabStrip } from './components/TabStrip';
import { renderClockPanel } from './components/ClockPanel';
import { renderAlarmList } from './components/AlarmList';
import { renderPomodoroPanel } from './components/PomodoroPanel';
import { renderCountdownPanel } from './components/CountdownPanel';
import { renderSettingsPanel } from './components/SettingsPanel';
import { mountToastHost, toast } from './components/Toast';
import { applyTheme, disposeTheme } from './themeApply';
import { t } from './i18n';
import type { Route } from '../shared/types';
type PanelRender = (root: HTMLElement) => () => void;
const PANELS: Record<Route, PanelRender> = {
clock: renderClockPanel,
alarms: renderAlarmList,
pomodoro: renderPomodoroPanel,
countdown: renderCountdownPanel,
settings: renderSettingsPanel
};
/** Route 白名单:openRoute IPC 收到的 route 必须是这里的一员才能设置到 store。
* 否则 store 会接受任意字符串,与上游 Route 类型契约不一致(SP-1 修复)。
* 表里维护一份即可,与 PANELS 的 key 同步类型保证。 */
const KNOWN_ROUTES = new Set<Route>(Object.keys(PANELS) as Route[]);
// window 级 error / unhandledrejection 在 main.ts 入口处就挂好了(早于 mount),
// 这里不再 addEventListener —— 重复挂会触发两份 toast。
export async function mount(root: HTMLElement): Promise<void> {
// bootstrap 走顶部静态 importstore.ts 本来就被本文件和一堆组件静态引用,
// 再动态 import 一次并不会拆出独立 chunk只会让 Vite 报
// "dynamically imported by ... but also statically imported" 的告警。
await bootstrap();
applyTheme();
// 通知权限:主进程自己用 Electron Notification 不需要渲染端授权,
// 但设置面板的「试听通知」走 window.NotificationWeb Notification API
// 必须先请求权限,否则 Notification.permission === 'default' 时 new Notification
// 会被 Chromium 直接吞掉。请求幂等:已 granted/denied 时立刻 resolve。
if ('Notification' in window && Notification.permission === 'default') {
void Notification.requestPermission().catch(() => { /* 用户拒绝不影响其它行为 */ });
}
root.innerHTML = `
<a class="skip-link" href="#route-panel-wrap">Skip to main content</a>
<main class="app-shell">
<div class="app-content">
<header id="topbar" class="topbar"></header>
<nav id="tab-strip" class="tab-strip"></nav>
<section id="route-panel-wrap" class="route-panel-wrap" role="main" tabindex="-1">
<div id="route-panel" class="route-panel"></div>
</section>
</div>
</main>
`;
const topbar = root.querySelector<HTMLElement>('#topbar')!;
const tabStrip = root.querySelector<HTMLElement>('#tab-strip')!;
const panelWrap = root.querySelector<HTMLElement>('#route-panel')!;
renderTopbar(topbar);
renderTabStrip(tabStrip);
// mountToastHost 内部幂等(main.ts 已在 document.body 上预挂一次 host,
// 这里再调用就是 no-op);不重复挂载避免一份 toast 在 4 处同时弹。
mountToastHost(root);
// 每次 mount() 自己持有一次性订阅,返回时(目前未返回)统一解绑。
// 当前 mount 是单次入口,不会重复;这里收集只是给 HMR / 测试一个退出点。
const localCleanups: Array<() => void> = [];
localCleanups.push(window.api.on.storageError((s) => {
toast(s.message || t('error.unknown'), 'error');
}));
localCleanups.push(window.api.on.openRoute((route) => {
// 主进程在托盘右键 / 单实例唤醒时让渲染端切到指定面板。
// 运行时白名单校验:未知值要丢弃,而非 fallback 到 'clock'(免得
// 用户不小心点了"打开主窗"就被拽到不存在的面板)。
if (!KNOWN_ROUTES.has(route)) return;
store.route.set(route);
}));
let cleanupCurrent: (() => void) | null = null;
function sync(): void {
cleanupCurrent?.();
cleanupCurrent = null;
// replaceChildren 比 innerHTML='' + appendChild 便宜:不会触发 HTML 解析器,
// 只走 DOM 移除路径。切面板属于高频低代价操作,但用户频繁切(看状态卡)时
// 也能避免 innerHTML setter 内置的安全检查开销。
panelWrap.replaceChildren();
const div = document.createElement('div');
div.className = 'route-panel';
panelWrap.appendChild(div);
cleanupCurrent = PANELS[store.route.get()](div) ?? null;
}
// 先订阅再 sync:之后推送的 storage 变化能直接触发 sync;
// 之前是先 sync 后 subscribe,如果在两次之间有 storage 推过来就会漏掉。
localCleanups.push(store.route.subscribe(sync));
sync();
// 暴露给 HMR / 测试(实际无人调用,但保留以备扩展)。
if (import.meta.hot) {
import.meta.hot.dispose(() => {
localCleanups.forEach(fn => fn());
cleanupCurrent?.();
disposeTheme();
});
}
}

View File

@@ -0,0 +1,262 @@
import { store } from '../store';
import { escapeHtml } from './escape';
import { toast } from './Toast';
import { t } from '../i18n';
import { formatHHMMSS, parseHHmm } from '../../shared/time';
/**
* 极简闹钟编辑器:仅暴露时间 + 标签。
* 提醒方式统一为系统通知Alarm 类型本身不再带声音/全屏闪字段。
* 编辑只改 label / time不覆盖其它字段。
*
* 由 AlarmList 内联调用 —— `onClose` 替代了原来 `window.close()` 的角色:
* 新建/编辑完成后由调用方负责收起编辑器。
*
* 返回 cleanup调用方AlarmList.setEditing在收起编辑器时必须调一下
* 否则1) 4s revertTimer 还会跑 leaveConfirming() 戳游离节点;
* 2) wheel / input listener 还在 detached DOM 上挂着 → 内存占用 + GC 推迟。
*/
export async function mount(root: HTMLElement, alarmId: string | null, onClose: () => void): Promise<() => void> {
const existing = alarmId ? store.alarms.get().find(a => a.id === alarmId) : null;
if (!store.settings.get()) {
root.innerHTML = `<main class="app app-editor"><p class="editor-error">${t('editor.settingsLoading')}</p></main>`;
return () => { /* nothing to clean up */ };
}
const isNew = !existing;
const draft = {
label: existing?.label ?? t('editor.defaultLabel'),
time: existing?.time ?? '08:00',
// 老闹钟没有 repeat 字段 → 视为 'once',与主进程 normalize 一致。
repeat: (existing?.repeat === 'daily' ? 'daily' : 'once') as 'once' | 'daily'
};
// 从现有 time 字符串拆出时/分/秒;秒缺省视为 0。新建时默认值 08:00 → (8, 0, 0)。
const initialParts = parseHHmm(draft.time) ?? { h: 8, m: 0, s: 0 };
const hh0 = String(initialParts.h).padStart(2, '0');
const mm0 = String(initialParts.m).padStart(2, '0');
const ss0 = String(initialParts.s).padStart(2, '0');
root.innerHTML = `
<main class="app app-editor">
<header class="editor-header">
<h2>${isNew ? t('editor.newTitle') : t('editor.editTitle')}</h2>
</header>
<div class="field-row field-row-time">
<label class="ed-time-label">${t('editor.time')}</label>
<div class="ed-time-row" role="group" aria-label="${escapeHtml(t('editor.ariaSetTime'))}">
<label class="ed-time-wrap">
<input id="ed-time-hh" class="ed-time-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="${hh0}" aria-label="${escapeHtml(t('editor.ariaHours'))}" autofocus />
<span class="ed-time-unit" aria-hidden="true">${t('editor.srHours')}</span>
</label>
<span class="ed-colon" aria-hidden="true">:</span>
<label class="ed-time-wrap">
<input id="ed-time-mm" class="ed-time-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="${mm0}" aria-label="${escapeHtml(t('editor.ariaMinutes'))}" />
<span class="ed-time-unit" aria-hidden="true">${t('editor.srMinutes')}</span>
</label>
<span class="ed-colon" aria-hidden="true">:</span>
<label class="ed-time-wrap">
<input id="ed-time-ss" class="ed-time-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="${ss0}" aria-label="${escapeHtml(t('editor.ariaSeconds'))}" />
<span class="ed-time-unit" aria-hidden="true">${t('editor.srSeconds')}</span>
</label>
</div>
</div>
<div class="field-row">
<label for="f-label">${t('editor.label')}</label>
<input id="f-label" type="text" value="${escapeHtml(draft.label)}" placeholder="${t('editor.labelPh')}" maxlength="40" />
</div>
<div class="field-row field-row-repeat">
<label class="ed-repeat" for="f-repeat">
<span>${t('editor.repeat')}</span>
<input id="f-repeat" type="checkbox" ${draft.repeat === 'daily' ? 'checked' : ''} />
<span class="ed-repeat-track" aria-hidden="true"><span class="ed-repeat-thumb"></span></span>
<span class="ed-repeat-text">${t(draft.repeat === 'daily' ? 'editor.repeatDaily' : 'editor.repeatOnce')}</span>
</label>
</div>
<div class="actions" data-confirming="false">
${existing ? `
<button id="del" class="btn danger del-btn">${t('editor.deleteBtn')}</button>
<button id="del-cancel" class="btn ghost del-cancel" hidden>${t('common.cancel')}</button>
<button id="del-confirm" class="btn danger del-confirm" hidden>${t('alarms.confirmDelete')}</button>
` : '<span></span>'}
<button id="cancel" class="btn ghost">${t('editor.cancel')}</button>
<button id="save" class="btn primary">${t('editor.save')}</button>
</div>
</main>
`;
const $ = <T extends HTMLElement = HTMLElement>(id: string) => root.querySelector<T>(`#${id}`)!;
// 鼠标滚轮调整时间HH/MM/SS 三个分段独立调wheel-up +1、wheel-down -1
// 按住 Shift 时步进为 5。运行/暂停态不存在(编辑器始终可编辑),无需 active 守卫。
const inputHh = $<HTMLInputElement>('ed-time-hh');
const inputMm = $<HTMLInputElement>('ed-time-mm');
const inputSs = $<HTMLInputElement>('ed-time-ss');
function clamp(raw: string, lo: number, hi: number): number {
const n = parseInt(raw || '0', 10);
if (!Number.isFinite(n)) return lo;
return Math.min(hi, Math.max(lo, n));
}
function sanitizeInput(el: HTMLInputElement): void {
const cleaned = el.value.replace(/\D/g, '').slice(0, el.maxLength || 2);
if (cleaned !== el.value) el.value = cleaned;
}
function onWheelAdjust(e: WheelEvent): void {
e.preventDefault();
const el = e.currentTarget as HTMLInputElement;
const max = el === inputHh ? 23 : 59;
const cur = clamp(el.value, 0, max);
const dir = e.deltaY < 0 ? 1 : -1;
const step = e.shiftKey ? 5 : 1;
const next = Math.max(0, Math.min(max, cur + dir * step));
if (next === cur) return;
el.value = String(next);
}
function onInputSanitize(): void {
sanitizeInput(inputHh);
sanitizeInput(inputMm);
sanitizeInput(inputSs);
}
// 收集 listener 注册 + revertTimer 清理由 cleanup 统一收口 —— 见 setEditing 调用。
let deleteRevertTimer: number | null = null;
const inputListeners: Array<[HTMLInputElement, 'input' | 'wheel']> = [];
[inputHh, inputMm, inputSs].forEach(el => {
el.addEventListener('input', onInputSanitize);
el.addEventListener('wheel', onWheelAdjust, { passive: false });
inputListeners.push([el, 'input'], [el, 'wheel']);
});
// 暴露给 AlarmList 的 cleanupmount 收到的回调(setEditing(null))在编辑器收起时触发。
// 旧实现没有 cleanup → 4s revertTimer 会戳游离 DOMwheel/input listener 也挂着。
const cleanup = (): void => {
if (deleteRevertTimer !== null) {
window.clearTimeout(deleteRevertTimer);
deleteRevertTimer = null;
}
for (const [el, type] of inputListeners) {
if (type === 'input') el.removeEventListener('input', onInputSanitize);
else el.removeEventListener('wheel', onWheelAdjust);
}
inputListeners.length = 0;
};
// 把 cleanup 绑到 onClose调用方收编辑器时主动解除 listener + 清 timer。
// 旧实现没有 cleanup —— 4s revertTimer 还在跑、listener 还在 detached DOM 上挂着。
const originalOnClose = onClose;
let cleaned = false;
const wrappedClose = (): void => {
if (cleaned) return;
cleaned = true;
cleanup();
originalOnClose();
};
$('save').onclick = async () => {
const saveBtn = $<HTMLButtonElement>('save');
const label = $<HTMLInputElement>('f-label').value.trim();
// 拼时/分/秒:每段单独 clamp 后用 formatHHMMSS 序列化;秒为 0 时自动省略 :ss
const hh = clamp(inputHh.value, 0, 23);
const mm = clamp(inputMm.value, 0, 59);
const ss = clamp(inputSs.value, 0, 59);
const time = formatHHMMSS({ h: hh, m: mm, s: ss });
if (!parseHHmm(time)) { toast(t('editor.timeInvalid'), 'warn'); return; }
// 重复模式:勾选 = 每日;未勾选 = 单次。
const repeat: 'once' | 'daily' = $<HTMLInputElement>('f-repeat').checked ? 'daily' : 'once';
// 提醒方式已统一为系统通知,编辑只改 label / time / repeat不动其它字段。
saveBtn.disabled = true;
saveBtn.textContent = t('editor.saving');
try {
if (existing) {
await window.api.alarms.update(existing.id, { label, time, repeat });
} else {
await window.api.alarms.add({
label,
time,
enabled: true,
repeat
});
}
wrappedClose();
} catch (e) {
// 用户在 await 期间点了"取消"会触发 wrappedClosecleaned=true 此时编辑器
// 已经从 DOM 卸载saveBtn 已游离 —— 再去 disabled=false / textContent='保存'
// 是无害的但会动一个 detached 节点,触发无效 mutation record。这里判断一下跳过。
toast(t('editor.saveFailedPrefix') + errorMessage(e), 'error');
if (!cleaned) {
saveBtn.disabled = false;
saveBtn.textContent = t('editor.save');
}
}
};
$('cancel').onclick = wrappedClose;
// 重复 toggle 切换时同步更新"一次/每日"文案。toggle 用 native checkbox
// 避免引入额外控件,样式由 .ed-repeat-track / .ed-repeat-thumb 走 CSS 渲染。
const repeatInput = $<HTMLInputElement>('f-repeat');
const repeatText = root.querySelector<HTMLElement>('.ed-repeat-text')!;
repeatInput.addEventListener('change', () => {
repeatText.textContent = repeatInput.checked
? t('editor.repeatDaily')
: t('editor.repeatOnce');
});
if (existing) {
// 内联删除确认:第一次点"删除"→ actions 切到"取消 / 确认删除"4s 内不点
// 就自动撤回。比原生 confirm() 更轻,也不打断用户的视线焦点。
const actionsEl = root.querySelector<HTMLElement>('.actions')!;
const delBtn = $<HTMLButtonElement>('del');
const cancelBtn = $<HTMLButtonElement>('del-cancel');
const confirmBtn = $<HTMLButtonElement>('del-confirm');
let revertTimer: number | null = null;
const clearTimer = (): void => {
if (revertTimer !== null) {
window.clearTimeout(revertTimer);
revertTimer = null;
deleteRevertTimer = null;
}
};
const enterConfirming = (): void => {
actionsEl.dataset.confirming = 'true';
delBtn.hidden = true;
cancelBtn.hidden = false;
confirmBtn.hidden = false;
clearTimer();
revertTimer = window.setTimeout(() => {
revertTimer = null;
deleteRevertTimer = null;
leaveConfirming();
}, 4000);
deleteRevertTimer = revertTimer;
};
const leaveConfirming = (): void => {
actionsEl.dataset.confirming = 'false';
delBtn.hidden = false;
cancelBtn.hidden = true;
confirmBtn.hidden = true;
clearTimer();
};
delBtn.onclick = () => {
if (actionsEl.dataset.confirming === 'true') leaveConfirming();
else enterConfirming();
};
cancelBtn.onclick = () => leaveConfirming();
confirmBtn.onclick = async () => {
confirmBtn.disabled = true;
try {
await window.api.alarms.remove(existing.id);
wrappedClose();
} catch (e) {
toast(t('editor.deleteFailedPrefix') + errorMessage(e), 'error');
confirmBtn.disabled = false;
// 出错留在编辑器里listener 不释放,留给下次点。
}
};
}
return cleanup;
}
function errorMessage(e: unknown): string {
if (e instanceof Error) return e.message;
if (typeof e === 'string') return e;
return t('error.unknown');
}

View File

@@ -0,0 +1,264 @@
import { store } from '../store';
import type { Alarm } from '../../shared/types';
import { escapeHtml } from './escape';
import { mount as mountAlarmEditor } from './AlarmEditor';
import { t } from '../i18n';
/** 列表里闹钟副标题:直接展示重复模式。行为通道统一为通知,没必要再让用户看。 */
function fmtSubtitle(a: Alarm): string {
return a.repeat === 'daily' ? t('alarms.repeatDaily') : t('alarms.repeatOnce');
}
/** 把 "HH:mm[:ss]" 折算成秒数,用于稳定排序。
* 直接字典序比较时 "07:30" 会排在 "07:30:00" 之前 —— 它们实际是同一时刻,
* 但字符串长度不同就会错位;先归一化到秒数再比更可靠。 */
function timeToSeconds(time: string): number {
const [h = 0, m = 0, s = 0] = time.split(':').map(Number);
return h * 3600 + m * 60 + s;
}
// `null` = 不在编辑;`'new'` = 新建alarm.id = 编辑该条
type EditingState = string | 'new' | null;
// 内联删除确认的超时时长ms。这段时间内用户可以反悔过了就自动恢复原状。
// 4s 是个折中:太短用户来不及点确认,太长又会一直占着"待确认"状态。
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
export function renderAlarmList(root: HTMLElement): () => void {
root.classList.add('alarms-panel');
root.innerHTML = `
<header class="alarms-header">
<h2>${t('alarms.title')} <span class="count" id="alarm-count"></span></h2>
<button class="btn primary" id="alarm-add">${t('alarms.newBtn')}</button>
</header>
<section class="alarm-editor-mount" id="alarm-editor-mount" hidden></section>
<ul class="alarm-list" id="alarm-list"></ul>
`;
const listEl = root.querySelector<HTMLUListElement>('#alarm-list')!;
const countEl = root.querySelector<HTMLElement>('#alarm-count')!;
const addBtn = root.querySelector<HTMLButtonElement>('#alarm-add')!;
const editorMount = root.querySelector<HTMLElement>('#alarm-editor-mount')!;
let editing: EditingState = null;
addBtn.onclick = () => {
setEditing(editing === 'new' ? null : 'new');
};
function closeEditor(): void {
setEditing(null);
}
// 当前真正挂着的编辑态:用于避免 store.alarms 变化触发整面板 draw 时把正在
// 录入的表单冲掉。只有 setEditing / closeEditor 主动切到不同目标时才会重建。
let mountedEditing: EditingState = null;
// 编辑器自身的 cleanup 引用;卸载 / 切换编辑目标时调一下,释放 wheel/input listener + 4s timer。
let editorCleanup: (() => void) | null = null;
// 单调递增的"挂载代"。每次 setEditing 启动新 mount 自增一次promise resolve 时
// 只有当代号匹配(= 这次挂载仍生效)才把 cleanup 写进 editorCleanup。
// 否则旧挂载的 promise 慢一拍 resolve 会把"上一任"cleanup 覆盖在"现任"头上,
// 用户关编辑器时只清掉游离 DOM新挂载的 input listener + 4s revertTimer 全部泄漏。
let mountGeneration = 0;
function setEditing(next: EditingState): void {
editing = next;
if (next === null) {
// 收起编辑器:清掉内容并把 mounted 同步回去,让下次进入相同目标也会重建。
editorCleanup?.();
editorCleanup = null;
editorMount.hidden = true;
editorMount.innerHTML = '';
mountedEditing = null;
} else if (next !== mountedEditing) {
// 进入新目标或换目标:先清空旧 DOM 再挂新的,避免旧 input 的 id 冲突。
editorCleanup?.();
editorCleanup = null;
editorMount.hidden = false;
editorMount.innerHTML = '';
mountedEditing = next;
const myGeneration = ++mountGeneration;
const cleanupOrUndef = mountAlarmEditor(editorMount, next === 'new' ? null : next, closeEditor);
// mountAlarmEditor 现在返回 Promise<() => void>;这里把它当 promise 用,
// 把 resolve 出来的 cleanup 存进 editorCleanup卸载时调用
// 代号不匹配说明这次挂载已经被"换目标/关闭"覆盖掉了 —— 直接调它的 cleanup
// 把游离 DOM 上的 listener / timer 清掉,不写进 editorCleanup避免后续覆盖现任 cleanup。
void cleanupOrUndef.then((fn) => {
if (myGeneration !== mountGeneration) { fn(); return; }
editorCleanup = fn;
});
}
// next === mountedEditing非 null时无需任何动作 —— 编辑器仍在挂载中。
// 新建按钮文字始终跟 editing 同步。
addBtn.textContent = editing === 'new' ? t('alarms.cancelNew') : t('alarms.newBtn');
}
// === 内联删除确认 ===
// 不再弹原生 confirm() —— 改为行内两段式:第一次点"删除" → 行进入"确认态"
// 操作区变成"取消 / 确认删除"。这段时间可以反悔(取消 / 4s 超时 / 点别处开始编辑)
// store 变化(删除成功)也会自然清掉确认态。
let pendingDeleteId: string | null = null;
let revertTimer: number | null = null;
function clearRevertTimer(): void {
if (revertTimer !== null) {
window.clearTimeout(revertTimer);
revertTimer = null;
}
}
function cancelPendingDelete(): void {
if (pendingDeleteId === null) return;
pendingDeleteId = null;
clearRevertTimer();
draw();
}
function requestDelete(id: string): void {
// 已经在同一行确认中:再点"删除"算作取消(用户可改主意),避免重复触发 timer。
if (pendingDeleteId === id) { cancelPendingDelete(); return; }
pendingDeleteId = id;
clearRevertTimer();
revertTimer = window.setTimeout(() => {
revertTimer = null;
if (pendingDeleteId !== null) {
pendingDeleteId = null;
draw();
}
}, DELETE_CONFIRM_TIMEOUT_MS);
draw();
}
function commitDelete(id: string): void {
// 确认按钮清掉确认态后真正删除store 变化会触发 draw 重画。
pendingDeleteId = null;
clearRevertTimer();
// 关键:若被删的就是正在编辑的那条,编辑器里 captured 的 existing 仍然引用
// 删除前的 alarm 对象 —— 用户点保存会拿 stale id 去 update无声地把"新建"
// 写成"更新不存在 id"或被静默吞掉。先关编辑器,再删。
if (mountedEditing === id) setEditing(null);
void window.api.alarms.remove(id);
}
// 整个面板生命周期内只建一个 IntersectionObserver每次 draw() 先 disconnect 再复用。
const rowObserver = 'IntersectionObserver' in window
? new IntersectionObserver((entries, observer) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('is-in');
observer.unobserve(e.target);
}
}
}, { rootMargin: '0px 0px -20px 0px', threshold: 0.01 })
: null;
function draw(): void {
// 列表重画:只要 alarms 变化就触发。编辑器部分由 setEditing / closeEditor
// 单独管理,不在这里重建(否则 settings 触发的 storageChanged 会把表单清掉)。
// 主进程按添加顺序存,UI 上按本地时刻升序更直观 —— 时间早的在上,晚的在下。
const items = [...store.alarms.get()].sort(
(a, b) => timeToSeconds(a.time) - timeToSeconds(b.time)
);
const enabled = items.filter(x => x.enabled).length;
// 空态时 count header 显示 "0 / 0 启用" 没意义:分分子为零。改为仅显示"0 启用"
// 或干脆不渲染。这里采用空态隐藏 count badge仅在 items.length > 0 时显示。
if (items.length === 0) {
// 直接用 hidden 属性把整个 span 移出布局,避免 h2 flex 布局里残留 gap 占位
countEl.hidden = true;
} else {
countEl.hidden = false;
countEl.textContent = t('alarms.countFmt', String(enabled), String(items.length));
}
if (items.length === 0) {
// 空状态:放一个 48px 的 outline 闹钟图标当视觉锚点
listEl.innerHTML = `
<li class="alarm-empty">
<span class="alarm-empty-icon" aria-hidden="true">
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 7c-4.5 0-7.8 3.5-7.8 8.2v3.6L6 22.5h20l-2.2-3.7v-3.6c0-4.7-3.3-8.2-7.8-8.2Z" stroke-linejoin="round" />
<path d="M12.5 25.5a3.5 3.5 0 0 0 7 0" />
<path d="M3 8l2-2M29 8l-2-2" />
</svg>
</span>
<p>${t('alarms.empty')}</p>
<p class="alarm-empty-hint">${t('alarms.emptyHint')}</p>
</li>`;
return;
}
const noTitle = t('alarms.untitled');
const titleAttr = t('alarms.toggle');
const ariaEnable = t('alarms.toggleAria');
const editLabel = t('alarms.edit');
const deleteLabel = t('alarms.delete');
const confirmDeleteLabel = t('alarms.confirmDelete');
const cancelLabel = t('common.cancel');
listEl.innerHTML = items.map((a, i) => {
const isConfirming = pendingDeleteId === a.id;
// 行始终渲染 4 个按钮edit / del / cancel-confirm / confirmCSS 按
// data-confirming 切换可见性 —— 不靠 innerHTML 反复改 DOM省得重新
// 绑定 listener 也避免动画跳变。
return `
<li class="alarm-row ${a.enabled ? '' : 'off'} ${a.repeat === 'daily' ? 'daily' : 'once'}"
data-id="${escapeHtml(a.id)}"
data-confirming="${isConfirming ? 'true' : 'false'}"
style="--alarm-row-i: ${i}">
<span class="time">${escapeHtml(a.time)}</span>
<div class="label">
<b>${escapeHtml(a.label) || `<span style="color:var(--text-faint);font-weight:500;">${noTitle}</span>`}</b>
<span>${fmtSubtitle(a)}</span>
</div>
<span class="actions">
<input type="checkbox" class="toggle" data-id="${escapeHtml(a.id)}" ${a.enabled ? 'checked' : ''} title="${titleAttr}" aria-label="${ariaEnable}" />
<button class="row-btn edit" data-id="${escapeHtml(a.id)}" title="${editLabel}">${editLabel}</button>
<button class="row-btn danger del" data-id="${escapeHtml(a.id)}" title="${deleteLabel}">${deleteLabel}</button>
<button class="row-btn cancel-confirm" data-id="${escapeHtml(a.id)}" title="${cancelLabel}">${cancelLabel}</button>
<button class="row-btn danger confirm" data-id="${escapeHtml(a.id)}" title="${confirmDeleteLabel}">${confirmDeleteLabel}</button>
</span>
</li>
`;
}).join('');
// 滚动入场IntersectionObserver 让只有进入视口的行才"松开"动画
// 列表一开始在视口内的行仍会按 --alarm-row-i 错位;之后滚动新行时是单行 fade-up
const rows = Array.from(listEl.querySelectorAll<HTMLElement>('.alarm-row'));
if (rowObserver) {
// 上一轮的行已被 innerHTML 整体替换掉,旧的 observe 目标全是游离节点。
// 不 disconnect 的话每次 draw() 都会多留一个仍持有这些节点强引用的
// observer长会话下持续累积这正是原来的泄漏点
rowObserver.disconnect();
// 拆成两类首屏内4 行内)直接 fade-in避免无可视变化之后用 IO
rows.forEach((row, i) => {
if (i < 4) { row.classList.add('is-in'); }
else { rowObserver.observe(row); }
});
} else {
// 兜底:降级到全量直接 fade-in
rows.forEach(row => row.classList.add('is-in'));
}
listEl.querySelectorAll<HTMLInputElement>('.toggle').forEach(b => b.onchange = async () => {
const id = b.dataset.id!;
await window.api.alarms.update(id, { enabled: b.checked });
});
// 内联编辑:点击"编辑"直接进入内联编辑器。
listEl.querySelectorAll<HTMLButtonElement>('.edit').forEach(b => b.onclick = () => {
// 进入编辑时取消任何挂起的删除确认 —— 用户意图已转移到编辑。
cancelPendingDelete();
setEditing(b.dataset.id!);
});
// 删除:进入行内两段式确认(不再弹原生 confirm
listEl.querySelectorAll<HTMLButtonElement>('.del').forEach(b => b.onclick = () => {
requestDelete(b.dataset.id!);
});
// 取消 / 确认:直接执行 / 撤销
listEl.querySelectorAll<HTMLButtonElement>('.cancel-confirm').forEach(b => b.onclick = () => {
cancelPendingDelete();
});
listEl.querySelectorAll<HTMLButtonElement>('.confirm').forEach(b => b.onclick = () => {
commitDelete(b.dataset.id!);
});
}
const unsubAlarms = store.alarms.subscribe(draw);
draw();
return () => {
unsubAlarms();
clearRevertTimer();
rowObserver?.disconnect();
// 编辑器如果还挂着,释放它的 listener + 4s timer。
editorCleanup?.();
editorCleanup = null;
};
}

View File

@@ -0,0 +1,239 @@
import { store } from '../store';
import { pickNextAlarm } from '../../shared/time';
import { t } from '../i18n';
import { localDateKey, POMODORO_DURATIONS_MS } from '../../shared/constants';
/** 番茄钟 idle 时卡片上显示的 MM:SS —— 从时长常量派生,别写死 25:00。 */
function idlePomoTime(): string {
const sec = Math.round(POMODORO_DURATIONS_MS.focus / 1000);
return `${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`;
}
/**
* 首页上方一个巨字时钟hero居中下方一行小卡片。
* - HeroHH:MM:SS实时+ 日期 + 下一个闹钟的胶囊提示
* - 卡片行:番茄钟状态 / 倒计时状态 / 启用的闹钟数
*
* 巨字时钟是首页的视觉锚点;小卡片只承担辅助信息,整体高度保持紧凑。
*/
export function renderClockPanel(root: HTMLElement): () => void {
root.classList.add('clock-panel');
// 首屏 HTML 直接写入本地时间,而不是 "--:--:--" 占位符。
// 这样即便 JS 在第一帧 paint 之前还没跑到 tickClock(),用户看到的也是真实时钟,
// 而不是一秒以内的占位符闪烁。浮窗时钟(floating-clock/main.ts)走的是同一思路。
// store.clockStr 在 store.ts 模块加载时已经由 localHMS() 初始化,
// 这里直接 get() 拿到的就是"模块加载时刻"的本地时间,与 IPC 首推相差最多 1s。
const initialClock = store.clockStr.get();
const initParts = /^\d{2}:\d{2}:\d{2}$/.test(initialClock)
? initialClock.split(':')
: (() => {
const d = new Date();
const p = (n: number): string => String(n).padStart(2, '0');
return [p(d.getHours()), p(d.getMinutes()), p(d.getSeconds())];
})();
root.innerHTML = `
<section class="clock-hero" aria-label="${t('clock.subtitle')}">
<div class="clock-display mono" id="home-time"><span class="hh">${initParts[0]}</span><span class="colon">:</span><span class="mm">${initParts[1]}</span><span class="colon">:</span><span class="sec">${initParts[2]}</span></div>
<div class="clock-hero-meta">
<span class="clock-date" id="home-date">—</span>
<span class="clock-meta-sep" aria-hidden="true">·</span>
<span class="clock-next" id="home-next">${t('clock.nextNone')}</span>
</div>
</section>
<section class="home-grid" role="list" aria-label="状态卡片">
<div class="home-card" role="listitem" data-card="pomo">
<div class="home-card-label">${t('nav.pomodoro')}</div>
<div class="home-card-value" id="home-pomo-state">${t('clock.pomoStateIdle')}</div>
<div class="home-card-sub mono" id="home-pomo-time">--:--</div>
</div>
<div class="home-card" role="listitem" data-card="countdown">
<div class="home-card-label">${t('nav.countdown')}</div>
<div class="home-card-value" id="home-cd-state">${t('countdown.stateIdle')}</div>
<div class="home-card-sub mono" id="home-cd-time">--:--</div>
</div>
<div class="home-card" role="listitem" data-card="alarms">
<div class="home-card-label">${t('nav.alarms')}</div>
<div class="home-card-value mono" id="home-enabled">0</div>
<div class="home-card-sub">${t('clock.statEnabledSub')}</div>
</div>
</section>
`;
const timeEl = root.querySelector<HTMLElement>('#home-time')!;
// 缓存 hh/mm/sec 子节点引用 —— 每秒只改 textContent不再重建 innerHTML
// 每秒重建一次 innerHTML 既贵又会让任何依赖节点身份的 CSS 动画/变换从头重放。
const hhEl = timeEl.querySelector<HTMLElement>('.hh')!;
const mmEl = timeEl.querySelector<HTMLElement>('.mm')!;
const ssEl = timeEl.querySelector<HTMLElement>('.sec')!;
const dateEl = root.querySelector<HTMLElement>('#home-date')!;
const nextEl = root.querySelector<HTMLElement>('#home-next')!;
const pomoCard = root.querySelector<HTMLElement>('[data-card="pomo"]')!;
const pomoStateEl = root.querySelector<HTMLElement>('#home-pomo-state')!;
const pomoTimeEl = root.querySelector<HTMLElement>('#home-pomo-time')!;
const enabledEl = root.querySelector<HTMLElement>('#home-enabled')!;
const cdCard = root.querySelector<HTMLElement>('[data-card="countdown"]')!;
const cdStateEl = root.querySelector<HTMLElement>('#home-cd-state')!;
const cdTimeEl = root.querySelector<HTMLElement>('#home-cd-time')!;
const weekdayChars = ['日', '一', '二', '三', '四', '五', '六'];
// 把本地 Date 序列化为 HH:MM:SS。本面板用它做两件事:
// 1) 首屏 mount 时立刻填一次,避免 0~1s 内"--:--:--"占位符闪烁;
// 2) IPC clockStr 拿到空串 / 格式异常时兜底,不让 hh/mm/sec 被写成 '' 导致"::"显示。
// 与 shared/time.formatLocalHMS 行为一致,但放在组件内避免再绕一道跨模块依赖。
function localHMS(d: Date = new Date()): string {
const p = (n: number): string => String(n).padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
// 校验 "HH:MM:SS" 形状:必须恰好两段冒号、每段两位数字。空串、'::'、'NaN:NaN'、'12:34' 都判负。
// 早先用 parts[N] ?? '00' 兜底,空串 '' 并不触发 ??,于是 hhEl 会被写成 '' → 视觉上变成 "::"。
const HHMMSS_RE = /^\d{2}:\d{2}:\d{2}$/;
function isValidHHMMSS(s: string): boolean {
return typeof s === 'string' && HHMMSS_RE.test(s);
}
function tickClock(): void {
// 优先级:有效 IPC clockStr > 本地时间。store 初始值(localHMS)可能因模块加载时刻
// 与 IPC 首推之间错位而比当前慢几秒,但仍然是合法 HH:MM:SS,这里就用它,不重新 new Date()。
const raw = store.clockStr.get();
const t = isValidHHMMSS(raw) ? raw : localHMS();
const parts = t.split(':');
const hh = parts[0]!;
const mm = parts[1]!;
const ss = parts[2]!;
if (hhEl.textContent !== hh) hhEl.textContent = hh;
if (mmEl.textContent !== mm) mmEl.textContent = mm;
if (ssEl.textContent !== ss) ssEl.textContent = ss;
const now = new Date();
const w = weekdayChars[now.getDay()] ?? '';
const dateText = `星期${w} ${now.getMonth() + 1}${now.getDate()}`;
if (dateEl.textContent !== dateText) dateEl.textContent = dateText;
}
function syncNext(): void {
// 用 pickNextAlarm 拿真实"下一个要响的" —— 多个 enabled 闹钟时显示最近的,
// 避免早先 enabledAlarms[0] 把最远的那条当成"下一个"。
const enabledAlarms = store.alarms.get().filter(x => x.enabled);
const { alarm, nextAt } = pickNextAlarm(enabledAlarms, new Date());
if (!alarm) {
nextEl.textContent = t('clock.nextNone');
nextEl.classList.remove('has-alarm');
} else {
const diffMin = Math.max(1, Math.round((nextAt - Date.now()) / 60000));
// 不要 escapeHtml下面是 textContent 赋值,本身就不解释 HTML。
// 先转义再写 textContent 会让标签里的 & 显示成 &amp;。
const label = alarm.label || t('floating.alarms.tagDefault');
nextEl.classList.add('has-alarm');
nextEl.textContent = diffMin < 60
? t('clock.nextFmtMin', alarm.time, diffMin, label)
: t('clock.nextFmtHm', alarm.time, Math.floor(diffMin / 60), diffMin % 60, label);
}
enabledEl.textContent = String(enabledAlarms.length);
}
function syncPomo(): void {
const { active } = store.pomodoro.get();
if (!active) {
pomoStateEl.textContent = t('clock.pomoStateIdle');
pomoTimeEl.textContent = idlePomoTime();
pomoCard.dataset.state = 'idle';
return;
}
const remain = active.paused
? active.remainingMs
: Math.max(0, active.endsAt - Date.now());
const sec = Math.ceil(remain / 1000);
const mm = String(Math.floor(sec / 60)).padStart(2, '0');
const ss = String(sec % 60).padStart(2, '0');
pomoTimeEl.textContent = `${mm}:${ss}`;
const phaseLabel = ({ focus: t('pomo.focus'), shortBreak: t('pomo.shortBreak'), longBreak: t('pomo.longBreak') } as Record<string, string>)[active.phase] ?? '';
pomoStateEl.textContent = active.paused
? t('pomo.paused')
: phaseLabel;
if (active.paused) pomoCard.dataset.state = 'paused';
else if (active.phase !== 'focus') pomoCard.dataset.state = 'break';
else pomoCard.dataset.state = 'running';
}
function syncCountdown(): void {
const { active } = store.countdown.get();
if (!active) {
cdStateEl.textContent = t('countdown.stateIdle');
cdTimeEl.textContent = '--:--';
cdCard.dataset.state = 'idle';
return;
}
const remain = active.paused
? active.remainingMs
: Math.max(0, active.endsAt - Date.now());
const totalSec = Math.ceil(remain / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
cdTimeEl.textContent = h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
if (active.paused) {
cdStateEl.textContent = t('countdown.statePaused');
cdCard.dataset.state = 'paused';
} else {
cdStateEl.textContent = t('countdown.stateRunning');
cdCard.dataset.state = 'running';
}
}
// 首屏立刻画一次:挂载前 store.clockStr 已经在模块加载时由 localHMS() 初始化,
// 跟着 IPC clockTick 在 0~1s 内覆盖。若不主动画,DOM 会停留在 "--:--:--" 占位符
// 直到下一个 IPC tick(最多 1s 的空窗),用户能看到闪现。Float-clock 已经做了同样处理。
tickClock();
// clockStr 由主进程每秒推一次,本面板所有随时间漂移的读数都挂在这一个驱动上,
// 不再另开 1Hz 的 driftTimer两个同频定时器做同类事白白多一次唤醒
// 但 idle 时(无 enabled 闹钟 + pomo/countdown 都没在跑)每秒重画是空转:
// tickClock 已经在比 textContent没变就跳过其它三个同步函数也按需短路
// 让首页在 idle 状态下每秒只做 4 个 textContent 比较,不走 pickNextAlarm / Date 构造。
let lastPomoActive = store.pomodoro.get().active;
let lastCountdownActive = store.countdown.get().active;
let lastEnabledCount = store.alarms.get().filter(a => a.enabled).length;
const unsubClock = store.clockStr.subscribe(() => {
tickClock();
// 仅在有内容时才走昂贵的同步路径
const enabledCount = lastEnabledCount;
if (enabledCount > 0) syncNext();
if (lastPomoActive) syncPomo();
if (lastCountdownActive) syncCountdown();
});
// 状态变化后刷新"是否要同步"标志位:减少 1Hz tick 时的判断开销
const unsubAlarms = store.alarms.subscribe(() => {
lastEnabledCount = store.alarms.get().filter(a => a.enabled).length;
syncNext();
});
const unsubPomo = store.pomodoro.subscribe((p) => {
lastPomoActive = p.active;
syncPomo();
});
const unsubCountdown = store.countdown.subscribe((c) => {
lastCountdownActive = c.active;
syncCountdown();
});
syncNext();
syncPomo();
syncCountdown();
// 跨天后日期会变
const dayTimer = window.setInterval(() => {
const today = localDateKey();
if (dateEl.dataset.day !== today) {
dateEl.dataset.day = today;
tickClock();
}
}, 60_000);
return () => {
unsubClock();
unsubAlarms();
unsubPomo();
unsubCountdown();
window.clearInterval(dayTimer);
};
}

View File

@@ -0,0 +1,325 @@
import { store } from '../store';
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
import { formatHMS } from '../../shared/time';
import { t } from '../i18n';
import type { CountdownActiveState } from '../../shared/types';
/**
* 通用倒计时面板:
* - idleHH/MM/SS 输入框 + [开始]
* - running圆环 + 剩余时间 + [暂停][重置]
* - paused圆环 + 剩余时间 + [继续][重置]
* - fired瞬时大字"时间到" + [完成]8 秒无操作自动返回)
*
* 输入框变化时idle本地更新 lastDurationMs + 调 setDuration IPC。
* 注意fired 状态由 phaseChange(null) 维持panel 内部 setTimeout 8 秒后自动清除。
*/
export function renderCountdownPanel(root: HTMLElement): () => void {
root.innerHTML = `
<div class="countdown-panel">
<div class="cd-head">
<span class="cd-title">${t('countdown.title')}</span>
<span class="cd-state" id="cd-state" data-state="idle" role="status" aria-live="polite">${t('countdown.stateIdle')}</span>
</div>
<div class="cd-input-row" id="cd-input-row" role="group" aria-label="${t('countdown.ariaSetDuration')}">
<label class="cd-input-wrap">
<input id="cd-input-hh" class="cd-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="0" aria-label="${t('countdown.ariaHours')}" />
<span class="cd-input-unit" aria-hidden="true">${t('countdown.srHours')}</span>
</label>
<span class="cd-colon" aria-hidden="true">:</span>
<label class="cd-input-wrap">
<input id="cd-input-mm" class="cd-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="5" aria-label="${t('countdown.ariaMinutes')}" />
<span class="cd-input-unit" aria-hidden="true">${t('countdown.srMinutes')}</span>
</label>
<span class="cd-colon" aria-hidden="true">:</span>
<label class="cd-input-wrap">
<input id="cd-input-ss" class="cd-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" value="0" aria-label="${t('countdown.ariaSeconds')}" />
<span class="cd-input-unit" aria-hidden="true">${t('countdown.srSeconds')}</span>
</label>
</div>
<div class="cd-display" role="timer" aria-label="${t('countdown.ariaDisplay')}" id="cd-timer">
<svg width="180" height="180" viewBox="0 0 180 180" aria-hidden="true">
<circle class="ring-bg" cx="90" cy="90" r="80" />
<circle id="cd-ring-fg" class="ring-fg" cx="90" cy="90" r="80"
pathLength="100" transform="rotate(-90 90 90)" />
</svg>
<div class="cd-center">
<div class="cd-time" id="cd-time">00:05:00</div>
<div class="cd-sub" id="cd-sub"></div>
</div>
</div>
<div class="cd-actions" id="cd-actions" data-state="idle">
<button class="btn primary lg" id="cd-start">${t('countdown.startBtn')}</button>
<button class="btn lg" id="cd-pause" style="display:none">${t('countdown.pauseBtn')}</button>
<button class="btn lg" id="cd-resume" style="display:none">${t('countdown.resumeBtn')}</button>
<button class="btn ghost lg" id="cd-reset" style="display:none">${t('countdown.resetBtn')}</button>
</div>
</div>
`;
const inputHh = root.querySelector<HTMLInputElement>('#cd-input-hh')!;
const inputMm = root.querySelector<HTMLInputElement>('#cd-input-mm')!;
const inputSs = root.querySelector<HTMLInputElement>('#cd-input-ss')!;
const inputRow = root.querySelector<HTMLElement>('#cd-input-row')!;
const time = root.querySelector<HTMLElement>('#cd-time')!;
const sub = root.querySelector<HTMLElement>('#cd-sub')!;
const ring = root.querySelector<SVGCircleElement>('#cd-ring-fg')!;
const stateEl = root.querySelector<HTMLElement>('#cd-state')!;
const timerEl = root.querySelector<HTMLElement>('#cd-timer')!;
const actions = root.querySelector<HTMLElement>('#cd-actions')!;
const btnStart = root.querySelector<HTMLButtonElement>('#cd-start')!;
const btnPause = root.querySelector<HTMLButtonElement>('#cd-pause')!;
const btnResume = root.querySelector<HTMLButtonElement>('#cd-resume')!;
const btnReset = root.querySelector<HTMLButtonElement>('#cd-reset')!;
function readDurationMs(): number {
const h = clamp(inputHh.value, 0, 99);
const m = clamp(inputMm.value, 0, 59);
const s = clamp(inputSs.value, 0, 59);
return ((h * 3600) + (m * 60) + s) * 1000;
}
function clamp(raw: string, lo: number, hi: number): number {
const n = Number.parseInt(raw || '0', 10);
if (!Number.isFinite(n)) return lo;
return Math.min(hi, Math.max(lo, n));
}
function syncInputsFromMs(ms: number): void {
const total = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
inputHh.value = String(h);
inputMm.value = String(m);
inputSs.value = String(s);
}
function formatHM(totalSec: number): string {
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
if (h > 0) return t('countdown.fmtHoursMinutes', String(h), String(m));
if (m > 0) return t('countdown.fmtMinutes', String(m));
if (totalSec > 0) return t('countdown.fmtSeconds', String(totalSec));
return t('countdown.inputEmpty');
}
// fired 是 UI-only 的瞬时态:主进程广播 phaseChanged(null) 之后
// active 已为 null靠这个标志把 idle 和 fired 区分开。
let fired = false;
let firedTimer: number | null = null;
function clearFiredTimer(): void {
if (firedTimer !== null) {
window.clearTimeout(firedTimer);
firedTimer = null;
}
}
function exitFired(): void {
clearFiredTimer();
fired = false;
draw();
// exitFired 后 btnReset 被 hidedraw 的 idle 分支),焦点若停留在它上面,
// 浏览器会把焦点挪回 body 而不是 idle 路径里"应该接住"的 btnStart。
// 显式 focus 让键盘/屏读用户后续 Enter 能直接启动,不必先 Tab 找按钮。
btnStart.focus({ preventScroll: true });
}
function enterFired(): void {
fired = true;
clearFiredTimer();
// 8 秒无操作自动回到 idle按钮在本轮 draw 后获得焦点。
firedTimer = window.setTimeout(() => {
firedTimer = null;
exitFired();
}, 8000);
// queueMicrotask 主要是为绕开 sync 路径里 focus 切换被吞的问题;
// 实际上 fired 只在 enterFired 同步路径里被设成 true下面的 focus
// 直接同步调用即可 —— 微任务那一层判断 fired 永远为 true没意义。
btnReset.focus({ preventScroll: true });
}
// 显隐一律走内联 style.display不要用 [hidden] 属性:
// 按钮会被 global.css 的 `.btn[hidden] { display: none }` 锁死,
// timerEl / inputRow 则反过来被自身的 display:flex 盖过 UA 的 [hidden]。
function setVisible(el: HTMLElement, v: boolean): void {
el.style.display = v ? '' : 'none';
}
function draw(): void {
const { active, lastDurationMs } = store.countdown.get();
if (!active) {
syncInputsFromMs(lastDurationMs);
const total = Math.ceil(lastDurationMs / 1000);
time.textContent = formatHMS(fired ? 0 : total);
ring.setAttribute('stroke-dasharray', '0 100');
ring.classList.add('idle');
// 没有 active 就不该呼吸。running → fired 时若不显式清零,
// 上一轮留下的 '1' 会让"时间到"表盘一直脉动。
timerEl.style.setProperty('--ring-pulse', '0');
timerEl.dataset.pulse = '0';
if (fired) {
sub.textContent = t('countdown.timeUp');
stateEl.textContent = t('countdown.timeUp');
stateEl.dataset.state = 'fired';
actions.dataset.state = 'fired';
setVisible(btnStart, false);
setVisible(btnPause, false);
setVisible(btnResume, false);
setVisible(btnReset, true);
btnReset.textContent = t('countdown.doneBtn');
btnReset.classList.remove('ghost');
btnReset.classList.add('primary');
btnReset.disabled = false;
setVisible(timerEl, true); // fired表盘显示"时间到"
inputRow.style.display = 'none';
[inputHh, inputMm, inputSs].forEach(i => { i.disabled = true; });
return;
}
btnReset.textContent = t('countdown.resetBtn');
btnReset.classList.remove('primary');
btnReset.classList.add('ghost');
sub.textContent = t('countdown.setN', formatHM(total));
stateEl.textContent = t('countdown.stateIdle');
stateEl.dataset.state = 'idle';
actions.dataset.state = 'idle';
setVisible(btnStart, true);
setVisible(btnPause, false);
setVisible(btnResume, false);
setVisible(btnReset, false);
setVisible(timerEl, false); // idle表盘整组隐藏避免与输入行重复显示 HH:MM:SS
inputRow.style.display = '';
[inputHh, inputMm, inputSs].forEach(i => { i.disabled = false; });
btnStart.disabled = lastDurationMs <= 0;
return;
}
btnReset.textContent = t('countdown.resetBtn');
btnReset.classList.remove('primary');
btnReset.classList.add('ghost');
const remain = active.paused
? active.remainingMs
: Math.max(0, active.endsAt - Date.now());
const totalSec = Math.ceil(remain / 1000);
time.textContent = formatHMS(totalSec);
sub.textContent = active.paused ? t('countdown.statePaused') : t('countdown.stateRunning');
stateEl.textContent = active.paused ? t('countdown.statePaused') : t('countdown.stateRunning');
stateEl.dataset.state = active.paused ? 'paused' : 'running';
const ratio = Math.max(0, Math.min(1, remain / active.durationMs));
ring.setAttribute('stroke-dasharray', `${ratio * 100} 100`);
ring.classList.remove('idle');
// 暂停时不呼吸;运行中呼吸
timerEl.style.setProperty('--ring-pulse', active.paused ? '0' : '1');
timerEl.dataset.pulse = active.paused ? '0' : '1';
actions.dataset.state = active.paused ? 'paused' : 'running';
setVisible(btnStart, false);
setVisible(btnPause, !active.paused);
setVisible(btnResume, active.paused);
setVisible(btnReset, true);
setVisible(timerEl, true); // running/paused表盘显示
inputRow.style.display = 'none';
[inputHh, inputMm, inputSs].forEach(i => { i.disabled = true; });
// aria 同步:让屏读器知道当前剩余时间(每秒 240ms 节流,无屏读噪音)
timerEl.setAttribute('aria-label',
active.paused ? t('countdown.ariaPausedFmt', formatHM(totalSec)) : t('countdown.ariaRunningFmt', formatHM(totalSec)));
}
// 输入框变化:本地更新 + IPC 持久化 lastDurationMs
// 附带 sanitizetype="text" 允许任意字符,过滤掉非数字;超过 maxlength 自动截断。
function sanitizeInput(el: HTMLInputElement): void {
const cleaned = el.value.replace(/\D/g, '').slice(0, el.maxLength || 2);
if (cleaned !== el.value) el.value = cleaned;
}
function onInputChange(): void {
if (store.countdown.get().active) return; // 运行中不允许改
[inputHh, inputMm, inputSs].forEach(sanitizeInput);
const ms = readDurationMs();
store.countdown.set({ active: null, lastDurationMs: ms });
btnStart.disabled = ms <= 0;
// ms<=0 时不发 IPC主进程会显式拒绝避免「上次时长 = 0」污染持久化
// 未捕获的 promise 拒绝会被 main.ts 的 unhandledrejection handler 弹成 toast
// 把无效输入静默吃掉更友好。start 按钮已在上一行禁用,无法启动。
if (ms > 0) void window.api.countdown.setDuration(ms);
}
// 鼠标滚轮调整数值wheel-up +1wheel-down -1按住 Shift 时步进为 5。
// 运行中active不响应避免误触fired/disabled 状态也跳过 —— 否则 disabled
// 输入框仍会触发 wheel 事件并被 preventDefault 吞掉页面滚动,鼠标停在它上面
// 就划不动页面fired 时三个输入框都是 disabled=true
function onWheelAdjust(e: WheelEvent): void {
const el = e.currentTarget as HTMLInputElement;
if (el.disabled) return;
if (store.countdown.get().active) return;
e.preventDefault();
const max = el === inputHh ? 99 : 59;
const cur = clamp(el.value, 0, max);
const dir = e.deltaY < 0 ? 1 : -1;
const step = e.shiftKey ? 5 : 1;
const next = Math.max(0, Math.min(max, cur + dir * step));
if (next === cur) return;
el.value = String(next);
onInputChange();
}
btnStart.onclick = () => {
const ms = readDurationMs();
if (ms <= 0) return;
void window.api.countdown.start(ms);
};
btnPause.onclick = () => void window.api.countdown.pause();
btnResume.onclick = () => void window.api.countdown.resume();
btnReset.onclick = () => {
if (fired) {
// fired 状态下按"完成"= 用户确认听到了提醒,主进程必须立刻把所有还在跑
// 的提醒路径停掉。当前唯一通道是系统通知cancelAll 已是 no-op
// 保留 IPC 调用是给未来恢复声音/全屏闪时不用再改面板。
void window.api.notifier.cancel();
exitFired();
return;
}
void window.api.countdown.reset();
};
// active 由非 null 变 null 有两种可能:自然归零(fired) 或 用户 reset。
// 区分依据:自然归零时 endsAt 已经到点(<= nowreset 时 endsAt 还在未来。
let prevActive: CountdownActiveState | null = null;
const unsubStore = store.countdown.subscribe(({ active }) => {
if (prevActive && !active
&& !prevActive.paused
&& prevActive.endsAt <= Date.now()) {
enterFired();
}
prevActive = active;
draw();
ticker.restart();
});
// 持续刷新(圆环 + 大字):睡到读数下一次改变,而不是 rAF 60Hz + 240ms 闸门。
// idle显示上次设定时长与 paused 下读数不会自己变nextAt 返回 null不装定时器。
const ticker = createSecondTicker(draw, () => {
const { active } = store.countdown.get();
return !active || active.paused ? null : nextCeilBoundary(active.endsAt);
});
draw();
// 输入框事件 listenercleanup 时一起移除
const inputListeners: Array<[HTMLInputElement, typeof onInputChange]> = [
[inputHh, onInputChange], [inputMm, onInputChange], [inputSs, onInputChange]
];
inputListeners.forEach(([el, fn]) => el.addEventListener('input', fn));
// 滚轮调整HH/MM/SS 都注册 wheel 监听,按目标元素的不同 max 限制HH 0-99、MM/SS 0-59
const wheelListener: typeof onWheelAdjust = onWheelAdjust;
[inputHh, inputMm, inputSs].forEach(el => {
el.addEventListener('wheel', wheelListener, { passive: false });
});
return () => {
ticker.stop();
clearFiredTimer();
inputListeners.forEach(([el, fn]) => el.removeEventListener('input', fn));
[inputHh, inputMm, inputSs].forEach(el => el.removeEventListener('wheel', wheelListener));
unsubStore();
};
}

View File

@@ -0,0 +1,33 @@
import { store } from '../store';
import { t } from '../i18n';
// 一把 14px 风格的"图钉/别针":头是圆弧 + 斜线,底部一根短针。
// 与 Topbar 统一16 viewBox · stroke 1.4 · round caps · currentColor。
const ICON_PIN = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.5 2.5h-3l-1 3.5L4 7.5v1.5h8V7.5l-1.5-1.5-1-3.5Z" />
<path d="M8 9v4.5" />
</svg>`;
export function renderPinButton(root: HTMLElement): () => void {
root.classList.add('pin-btn');
root.setAttribute('aria-label', t('topbar.pinOff'));
// 纯图标按钮:靠图标本身 + 旋转来表达 on/off 状态
root.innerHTML = `
<span class="pin-btn-mark" aria-hidden="true">${ICON_PIN}</span>
`;
function sync(): void {
const on = store.topbar.get().alwaysOnTop;
root.classList.toggle('on', on);
root.setAttribute('aria-pressed', String(on));
const key = on ? 'topbar.pinOn' : 'topbar.pinOff';
root.setAttribute('aria-label', t(key as Parameters<typeof t>[0]));
}
const unsubTopbar = store.topbar.subscribe(sync);
const onClick = () => { void window.api.windows.toggleAlwaysOnTop(); };
root.addEventListener('click', onClick);
sync();
return () => {
unsubTopbar();
root.removeEventListener('click', onClick);
};
}

View File

@@ -0,0 +1,178 @@
import { store } from '../store';
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
import { formatHMS } from '../../shared/time';
import { POMODORO_DURATIONS_MS, localDateKey } from '../../shared/constants';
import { t } from '../i18n';
import type { PomodoroPhase } from '../../shared/types';
function phaseLabel(p: PomodoroPhase): string {
return ({ focus: t('pomo.focus'), shortBreak: t('pomo.shortBreak'), longBreak: t('pomo.longBreak') } as Record<PomodoroPhase, string>)[p];
}
export function renderPomodoroPanel(root: HTMLElement): () => void {
root.innerHTML = `
<div class="pomo-panel">
<div class="pomo-head">
<span class="pomo-phase" id="pomo-phase">${t('pomo.idle')}</span>
<span class="pomo-round-pill" id="pomo-round-pill"></span>
</div>
<div class="pomo-display" role="timer" aria-label="${t('pomo.ariaDisplay')}" id="pomo-timer">
<svg width="180" height="180" viewBox="0 0 180 180" aria-hidden="true">
<defs>
<linearGradient id="pomo-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="var(--accent)" />
<stop offset="100%" stop-color="var(--accent-2)" />
</linearGradient>
</defs>
<circle class="ring-bg" cx="90" cy="90" r="80" />
<circle id="ring-fg" class="ring-fg" cx="90" cy="90" r="80"
pathLength="100" transform="rotate(-90 90 90)" />
</svg>
<div class="pomo-center">
<div class="pomo-time" id="pomo-time"><span id="pomo-mm">--</span><span class="colon">:</span><span id="pomo-ss">--</span></div>
<div class="pomo-sub" id="pomo-sub">25 min ${t('pomo.focus')}</div>
</div>
</div>
<div class="pomo-actions" id="pomo-actions" data-state="idle">
<button class="btn primary lg" id="pomo-start">${t('pomo.start')}</button>
<button class="btn lg" id="pomo-pause" style="display:none">${t('pomo.pause')}</button>
<button class="btn lg" id="pomo-resume" style="display:none">${t('pomo.resume')}</button>
<button class="btn ghost lg" id="pomo-skip">${t('pomo.skip')}</button>
<button class="btn ghost lg" id="pomo-reset" style="display:none">${t('pomo.reset')}</button>
</div>
<div class="pomo-foot">
<span class="pomo-today" id="pomo-today">${t('pomo.today')} <strong id="pomo-today-count">0</strong> ${t('pomo.todayUnit')}</span>
<span class="pomo-foot-sep">·</span>
<span>${t('pomo.footHint')}</span>
</div>
</div>
`;
const sub = root.querySelector<HTMLElement>('#pomo-sub')!;
const timeMm = root.querySelector<HTMLElement>('#pomo-mm')!;
const timeSs = root.querySelector<HTMLElement>('#pomo-ss')!;
const ring = root.querySelector<SVGCircleElement>('#ring-fg')!;
const timerEl = root.querySelector<HTMLElement>('#pomo-timer')!;
const phase = root.querySelector<HTMLElement>('#pomo-phase')!;
const roundPill = root.querySelector<HTMLElement>('#pomo-round-pill')!;
const todayCount = root.querySelector<HTMLElement>('#pomo-today-count')!;
const actions = root.querySelector<HTMLElement>('#pomo-actions')!;
const btnStart = root.querySelector<HTMLButtonElement>('#pomo-start')!;
const btnPause = root.querySelector<HTMLButtonElement>('#pomo-pause')!;
const btnResume = root.querySelector<HTMLButtonElement>('#pomo-resume')!;
const btnSkip = root.querySelector<HTMLButtonElement>('#pomo-skip')!;
const btnReset = root.querySelector<HTMLButtonElement>('#pomo-reset')!;
btnStart.onclick = () => void window.api.pomodoro.start();
btnPause.onclick = () => void window.api.pomodoro.pause();
btnResume.onclick = () => void window.api.pomodoro.resume();
btnSkip.onclick = () => void window.api.pomodoro.skip();
btnReset.onclick = () => void window.api.pomodoro.reset();
function setVisible(el: HTMLElement, visible: boolean): void {
// 初始隐藏必须写成内联 style="display:none",不能用 [hidden] 属性:
// global.css 的 `.btn[hidden] { display: none }` 特异性高于内联清空后的回退,
// 一旦带上 hidden 属性,这里的 display='' 就再也显不出来F-14 修复)。
el.style.display = visible ? '' : 'none';
}
// 每秒只改文本,不再重建节点 —— 原来这里是 time.innerHTML = `...`
// 每秒把两个冒号和秒数节点全部销毁重建(做法参照 ClockPanel
// 番茄钟最长 25 分钟HH 永远是 0显示 MM:SS 即可。
// secSmall 保留原有差异idle 的秒是正常字号running 的秒带 .sec20px + 暗色)。
function paintTime(mm: string, ss: string, secSmall: boolean): void {
if (timeMm.textContent !== mm) timeMm.textContent = mm;
if (timeSs.textContent !== ss) timeSs.textContent = ss;
const cls = secSmall ? 'sec' : '';
if (timeSs.className !== cls) timeSs.className = cls;
}
function draw(): void {
const { active, history } = store.pomodoro.get();
// 今日计数:跨天后 store 还没更新,但 localDateKey 已变,强制重置显示
const today = localDateKey();
const displayCount = history.date === today ? history.count : 0;
todayCount.textContent = String(displayCount);
if (!active) {
// 未开始
// 从时长常量派生,别写死 25 —— 否则改了 POMODORO_DURATIONS_MS
// 这里和下面那行 sub 就会互相打架。
// 番茄钟最长 25 分钟HH 永远 0 → 只显示 MM:SS。
{
const sec = Math.round(POMODORO_DURATIONS_MS.focus / 1000);
const p = (n: number): string => String(n).padStart(2, '0');
paintTime(p(Math.floor(sec / 60)), p(sec % 60), false);
}
sub.textContent = `${Math.round(POMODORO_DURATIONS_MS.focus / 60000)} min ${t('pomo.focus')}`;
phase.textContent = t('pomo.idle');
ring.setAttribute('stroke-dasharray', '0 100');
ring.setAttribute('stroke', 'url(#pomo-grad)');
ring.classList.add('idle');
ring.dataset.phase = 'focus';
timerEl.style.setProperty('--ring-pulse', '0'); // idle 不呼吸
timerEl.dataset.pulse = '0';
actions.dataset.state = 'idle';
setVisible(btnStart, true);
setVisible(btnPause, false);
setVisible(btnResume, false);
setVisible(btnReset, false);
btnSkip.disabled = true;
roundPill.textContent = t('pomo.roundPill', 1);
timerEl.setAttribute('aria-label', t('pomo.ariaIdle', t('pomo.focus'), Math.round(POMODORO_DURATIONS_MS.focus / 60000)));
return;
}
const remain = active.paused ? active.remainingMs : Math.max(0, active.endsAt - Date.now());
const totalSec = Math.ceil(remain / 1000);
const formatted = formatHMS(totalSec);
const parts = formatted.split(':');
const mm = parts[1] ?? '00';
const ss = parts[2] ?? '00';
paintTime(mm, ss, true);
const isBreak = active.phase !== 'focus';
const total = POMODORO_DURATIONS_MS[active.phase];
const ratio = Math.max(0, Math.min(1, remain / total));
ring.setAttribute('stroke-dasharray', `${ratio * 100} 100`);
ring.setAttribute('stroke', isBreak ? 'var(--ok)' : 'url(#pomo-grad)');
ring.classList.remove('idle');
ring.dataset.phase = isBreak ? 'break' : 'focus';
// 暂停时不呼吸;运行中呼吸
timerEl.style.setProperty('--ring-pulse', active.paused ? '0' : '1');
timerEl.dataset.pulse = active.paused ? '0' : '1';
sub.textContent = active.paused ? t('pomo.paused') : isBreak ? t('pomo.subBreak') : t('pomo.subFocus');
phase.textContent = phaseLabel(active.phase) + (active.paused ? ' · ' + t('pomo.paused') : '');
actions.dataset.state = active.paused ? 'paused' : 'running';
setVisible(btnStart, false);
setVisible(btnPause, !active.paused);
setVisible(btnResume, active.paused);
setVisible(btnReset, true);
btnSkip.disabled = false;
// 用 currentRound用户心智模型是"正在进行第 N 轮"。
// active.currentRound 在 transition 进 focus 时 +1所以从 1 开始累计。
roundPill.textContent = t('pomo.roundPill', active.currentRound);
const pausedSuffix = active.paused ? t('pomo.ariaPaused') : '';
timerEl.setAttribute('aria-label',
t('pomo.ariaRemain', phaseLabel(active.phase), pausedSuffix, formatted));
}
// 跨午夜时 history.count 不会自动清零(要等 storage 写盘后下次启动),
// 渲染端每分钟检查一次:发现跨天则把 display 视为 0
const dayTimer = window.setInterval(draw, 60_000);
// 显示精度是秒,所以睡到「读数下一次改变」即可(改造前是 rAF 60Hz + 240ms 闸门)。
// 暂停 / 未开始时读数不会自己变nextAt 返回 null完全不装定时器。
const ticker = createSecondTicker(draw, () => {
const { active } = store.pomodoro.get();
return !active || active.paused ? null : nextCeilBoundary(active.endsAt);
});
// 状态变化(开始 / 暂停 / 继续 / 跳过 / 重置)后要重排下一次唤醒。
const unsubPomodoro = store.pomodoro.subscribe(() => { draw(); ticker.restart(); });
draw();
return () => {
ticker.stop();
window.clearInterval(dayTimer);
unsubPomodoro();
};
}

View File

@@ -0,0 +1,222 @@
// src/renderer/components/SettingsPanel.ts
// 设置面板:皮肤 + 窗口置顶 + 关于。
// 2026-08 精简后,提醒方式只剩"系统通知"一种(写死在 Notifier 里),
// 不再暴露给用户配置;铃声、音量、试听按钮也一并下线。
import { store } from '../store';
import { setSkin } from '../themeApply';
import { SKINS } from '../../shared/theme';
import { t } from '../i18n';
import { toast } from './Toast';
/** 颜色字符串收紧校验:只接受 #RGB / #RGBA / #RRGGBB / #RRGGBBAA。
* 来源是 share/theme.ts(SKINS),目前受信任;但拦截掉"red; ..." 类 CSS 注入面,
* 万一未来把 SKINS 数据迁到 JSON 就立刻生效。 */
const COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
const ICON_CHECK_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 8.5l3 3 6-6.5" /></svg>`;
export function renderSettingsPanel(root: HTMLElement): () => void {
root.classList.add('settings-panel');
root.innerHTML = `
<header class="settings-header">
<h2>${t('settings.title')}</h2>
<p class="settings-sub">${t('settings.sub')}</p>
</header>
<section class="settings-section" aria-labelledby="settings-skin-h">
<h3 id="settings-skin-h" class="settings-section-h">
<span>${t('settings.skinTitle')}</span>
<span class="settings-current" id="settings-skin-current"></span>
</h3>
<p class="settings-section-desc">${t('settings.skinDesc')}</p>
<div class="skin-grid" id="skin-grid" role="radiogroup" aria-labelledby="settings-skin-h"></div>
</section>
<section class="settings-section" aria-labelledby="settings-window-h">
<h3 id="settings-window-h" class="settings-section-h">${t('settings.windowTitle')}</h3>
<label class="settings-row">
<span class="settings-row-label">
<span class="settings-row-name">${t('settings.alwaysOnTop')}</span>
<span class="settings-row-desc">${t('settings.alwaysOnTopDesc')}</span>
</span>
<span class="settings-row-control">
<input type="checkbox" id="settings-aot" />
</span>
</label>
</section>
<section class="settings-section" aria-labelledby="settings-about-h">
<h3 id="settings-about-h" class="settings-section-h">${t('settings.aboutTitle')}</h3>
<p class="settings-about-line">
<span class="settings-about-label">${t('settings.aboutDeveloper')}</span>
<a class="settings-about-link"
id="settings-about-link"
href="https://www.guanjihuan.com/about"
rel="noopener noreferrer"
aria-label="${t('settings.aboutDeveloperAria')}">https://www.guanjihuan.com/about</a>
</p>
</section>
<footer class="settings-foot">
<span>${t('settings.foot')}</span>
</footer>
`;
const grid = root.querySelector<HTMLElement>('#skin-grid')!;
const currentEl = root.querySelector<HTMLElement>('#settings-skin-current')!;
const aotInput = root.querySelector<HTMLInputElement>('#settings-aot')!;
// 缓存:上一次画过的 active skin id。store.skin 在 setSkin 之后会触发 2 次
// subscribe(本地 setSkin + bootstrap storageChanged 转发),每次都 paintSkins()
// 会把整张 grid 重写 + 重新绑 listener。用 id 短路掉重复画。
let paintedSkin: string | null = null;
/** 一个 skin-card DOM;数据走 textContent / setAttribute,杜绝 innerHTML 注入。 */
function buildSkinCard(s: typeof SKINS[number], active: string): HTMLElement {
const isActive = s.id === active;
const [a, b, c, d] = s.preview;
const btn = document.createElement('button');
btn.className = 'skin-card' + (isActive ? ' active' : '');
btn.type = 'button';
btn.setAttribute('role', 'radio');
btn.setAttribute('aria-checked', String(isActive));
btn.dataset.skin = s.id;
const preview = document.createElement('span');
preview.className = 'skin-card-preview';
preview.setAttribute('aria-hidden', 'true');
preview.style.display = 'flex';
preview.style.flexDirection = 'column';
preview.style.width = '100%';
preview.style.height = '56px';
preview.style.borderRadius = '4px';
preview.style.overflow = 'hidden';
for (const color of [a, b, c, d]) {
// 颜色做正则校验,不合规就 fallback 到 transparent —— 比抛错更宽容。
const stripe = document.createElement('span');
stripe.className = 'skin-card-stripe';
stripe.style.background = COLOR_RE.test(color) ? color : 'transparent';
stripe.style.flex = '1 1 0';
stripe.style.width = '100%';
stripe.style.minHeight = '14px';
preview.appendChild(stripe);
}
btn.appendChild(preview);
const meta = document.createElement('span');
meta.className = 'skin-card-meta';
const name = document.createElement('span');
name.className = 'skin-card-name';
name.textContent = s.name; // 不再走 innerHTML
meta.appendChild(name);
const blurb = document.createElement('span');
blurb.className = 'skin-card-blurb';
blurb.textContent = s.blurb; // 不再走 innerHTML
meta.appendChild(blurb);
const mode = document.createElement('span');
mode.className = 'skin-card-mode';
mode.dataset.mode = s.mode;
mode.textContent = s.mode === 'dark' ? t('settings.modeDark') : t('settings.modeLight');
meta.appendChild(mode);
btn.appendChild(meta);
const check = document.createElement('span');
check.className = 'skin-card-check';
check.setAttribute('aria-hidden', 'true');
// 复选图标是受信任的静态 SVG,直接 innerHTML 设置——非用户输入路径。
check.innerHTML = ICON_CHECK_SVG;
btn.appendChild(check);
return btn;
}
function paintSkins(): void {
const active = store.skin.get();
if (active === paintedSkin) return;
paintedSkin = active;
const frag = document.createDocumentFragment();
for (const s of SKINS) frag.appendChild(buildSkinCard(s, active));
grid.replaceChildren(frag);
}
function syncCurrent(): void {
const id = store.skin.get();
const meta = SKINS.find(s => s.id === id);
currentEl.textContent = meta ? `· ${meta.name}` : '';
}
// 三组事件 handler 单独命名(不要用匿名箭头直接 addEventListener
// cleanup 要 removeEventListener 时必须拿得到同一个函数引用;匿名箭头即使
// 长得一样也无法被 removeEventListener 匹配。这里把 handler 提到外层。
function onGridClick(e: MouseEvent): void {
const card = (e.target as HTMLElement).closest<HTMLElement>('.skin-card');
if (!card) return;
const id = card.dataset.skin;
if (!id) return;
setSkin(id);
}
function onGridKeydown(e: KeyboardEvent): void {
if (e.key !== 'Enter' && e.key !== ' ') return;
const card = (e.target as HTMLElement).closest<HTMLElement>('.skin-card');
if (!card) return;
e.preventDefault();
const id = card.dataset.skin;
if (id) setSkin(id);
}
function onAotChange(): void {
// 切换框先翻转"未来态",再触发 IPC —— IPC 成功 → topbarChanged → store 同步,
// 失败时本地复选框状态会暂时与 store 不一致,下一次 subscribe 自动修正。
void window.api.windows.toggleAlwaysOnTop();
}
/** 外链点击:走 IPC 让主进程用系统默认浏览器打开 —— 而不是开 Electron 自己的
* BrowserWindowtarget=_blank 在 Electron 里仍会落在本应用里)。
* 中键 / Ctrl+Click 仍交给浏览器原生处理(不 preventDefault尊重用户偏好。 */
function onAboutClick(e: MouseEvent): void {
if (e.defaultPrevented) return;
if (e.button !== 0) return; // 中键 / 右键:保留原生行为
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
const a = e.currentTarget as HTMLAnchorElement;
const href = a.href;
void window.api.shell.openExternal(href).then((ok) => {
if (!ok) toast(t('settings.aboutOpenFailed'), 'warn');
});
}
/** Enter 键:链接本身就响应 Enter无需重复实现但按下时浏览器会先触发 click
* 上面 onAboutClick 已拦截;这里只需保证不被其它键盘 handler 抢走焦点。 */
grid.addEventListener('click', onGridClick);
// 卡片用 radio role,键盘可达:focus + Enter/Space 应用
grid.addEventListener('keydown', onGridKeydown);
function syncAot(): void {
aotInput.checked = store.topbar.get().alwaysOnTop === true;
}
aotInput.addEventListener('change', onAotChange);
const aboutLink = root.querySelector<HTMLAnchorElement>('#settings-about-link')!;
aboutLink.addEventListener('click', onAboutClick);
const unsubSkin = store.skin.subscribe(() => {
paintSkins();
syncCurrent();
});
const unsubTopbar = store.topbar.subscribe(syncAot);
paintSkins();
syncCurrent();
syncAot();
return () => {
unsubSkin();
unsubTopbar();
grid.removeEventListener('click', onGridClick);
grid.removeEventListener('keydown', onGridKeydown);
aotInput.removeEventListener('change', onAotChange);
aboutLink.removeEventListener('click', onAboutClick);
};
}

View File

@@ -0,0 +1,220 @@
import { store } from '../store';
import { t } from '../i18n';
import type { Route } from '../../shared/types';
interface TabItem {
id: Route;
label: string;
svg: string;
}
// 图标与 Sidebar.ts 保持一致(同一套 14×14 stroke=1.4 的 outline 系),
// 这样后面若启用 Sidebar 二选一时视觉不会割裂。
// 4 个图标各表一意,互不重叠:
// clock — 圆面 + 时针分针
// pomodoro — 番茄pomodoro 意大利语即"番茄"):圆体 + 顶部三叶花萼
// countdown — 沙漏:上下边框 + 两对角线 + 中段沙粒
// alarms — 闹钟:圆顶铃身 + 外撇喇叭口 + 底部摆锤
const ICONS = {
clock: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<circle cx="8" cy="8" r="6.5" />
<path d="M8 4.5v3.6l2.4 1.4" stroke-linecap="round" />
</svg>`,
pomodoro: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="9.5" r="4.3" />
<path d="M5.5 5.5L8 3l2.5 2.5" />
<path d="M8 3v3" />
</svg>`,
countdown: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 2.5h8" />
<path d="M4 13.5h8" />
<path d="M4 2.5L12 13.5" />
<path d="M12 2.5L4 13.5" />
<path d="M8 8v1.5" />
</svg>`,
alarms: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 11s2-1.5 2-5.5a3 3 0 0 1 6 0c0 4 2 5.5 2 5.5h-10Z" />
<path d="M7 13a1 1 0 0 0 2 0" />
</svg>`
};
const ITEMS: TabItem[] = [
{ id: 'clock', label: t('nav.clock'), svg: ICONS.clock },
{ id: 'pomodoro', label: t('nav.pomodoro'), svg: ICONS.pomodoro },
{ id: 'countdown', label: t('nav.countdown'), svg: ICONS.countdown },
{ id: 'alarms', label: t('nav.alarms'), svg: ICONS.alarms }
];
export function renderTabStrip(root: HTMLElement): () => void {
root.classList.add('tab-strip');
root.setAttribute('role', 'tablist');
// tablist 的 aria-label 是整个导航的名字,不是某一个 tab 的名字
// (早先误设成 t('nav.clock')="时钟",屏读器会把整条导航念成"时钟")。
root.setAttribute('aria-label', t('nav.mainTabs'));
// 静态结构list + 共用的滑动指示线节点 + 右侧淡出遮罩
root.innerHTML = `
<div class="tab-strip-list" role="presentation">
${ITEMS.map(it => `
<button class="tab-item"
role="tab"
data-route="${it.id}"
type="button"
aria-controls="route-panel">
<span class="tab-item-mark">${it.svg}</span>
<span class="tab-item-label">${it.label}</span>
</button>
`).join('')}
</div>
<span class="tab-indicator" aria-hidden="true"></span>
`;
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('.tab-item'));
const list = root.querySelector<HTMLElement>('.tab-strip-list')!;
const indicator = root.querySelector<HTMLElement>('.tab-indicator')!;
/** 永远只读当前 store避免组件内部存过期状态。 */
function sync(): void {
const cur = store.route.get();
// settings 不在 ITEMS 里 —— 当 route === 'settings' 时所有按钮都不 active
// 不能把所有 button.tabIndex 设 -1否则整条 tablist 失去键盘可达性,
// 键盘用户进入设置页后再也回不到主面板(除了点顶部齿轮按钮)。
// 修复:保留"当前 active 或列表第一个"为 tabIndex=0其他 -1。
buttons.forEach(btn => {
const active = btn.dataset.route === cur;
btn.classList.toggle('active', active);
btn.setAttribute('aria-selected', String(active));
});
// 让"列表第一个"作为 fallback focus 锚点(仅在没有任何 active 时)。
const activeBtn = buttons.find(b => b.dataset.route === cur) ?? buttons[0]!;
buttons.forEach(btn => {
btn.tabIndex = btn === activeBtn ? 0 : -1;
});
// 指示线定位放 RAF 里1) 让 class 切换先 paint 完 2) 拿到真实宽度
requestAnimationFrame(() => moveIndicator(cur, false));
// 选中 tab 偏离视口时滚回视口
activeBtn.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
/**
* 滑动指示线。
* initial=true 首次挂载:仅淡入,避免从 0 宽滑过来的突兀感
* initial=false 切换transform + width 同步 220ms cubic-bezier 平移
* 没有目标时(理论不会发生)隐藏。
*
* 位置公式:
* indicator 用 `position:absolute; left:0` 挂在 .tab-strip 下left:0
* 落在父元素的 padding-edge不是 content-edge所以 translateX 的
* 原点 = stripRect.left —— 不要再去减 paddingLeft那会双重扣减。
* getBoundingClientRect 已反映 strip 的 scrollLeft因此即使 tab 被横向
* 滚出再滚回,指示线也会跟到视觉中心。用 Math.round 把 subpixel 抖动消除。
*/
function moveIndicator(route: Route, initial: boolean): void {
const target = buttons.find(b => b.dataset.route === route);
if (!target) {
indicator.style.opacity = '0';
return;
}
const tabRect = target.getBoundingClientRect();
const stripRect = root.getBoundingClientRect();
// 胶囊感:指示线宽度 = 选中 tab 宽度的 60%,最小 20px
const w = Math.max(20, tabRect.width * 0.6);
// 中心对齐tab 视觉中心 - indicator 中心 - strip 左缘
const x = Math.round(
(tabRect.left + tabRect.width / 2) - (stripRect.left + w / 2)
);
indicator.style.width = `${w}px`;
indicator.style.transform = `translateX(${x}px)`;
indicator.style.opacity = '1';
indicator.style.transition = initial
? 'opacity 220ms cubic-bezier(0.22, 1, 0.36, 1)'
: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1)';
}
/**
* 只在真正能滚动的一侧才淡出。
* 4 个 tab 通常不溢出,若无条件挂 mask最左的"时钟"会被永久压暗,
* 看起来像有一层阴影挡住了图标。
*/
function syncFade(): void {
const max = root.scrollWidth - root.clientWidth;
// 1px 容差subpixel 布局下 scrollWidth 常比 clientWidth 大零点几
const overflowing = max > 1;
const left = overflowing && root.scrollLeft > 1;
const right = overflowing && root.scrollLeft < max - 1;
list.style.setProperty('--tab-fade-start', left ? '24px' : '0px');
list.style.setProperty('--tab-fade-end', right ? '24px' : '0px');
}
// ---- 交互 ----
// handler 提到外层取名cleanup 时 removeEventListener 要拿到同一引用,
// 匿名箭头就算长一样也无法匹配;统一命名也方便阅读。
function onTabClick(e: MouseEvent): void {
const target = e.target as HTMLElement;
const btn = target.closest<HTMLButtonElement>('.tab-item');
if (!btn) return;
const route = btn.dataset.route as Route | undefined;
if (route) store.route.set(route);
}
function onTabKeydown(e: KeyboardEvent): void {
// settings 不在 ITEMS 里cur === 'settings' 时 idx = -1。
// 早先直接 return → 进入设置页后 ←/→/Home/End 全失效。
// 修复cur 不在 ITEMS 时,把"当前 focus 的按钮索引"作为锚点,没有就 fallback 0。
const cur = store.route.get();
let idx = ITEMS.findIndex(it => it.id === cur);
if (idx < 0) {
const focused = document.activeElement as HTMLButtonElement | null;
const fi = focused ? buttons.indexOf(focused) : -1;
idx = fi >= 0 ? fi : 0;
}
let nextIdx = -1;
switch (e.key) {
case 'ArrowLeft': nextIdx = (idx - 1 + ITEMS.length) % ITEMS.length; break;
case 'ArrowRight': nextIdx = (idx + 1) % ITEMS.length; break;
case 'Home': nextIdx = 0; break;
case 'End': nextIdx = ITEMS.length - 1; break;
case 'Enter':
case ' ':
// focus 已经在 tab 上时,浏览器默认 click不用额外处理
return;
default: return;
}
e.preventDefault();
const next = ITEMS[nextIdx]!;
store.route.set(next.id);
buttons[nextIdx]?.focus();
}
root.addEventListener('click', onTabClick);
/**
* 键盘导航:←/→ 切换一个Home/End 跳首尾Enter/Space 激活当前 focus 那个。
* 选中后焦点跟到新 tab 上roving tabindex 模式)。
*/
root.addEventListener('keydown', onTabKeydown);
// 视口尺寸变化(窗口拖宽、缩放)时保持指示线对齐
const ro = new ResizeObserver(() => {
moveIndicator(store.route.get(), false);
syncFade();
});
ro.observe(list);
ro.observe(root);
const onScroll = (): void => syncFade();
root.addEventListener('scroll', onScroll, { passive: true });
const unsubRoute = store.route.subscribe(sync);
sync();
// 首次挂载走 initial 路径(淡入)
moveIndicator(store.route.get(), true);
syncFade();
return () => {
unsubRoute();
ro.disconnect();
root.removeEventListener('scroll', onScroll);
root.removeEventListener('click', onTabClick);
root.removeEventListener('keydown', onTabKeydown);
};
}

View File

@@ -0,0 +1,243 @@
// 极简 toast 通知:替代 alert()。在应用根挂一次,所有地方都可调 toast()。
// 设计要点:
// - 不依赖任何状态库,纯 DOM + setTimeout
// - 同种 kind 的连续 toast 在 250ms 内会被合并,避免短时间内刷屏;
// - 失败 / 警告类的 toast 默认 5s比 info 长一些),给用户时间看清楚。
// - 多 host 支持:主窗和编辑器各自调 mountToastHost(),互不影响——编辑器启动
// 不会再"抢走"主窗的 host 导致主窗后续 toast 不可见F-17 修复)。
// - 按 kind 分流error / warn 进顶部居中"打断"位info / ok 进右下浮层。
import { t } from '../i18n';
export type ToastKind = 'info' | 'ok' | 'warn' | 'error';
interface ToastItem {
id: number;
kind: ToastKind;
message: string; // 纯文本 —— render 时走 textContent 注入,杜绝 innerHTML 路径下的 XSS
expiresAt: number;
}
interface HostCtx {
items: ToastItem[];
lastByKind: Map<ToastKind, number>;
cleanupTimer: number | null;
/** 容器节点(含 .is-critical 类) */
el: HTMLElement;
/** 这个 host 接收的 kind 集合('all' = 全部) */
accepts: 'all' | ToastKind[];
}
class ToastBus {
private hosts: HostCtx[] = [];
private nextId = 1;
/**
* 在 root 下挂两个 hostcriticalerror/warn顶部居中和 normalinfo/ok右下
* 解绑时整体一起卸载。
* 多入口共存的场景(主窗 + 编辑器)由调用方各调一次 attach互不干扰。
*
* 幂等:同一 document 里已有 hostid 探测),则不再 attach,返回 noop cleanup。
* 这样保证主窗 mount 期间 main.ts 在 document.body 上预挂的 host 不会被
* App.ts 在 #app 子树里再挂一份,避免一次 toast 在 4 处同时显示。
* 不同 BrowserWindow 的渲染进程各自一份 bus,document 隔离不影响。
*/
attach(root: HTMLElement): () => void {
if (document.getElementById('toast-host-critical') || document.getElementById('toast-host-normal')) {
return () => {};
}
const critical = document.createElement('div');
critical.id = 'toast-host-critical';
root.appendChild(critical);
const normal = document.createElement('div');
normal.id = 'toast-host-normal';
root.appendChild(normal);
const ctxs: HostCtx[] = [
{ items: [], lastByKind: new Map(), cleanupTimer: null, el: critical, accepts: ['error', 'warn'] },
{ items: [], lastByKind: new Map(), cleanupTimer: null, el: normal, accepts: ['info', 'ok'] }
];
this.hosts.push(...ctxs);
ctxs.forEach(c => {
c.el.classList.add('toast-host');
// 只有承载 'error' 的 host 才是"打断"位info/ok host 不要这个类
// 以免被 CSS 当 critical 容器撑起顶部留白(参考 global.css .toast-host.is-critical)。
if (c.accepts.includes('error')) c.el.classList.add('is-critical');
this.render(c);
});
return () => this.detach(ctxs);
}
private detach(ctxs: HostCtx[]): void {
for (const ctx of ctxs) {
if (ctx.cleanupTimer !== null) {
window.clearTimeout(ctx.cleanupTimer);
ctx.cleanupTimer = null;
}
ctx.el.remove();
const i = this.hosts.indexOf(ctx);
if (i >= 0) this.hosts.splice(i, 1);
}
}
show(message: string, kind: ToastKind = 'info', ms?: number): void {
// 无 host 时例如测试或挂载之前静默忽略toast 不应崩溃主流程。
if (this.hosts.length === 0) return;
const lifetime = ms ?? this.defaultMs(kind);
const now = Date.now();
for (const ctx of this.hosts) {
if (!this.accepts(ctx, kind)) continue;
// 合并:同 kind 在 250ms 内连击 → 替换上一条而不是新开一条
const lastAt = ctx.lastByKind.get(kind) ?? 0;
if (now - lastAt < 250) {
const idx = ctx.items.findIndex(i => i.kind === kind);
if (idx >= 0) {
ctx.items[idx]!.message = message;
ctx.items[idx]!.expiresAt = now + lifetime;
ctx.lastByKind.set(kind, now);
this.render(ctx);
this.scheduleCleanup(ctx);
// continue 而不是 return本类支持多个 host主窗 + 编辑器各 attach 一次),
// return 会让后面的 host 一条都收不到。
continue;
}
}
const item: ToastItem = {
id: this.nextId++,
kind,
message,
expiresAt: now + lifetime
};
ctx.items.push(item);
ctx.lastByKind.set(kind, now);
this.render(ctx);
this.scheduleCleanup(ctx);
}
}
private accepts(ctx: HostCtx, kind: ToastKind): boolean {
if (ctx.accepts === 'all') return true;
return ctx.accepts.includes(kind);
}
dismiss(id: number): void {
for (const ctx of this.hosts) {
const el = ctx.el.querySelector<HTMLElement>(`.toast[data-id="${id}"]`);
if (el && el.isConnected) {
el.classList.add('is-leaving');
window.setTimeout(() => {
const idx = ctx.items.findIndex(i => i.id === id);
if (idx >= 0) {
ctx.items.splice(idx, 1);
this.render(ctx);
}
}, 180);
return;
}
const idx = ctx.items.findIndex(i => i.id === id);
if (idx >= 0) {
ctx.items.splice(idx, 1);
this.render(ctx);
}
}
}
/** 清掉所有 host 上的所有 toast同时清掉各自的 cleanupTimer避免悬空。 */
clear(): void {
for (const ctx of this.hosts) {
if (ctx.cleanupTimer !== null) {
window.clearTimeout(ctx.cleanupTimer);
ctx.cleanupTimer = null;
}
ctx.items = [];
this.render(ctx);
}
}
private defaultMs(kind: ToastKind): number {
if (kind === 'error') return 6000;
if (kind === 'warn') return 5000;
if (kind === 'ok') return 2500;
return 3500;
}
private scheduleCleanup(ctx: HostCtx): void {
if (ctx.cleanupTimer !== null) {
window.clearTimeout(ctx.cleanupTimer);
ctx.cleanupTimer = null;
}
// 找下一个最早过期的 toast 的剩余寿命作为本轮 wakeup 间隔 —— 否则设成 250ms 的话
// 长寿命 toast6s error也要等 250ms 才能进入 leaving 动画。让 cleanup 直接睡到
// "下一个要过期"的时刻,期间不发任何清理。
const now = Date.now();
const nextExpiry = ctx.items.reduce((m, it) => Math.min(m, it.expiresAt), Number.POSITIVE_INFINITY);
const delay = nextExpiry === Number.POSITIVE_INFINITY
? 250
: Math.max(0, nextExpiry - now) + 200; // +200ms 留点过渡让 toastOut 跑完
ctx.cleanupTimer = window.setTimeout(() => {
const at = Date.now();
let leaving = 0;
for (const it of ctx.items) {
if (it.expiresAt <= at && leaving < 8) {
const el = ctx.el.querySelector<HTMLElement>(`.toast[data-id="${it.id}"]`);
if (el && !el.classList.contains('is-leaving')) {
el.classList.add('is-leaving');
window.setTimeout(() => this.dismiss(it.id), 180);
leaving++;
}
}
}
ctx.items = ctx.items.filter(i => i.expiresAt > at + 200);
this.render(ctx);
ctx.cleanupTimer = null;
// 如果列表里还有未过期的 toast重新调度下一轮 —— 早先 fix 漏了这一行,
// 导致 setTimeout 一次性触发后 ctx.cleanupTimer 永久为 nulltoast 永远不消失。
if (ctx.items.length > 0) this.scheduleCleanup(ctx);
}, delay);
}
private render(ctx: HostCtx): void {
// 用 DOM API 重建,杜绝 innerHTML 下用户输入被解析为 HTML 的风险;
// 全部字符串走 textContent / setAttribute('aria-label', ...) 等安全接口。
ctx.el.replaceChildren(...ctx.items.map(i => {
const row = document.createElement('div');
row.className = `toast toast-${i.kind}`;
row.dataset.id = String(i.id);
row.setAttribute('role', 'status');
row.setAttribute('aria-live', 'polite');
const dot = document.createElement('span');
dot.className = 'toast-dot';
dot.setAttribute('aria-hidden', 'true');
row.appendChild(dot);
const msg = document.createElement('span');
msg.className = 'toast-msg';
msg.textContent = i.message;
row.appendChild(msg);
const close = document.createElement('button');
close.className = 'toast-x';
close.setAttribute('aria-label', t('toast.close'));
close.textContent = '×'; // "×",不用 HTML 实体
close.addEventListener('click', () => this.dismiss(i.id));
row.appendChild(close);
return row;
}));
}
}
// 单例:模块级私有,外部只通过 toast() / mountToastHost() 间接调用,
// 避免外部直接操作内部状态。
const bus = new ToastBus();
/** 简写toast(msg, kind?) — 内部转 bus.show */
export function toast(message: string, kind: ToastKind = 'info', ms?: number): void {
bus.show(message, kind, ms);
}
/** 在 App.ts mount 时调一次挂载点。返回的函数可卸载整个 host。 */
export function mountToastHost(root: HTMLElement): () => void {
return bus.attach(root);
}

View File

@@ -0,0 +1,191 @@
import { t } from '../i18n';
import { renderPinButton } from './PinButton';
import { store } from '../store';
import type { FloatingKind, Route } from '../../shared/types';
const TITLE_KEY = 'app.brand' as const;
// === Icon System ===
// 全部图标统一16×16 viewBox · stroke 1.4 · linecap/linejoin round · currentColor。
// 与 TabStrip / Sidebar 同源避免视觉割裂。CSS 端按容器尺寸归一到 14×14 显示。
// 浮窗的 4 个图标与 TabStrip 的 4 个语义一一对应,造型一致:
// floatingClock — 圆面 + 时针分针
// floatingPomodoro — 番茄:圆体 + 顶部三叶花萼
// floatingCountdown — 沙漏:上下边框 + 两对角线 + 中段沙粒
// floatingAlarms — 闹钟:圆顶铃身 + 外撇喇叭口 + 底部摆锤
const ICONS = {
// 窗口按钮14px 视觉尺寸配 16×16 viewBox居中
winMin: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true">
<path d="M3.5 11h9" />
</svg>`,
winMax: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true">
<rect x="3.5" y="3.5" width="9" height="9" rx="1.2" />
</svg>`,
winClose: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>`,
// 齿轮Feather/Lucide 的标准 settings 齿轮(原 24×24 坐标整体 ×2/3 缩到 16×16
// 视口,与本文件其它图标同一规格)。原先那版是"圆 + 8 根放射短线",在 14px 下
// 读起来是太阳/亮度,不是设置,故替换为公认造型。
settings: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="2" />
<path d="M12.93 10a1.1 1.1 0 0 0 .22 1.21l.04.04a1.33 1.33 0 0 1 0 1.89 1.33 1.33 0 0 1-1.89 0l-.04-.04a1.1 1.1 0 0 0-1.21-.22 1.1 1.1 0 0 0-.67 1.01V14a1.33 1.33 0 0 1-1.33 1.33A1.33 1.33 0 0 1 6.72 14v-.06A1.1 1.1 0 0 0 6 12.93a1.1 1.1 0 0 0-1.21.22l-.04.04a1.33 1.33 0 0 1-1.89 0 1.33 1.33 0 0 1 0-1.89l.04-.04a1.1 1.1 0 0 0 .22-1.21 1.1 1.1 0 0 0-1.01-.67H2a1.33 1.33 0 0 1-1.33-1.33A1.33 1.33 0 0 1 2 6.72h.06A1.1 1.1 0 0 0 3.07 6a1.1 1.1 0 0 0-.22-1.21l-.04-.04a1.33 1.33 0 0 1 0-1.89 1.33 1.33 0 0 1 1.89 0l.04.04a1.1 1.1 0 0 0 1.21.22H6a1.1 1.1 0 0 0 .67-1.01V2A1.33 1.33 0 0 1 8 .67a1.33 1.33 0 0 1 1.33 1.33v.06a1.1 1.1 0 0 0 .67 1.01 1.1 1.1 0 0 0 1.21-.22l.04-.04a1.33 1.33 0 0 1 1.89 0 1.33 1.33 0 0 1 0 1.89l-.04.04a1.1 1.1 0 0 0-.22 1.21V6a1.1 1.1 0 0 0 1.01.67H14a1.33 1.33 0 0 1 1.33 1.33 1.33 1.33 0 0 1-1.33 1.33h-.06a1.1 1.1 0 0 0-1.01.67z" />
</svg>`,
// 浮窗时钟:传统时钟 + 12/3/6/9 + 指针
floatingClock: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="5.6" />
<path d="M8 4.6V8l2.4 1.4" />
</svg>`,
// 浮窗番茄钟番茄pomodoro 意大利语即"番茄")—— 圆体 + 顶部三叶花萼
floatingPomodoro: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="9.5" r="4.3" />
<path d="M5.5 5.5L8 3l2.5 2.5" />
<path d="M8 3v3" />
</svg>`,
// 浮窗闹钟:圆顶铃身 + 外撇喇叭口 + 底部摆锤
floatingAlarms: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 11s2-1.5 2-5.5a3 3 0 0 1 6 0c0 4 2 5.5 2 5.5h-10Z" />
<path d="M7 13a1 1 0 0 0 2 0" />
</svg>`,
// 浮窗倒计时:沙漏 —— 上下边框 + 两对角线 + 中段沙粒
floatingCountdown: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 2.5h8" />
<path d="M4 13.5h8" />
<path d="M4 2.5L12 13.5" />
<path d="M12 2.5L4 13.5" />
<path d="M8 8v1.5" />
</svg>`
};
// 4 个浮窗按钮的元数据。顺序与托盘菜单一致:时钟 → 番茄钟 → 倒计时 → 闹钟。
const FLOATING_BUTTONS: Array<{ kind: FloatingKind; icon: keyof typeof ICONS; tooltipKey: string; btnId: string }> = [
{ kind: 'clock', icon: 'floatingClock', tooltipKey: 'topbar.floatingClock', btnId: 'topbar-floating-clock' },
{ kind: 'pomodoro', icon: 'floatingPomodoro', tooltipKey: 'topbar.floatingPomodoro', btnId: 'topbar-floating-pomodoro' },
{ kind: 'countdown', icon: 'floatingCountdown', tooltipKey: 'topbar.floatingCountdown', btnId: 'topbar-floating-countdown' },
{ kind: 'alarms', icon: 'floatingAlarms', tooltipKey: 'topbar.floatingAlarms', btnId: 'topbar-floating-alarms' }
];
export function renderTopbar(root: HTMLElement): () => void {
root.classList.add('topbar');
const floatingBtns = FLOATING_BUTTONS.map(b => `
<button class="topbar-iconbtn floating-btn" id="${b.btnId}" data-kind="${b.kind}"
title="${t(b.tooltipKey as Parameters<typeof t>[0])}"
aria-label="${t(b.tooltipKey as Parameters<typeof t>[0])}">
<span class="topbar-icon" aria-hidden="true">${ICONS[b.icon]}</span>
</button>
`).join('');
root.innerHTML = `
<div class="topbar-title">
<span class="topbar-title-main" id="topbar-title"></span>
</div>
<div class="topbar-actions" id="topbar-actions">
<div class="topbar-floating-group" role="group" aria-label="${t('topbar.floatingGroup')}">
${floatingBtns}
</div>
<div id="topbar-pin" class="topbar-pin-slot"></div>
<button class="topbar-iconbtn" id="topbar-settings" title="${t('topbar.settings')}" aria-label="${t('topbar.settings')}">
<span class="topbar-icon" aria-hidden="true">${ICONS.settings}</span>
</button>
</div>
<div class="topbar-winbtns" id="topbar-winbtns" aria-label="窗口控制" hidden>
<button class="winbtn" id="winbtn-min" title="最小化" aria-label="最小化">
<span class="topbar-icon" aria-hidden="true">${ICONS.winMin}</span>
</button>
<button class="winbtn" id="winbtn-max" title="最大化" aria-label="最大化">
<span class="topbar-icon" aria-hidden="true">${ICONS.winMax}</span>
</button>
<button class="winbtn winbtn-close" id="winbtn-close" title="关闭" aria-label="关闭">
<span class="topbar-icon" aria-hidden="true">${ICONS.winClose}</span>
</button>
</div>
`;
const title = root.querySelector<HTMLElement>('#topbar-title')!;
const pinSlot = root.querySelector<HTMLElement>('#topbar-pin')!;
const cleanupPin = renderPinButton(pinSlot);
// 设置按钮toggle 语义 —— 进设置页 / 再点一次回到进来之前那个面板。
// 点亮态与当前路由联动,键盘可达 + aria-pressed 同步。
const settingsBtn = root.querySelector<HTMLButtonElement>('#topbar-settings')!;
// 记住"从哪个面板进的设置"。托盘/单实例唤醒也可能直接把路由设成 settings
// 那时这里保留的仍是上一个非设置面板,退出时回得去。
let lastPanelRoute: Route = store.route.get() === 'settings' ? 'clock' : store.route.get();
function syncSettingsBtn(route: Route): void {
if (route !== 'settings') lastPanelRoute = route;
const on = route === 'settings';
settingsBtn.classList.toggle('on', on);
settingsBtn.setAttribute('aria-pressed', String(on));
}
const unsubRoute = store.route.subscribe(syncSettingsBtn);
const onSettingsClick = (): void => {
store.route.set(store.route.get() === 'settings' ? lastPanelRoute : 'settings');
};
settingsBtn.addEventListener('click', onSettingsClick);
syncSettingsBtn(store.route.get());
// 4 个浮窗按钮:点击 toggle 对应浮窗on 状态由 store.floatingOpen 驱动
// (主进程通过 floatingWindowsChanged 推送)。键盘焦点态沿用 .topbar-iconbtn.on 的样式。
// 缓存按钮引用floatingOpen 推送(开/关浮窗、托盘 refresh每条都会触发 subscribe
// 每条都 4 次 querySelector 没意义 —— 这里只查一次,循环里直接复用。
const floatingBtnEls = FLOATING_BUTTONS.map(b => ({
kind: b.kind,
btn: root.querySelector<HTMLButtonElement>(`#${b.btnId}`)!,
onClick: () => { void window.api.windows.toggleFloating(b.kind); }
}));
const unsubFloating = store.floatingOpen.subscribe((open) => {
for (const { kind, btn } of floatingBtnEls) {
const isOn = !!open[kind];
// 状态没变就不写 DOMtoggle/aria 在高频 broadcast 下会触发多次重排
// floatingWindowsChanged 推一次storageChanged 又推一次,等等)。
const prevOn = btn.classList.contains('on');
if (prevOn === isOn) continue;
btn.classList.toggle('on', isOn);
btn.setAttribute('aria-pressed', String(isOn));
}
});
for (const { btn, onClick } of floatingBtnEls) {
btn.addEventListener('click', onClick);
}
const winbtns = root.querySelector<HTMLElement>('#topbar-winbtns')!;
// Win32 上的窗口按钮onclick 通过 named 引用收集cleanup 时一起置 null 让 GC 释放。
let winBtnHandlers: Array<{ btn: HTMLButtonElement; handler: () => void }> | null = null;
if (window.api.platform === 'win32') {
winbtns.hidden = false;
const minBtn = root.querySelector<HTMLButtonElement>('#winbtn-min')!;
const maxBtn = root.querySelector<HTMLButtonElement>('#winbtn-max')!;
const closeBtn = root.querySelector<HTMLButtonElement>('#winbtn-close')!;
const onMin = (): void => { void window.api.windows.minimize(); };
const onMax = (): void => { void window.api.windows.toggleMaximize(); };
const onWinClose = (): void => { void window.api.windows.close(); };
minBtn.onclick = onMin;
maxBtn.onclick = onMax;
closeBtn.onclick = onWinClose;
winBtnHandlers = [
{ btn: minBtn, handler: onMin },
{ btn: maxBtn, handler: onMax },
{ btn: closeBtn, handler: onWinClose }
];
}
// 标题不再随 route 变化(设置页已移除),挂载时设一次即可。
title.textContent = t(TITLE_KEY as Parameters<typeof t>[0]);
return () => {
unsubRoute();
unsubFloating();
settingsBtn.removeEventListener('click', onSettingsClick);
for (const { btn, onClick } of floatingBtnEls) {
btn.removeEventListener('click', onClick);
}
if (winBtnHandlers) {
for (const { btn, handler } of winBtnHandlers) {
btn.onclick = null;
}
winBtnHandlers = null;
}
// PinButton 自己挂了一个 store.topbar 订阅 + click listener
// 不调它的 cleanup 会让 listener 在下次 mount 时叠加。
cleanupPin();
};
}

View File

@@ -0,0 +1,14 @@
// 共享的 HTML 转义工具,供多个组件复用。
// 注意:这是给 textContent / 标签属性 / 内联子节点使用的最小集,
// 不要把它套到 URL、style 等需要更严格语境处理的场景。
const HTML_ESCAPE_MAP: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
export function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c]!);
}

View File

@@ -0,0 +1,167 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' file:" />
<title>闹钟</title>
<style>
/* === 浮窗专用 CSS 变量(6 皮肤,每皮肤只声明浮窗实际用到的 16 个) ===
原先加载 global.css + themes.css 合并产物 ~75 KB,实际只用 ~5%。
内联后 0 round-trip,且不依赖主进程预创建后的额外资源请求。 */
:root {
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
--line: rgba(255, 255, 255, 0.06);
--text: #F2F2F5;
--text-dim: #B4B4BC;
--text-faint: #9090A0;
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.14);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.20);
--accent-2: #7C8AE5;
--warn: #D9695E;
--warn-soft: rgba(217, 105, 94, 0.12);
--warn-line: rgba(217, 105, 94, 0.40);
--ok: #4CB782;
--ok-soft: rgba(76, 183, 130, 0.14);
--ok-line: rgba(76, 183, 130, 0.40);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
}
:root[data-skin="brass-dark"] {
--bg-elev-2: #2B231B;
--bg-elev-3: #38291E;
--line: rgba(255, 235, 200, 0.08);
--text: #F1E4C7;
--text-dim: #C9B998;
--text-faint: #8A7E66;
--accent: #D9B25F;
--accent-soft: rgba(217, 178, 95, 0.14);
--accent-line: rgba(217, 178, 95, 0.45);
--accent-glow: rgba(217, 178, 95, 0.22);
--accent-2: #E8C77A;
--warn: #C97050;
--warn-soft: rgba(201, 112, 80, 0.14);
--warn-line: rgba(201, 112, 80, 0.45);
--ok: #7BA47B;
--ok-soft: rgba(123, 164, 123, 0.14);
--ok-line: rgba(123, 164, 123, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
}
:root[data-skin="solarized-dark"] {
--bg-elev-2: #0A4150;
--bg-elev-3: #134E5E;
--line: rgba(147, 161, 161, 0.20);
--text: #EEE8D5;
--text-dim: #93A1A1;
--text-faint: #657B83;
--accent: #268BD2;
--accent-soft: rgba(38, 139, 210, 0.18);
--accent-line: rgba(38, 139, 210, 0.45);
--accent-glow: rgba(38, 139, 210, 0.22);
--accent-2: #B58900;
--warn: #DC322F;
--warn-soft: rgba(220, 50, 47, 0.18);
--warn-line: rgba(220, 50, 47, 0.45);
--ok: #859900;
--ok-soft: rgba(133, 153, 0, 0.18);
--ok-line: rgba(133, 153, 0, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
}
:root[data-skin="nord"] {
--bg-elev-2: #434C5E;
--bg-elev-3: #4C566A;
--line: rgba(229, 233, 240, 0.10);
--text: #ECEFF4;
--text-dim: #D8DEE9;
--text-faint: #88909D;
--accent: #88C0D0;
--accent-soft: rgba(136, 192, 208, 0.18);
--accent-line: rgba(136, 192, 208, 0.45);
--accent-glow: rgba(136, 192, 208, 0.22);
--accent-2: #81A1C1;
--warn: #BF616A;
--warn-soft: rgba(191, 97, 106, 0.18);
--warn-line: rgba(191, 97, 106, 0.45);
--ok: #A3BE8C;
--ok-soft: rgba(163, 190, 140, 0.18);
--ok-line: rgba(163, 190, 140, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
}
:root[data-skin="catppuccin"] {
--bg-elev-2: #313244;
--bg-elev-3: #45475A;
--line: rgba(205, 214, 244, 0.10);
--text: #CDD6F4;
--text-dim: #BAC2DE;
--text-faint: #6C7086;
--accent: #CBA6F7;
--accent-soft: rgba(203, 166, 247, 0.18);
--accent-line: rgba(203, 166, 247, 0.45);
--accent-glow: rgba(203, 166, 247, 0.22);
--accent-2: #F38BA8;
--warn: #F38BA8;
--warn-soft: rgba(243, 139, 168, 0.18);
--warn-line: rgba(243, 139, 168, 0.45);
--ok: #A6E3A1;
--ok-soft: rgba(166, 227, 161, 0.18);
--ok-line: rgba(166, 227, 161, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
}
:root[data-skin="vercel-light"] {
--bg-elev-2: #F4F4F5;
--bg-elev-3: #E4E4E7;
--line: rgba(20, 22, 38, 0.08);
--text: #18181B;
--text-dim: #50525A;
--text-faint: #A1A1AA;
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.10);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.18);
--accent-2: #6E78C7;
--warn: #DC2626;
--warn-soft: rgba(220, 38, 38, 0.10);
--warn-line: rgba(220, 38, 38, 0.40);
--ok: #16A34A;
--ok-soft: rgba(22, 163, 74, 0.10);
--ok-line: rgba(22, 163, 74, 0.40);
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
}
/* 兼容旧 [data-theme] 写法(无 [data-skin])—— 落到 linear-dark 默认值 */
:root[data-theme]:not([data-skin]) {
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
}
/* === Minimal reset: 浮窗专用精简版 ===
background: transparent 是关键 —— 否则覆盖不住桌布,卡片会变黑底。
font-family 不在这里写:会被各 floating-*/styles.css html, body 块覆盖,
styles.css font-family 才是当前生效的版本如果要加中文回退,应该改
styles.css 而不是这里(否则被 styles.css 覆盖失效) */
html, body {
margin: 0; padding: 0;
background: transparent;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden; height: 100vh;
}
* { box-sizing: border-box; }
</style>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root">
<button id="close" title="关闭浮窗" aria-label="关闭浮窗">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
<div id="label">下一个</div>
<div id="time">--:--</div>
<div id="countdown"></div>
<div id="title"></div>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,132 @@
// 浮窗闹钟240×150 紧凑卡片,展示下一个要触发的闹钟 + 距今多久。
// - 拖拽走 OS#root 的 -webkit-app-region: drag
// - 关闭路径与浮窗番茄钟一致:×/双击/Esc/右键/触屏长按
//
// 设计要点:
// 1. 拉一次 getState 兜底初始化,然后订阅 storageChanged。
// 2. 距下次触发的剩余时间用 1s setTimeout 重画clockTick 已经每秒推送,复用之避免再加 interval
// 3. 多个闹钟时,主标题展示"下一个",副标题展示"共 X 个启用"。
// 4. 没有启用闹钟时,显示空态文案。
import { pickNextAlarm } from '../../shared/time';
import { t } from '../i18n';
import { attachFloatingWindowBehavior, subscribeFloatingTheme } from '../floating-shared';
import type { Alarm } from '../../shared/types';
const root = document.getElementById('root')!;
const labelEl = document.querySelector<HTMLElement>('#label')!;
const timeEl = document.querySelector<HTMLElement>('#time')!;
const countdownEl = document.querySelector<HTMLElement>('#countdown')!;
const titleEl = document.querySelector<HTMLElement>('#title')!;
let enabled: Alarm[] = [];
/** 把毫秒差格式化为 "Xh Ym 后" / "Y 分后" / "Z 秒后"。文案走 i18n 键。 */
function fmtDiff(ms: number): string {
const sec = Math.max(1, Math.round(ms / 1000));
if (sec < 60) return t('floating.alarms.diffSec', sec);
const min = Math.round(sec / 60);
if (min < 60) return t('floating.alarms.diffMin', min);
const h = Math.floor(min / 60);
const m = min % 60;
return m > 0 ? t('floating.alarms.diffHM', h, m) : t('floating.alarms.diffH', h);
}
/** "下一个要响" 缓存pickNextAlarm 是 O(N),每帧 paint 调用会有冗余。
* 250ms 内复用同一次结果,避免短时间内算两次。 */
let pickCache: { at: number; result: { alarm: Alarm | null; nextAt: number } } | null = null;
function pickNext(now = new Date()): { alarm: Alarm | null; nextAt: number } {
if (pickCache && Date.now() - pickCache.at < 250) return pickCache.result;
const result = pickNextAlarm(enabled, now);
pickCache = { at: Date.now(), result };
return result;
}
let cancelled = false;
// 上次画过的状态快照:只有真正变了才动 DOM。
// 1Hz clockTick 每秒都会推一次;空态/无变化的渲染全靠此短路。
let lastDiffText = '';
let lastTimeText = '';
let lastTitleText = '';
let lastLabelText = '';
let lastRootState: 'enabled' | 'empty' | null = null;
function paint(): void {
if (cancelled) return;
const total = enabled.length;
if (total === 0) {
if (lastRootState !== 'empty') {
root.dataset.state = 'empty';
lastRootState = 'empty';
}
if (lastLabelText !== 'def') { labelEl.textContent = t('floating.alarms.tagDefault'); lastLabelText = 'def'; }
if (lastTimeText !== '——') { timeEl.textContent = '——'; lastTimeText = '——'; }
if (lastDiffText !== 'empty') { countdownEl.textContent = t('floating.alarms.empty'); lastDiffText = 'empty'; }
const cnt0 = t('floating.alarms.countN', 0);
if (lastTitleText !== cnt0) { titleEl.textContent = cnt0; lastTitleText = cnt0; }
return;
}
const { alarm, nextAt } = pickNext();
if (!alarm) {
// enabled 不空但没有 nextAt理论上 nextFireAt 至少给一个)—— 走空态路径
if (lastRootState !== 'empty') {
root.dataset.state = 'empty';
lastRootState = 'empty';
}
if (lastTimeText !== '——') { timeEl.textContent = '——'; lastTimeText = '——'; }
if (lastDiffText !== 'empty') { countdownEl.textContent = t('floating.alarms.empty'); lastDiffText = 'empty'; }
return;
}
if (lastRootState !== 'enabled') {
root.dataset.state = 'enabled';
lastRootState = 'enabled';
}
const diff = nextAt - Date.now();
const nextLabel = t('floating.alarms.next');
if (lastLabelText !== nextLabel) { labelEl.textContent = nextLabel; lastLabelText = nextLabel; }
if (lastTimeText !== alarm.time) { timeEl.textContent = alarm.time; lastTimeText = alarm.time; }
const diffText = fmtDiff(diff);
if (lastDiffText !== diffText) { countdownEl.textContent = diffText; lastDiffText = diffText; }
// 标题:标签 + 启用数量角标(多启用时显示总数)
const tag = alarm.label || t('floating.alarms.tagDefault');
const nextTitle = total > 1 ? t('floating.alarms.titleFmt', tag, total) : t('floating.alarms.titleOnly', tag);
if (lastTitleText !== nextTitle) { titleEl.textContent = nextTitle; lastTitleText = nextTitle; }
}
// 共用:右键菜单 / 双击关闭 / 触屏长按 / Esc / × 按钮
attachFloatingWindowBehavior(root, 'alarms');
// 同步首屏 paint:此时 enabled=[] 走 total===0 分支,显示 i18n 默认空态文案
// ("暂无闹钟"+"共 0 个启用"),而不是裸 HTML 默认占位符。await getState() 拿到
// 真数据后,lambda 内会再 paint() 覆盖 —— 视觉上不会有"空态 → 正确"的两帧闪烁。
paint();
// 主题订阅(与 pomodoro / countdown / clock 共用同一份实现)
const unsubTheme = subscribeFloatingTheme();
// 所有 on.* 订阅统一收口beforeunload 一次性释放 —— 与 floating-shared 的
// unsubTheme 模式对齐,避免散落的未释放 listener 在长会话里堆积。
const unsubs: Array<() => void> = [
unsubTheme,
window.api.on.storageChanged((s) => {
enabled = s.alarms.filter((a) => a.enabled);
// enabled 集合变了就必须让"下一个"重算pickCache 命中 250ms 内的旧结果
// 会让被删除/禁用的那条继续显示最长 250ms给用户"按了删除还在"的错觉。
pickCache = null;
paint();
}),
// clockTick 每秒推一次;用它在每秒驱动 paint不需要再加 setInterval
window.api.on.clockTick(() => paint())
];
(async () => {
const s = await window.api.clock.getState();
enabled = s.alarms.filter((a: Alarm) => a.enabled);
paint();
})();
window.addEventListener('beforeunload', () => {
cancelled = true;
unsubs.forEach(fn => fn());
});

View File

@@ -0,0 +1,109 @@
/* Floating alarms — 240×150 card showing next alarm */
html, body {
margin: 0; padding: 0;
background: transparent;
color: var(--text);
font-family: Inter, system-ui, sans-serif;
overflow: hidden; height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
position: relative;
width: 240px; height: 150px;
margin: 0;
background: var(--bg-elev-2);
border-radius: 14px;
border: 1px solid var(--line);
box-shadow: var(--shadow-2);
cursor: grab;
user-select: none;
-webkit-app-region: drag;
display: flex; flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 4px;
padding: 16px 18px;
/* 空态:柔和边 */
transition: border-color 160ms;
}
#root[data-state="enabled"] {
border-color: var(--accent-line);
box-shadow: var(--shadow-2), 0 0 0 1px var(--accent-line), 0 0 12px var(--accent-glow);
}
#root[data-state="empty"] {
border-color: var(--line);
/* 与 enabled 态有所区分,避免视觉空洞 */
opacity: 0.9;
}
#root:active { cursor: grabbing; }
#close {
position: absolute;
top: 8px; right: 8px;
width: 18px; height: 18px;
display: grid; place-items: center;
background: var(--line);
border: 0;
border-radius: 50%;
color: var(--text-dim);
cursor: pointer;
-webkit-app-region: no-drag;
opacity: 0;
transition: opacity 120ms, background 120ms, color 120ms, transform 80ms;
}
#close svg { width: 9px; height: 9px; display: block; }
#root:hover #close { opacity: 1; }
#close:hover { background: var(--warn-soft); color: var(--warn); }
#close:active { transform: scale(0.9); }
#root:focus-within #close { opacity: 1; }
#close:focus-visible { opacity: 1; outline: 2px solid var(--accent-line); outline-offset: 2px; }
@media (hover: none) {
#close { opacity: 1; }
}
#label {
font-size: 10px;
font-weight: 700;
color: var(--text-faint);
letter-spacing: 0.16em;
text-transform: uppercase;
transition: color 120ms;
}
#root[data-state="enabled"] #label { color: var(--accent); }
#time {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 36px;
color: var(--text);
font-feature-settings: "tnum" 1;
font-variant-numeric: tabular-nums;
letter-spacing: -1px;
line-height: 1;
/* 时间区占据视觉重心 */
margin-top: 2px;
}
#countdown {
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
margin-top: 2px;
font-variant-numeric: tabular-nums;
}
#root[data-state="enabled"] #countdown { color: var(--accent-2); }
#title {
font-size: 11px;
font-weight: 500;
color: var(--text-faint);
letter-spacing: 0.04em;
/* 单行省略,避免长标签撑爆卡片 */
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}

View File

@@ -0,0 +1,167 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<!-- 浮窗没显式 CSP 时由 BrowserWindow 兜底——但内嵌的 Audio 仍需要
media-src 'self' file: 才能播 file:// 铃声。兜底写法与主窗对齐。 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' file:" />
<title>时钟</title>
<style>
/* === 浮窗专用 CSS 变量(6 皮肤,每皮肤只声明浮窗实际用到的 16 个) ===
原先加载 global.css + themes.css 合并产物 ~75 KB,实际只用 ~5%。
内联后 0 round-trip,且不依赖主进程预创建后的额外资源请求。 */
:root {
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
--line: rgba(255, 255, 255, 0.06);
--text: #F2F2F5;
--text-dim: #B4B4BC;
--text-faint: #9090A0;
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.14);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.20);
--accent-2: #7C8AE5;
--warn: #D9695E;
--warn-soft: rgba(217, 105, 94, 0.12);
--warn-line: rgba(217, 105, 94, 0.40);
--ok: #4CB782;
--ok-soft: rgba(76, 183, 130, 0.14);
--ok-line: rgba(76, 183, 130, 0.40);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
}
:root[data-skin="brass-dark"] {
--bg-elev-2: #2B231B;
--bg-elev-3: #38291E;
--line: rgba(255, 235, 200, 0.08);
--text: #F1E4C7;
--text-dim: #C9B998;
--text-faint: #8A7E66;
--accent: #D9B25F;
--accent-soft: rgba(217, 178, 95, 0.14);
--accent-line: rgba(217, 178, 95, 0.45);
--accent-glow: rgba(217, 178, 95, 0.22);
--accent-2: #E8C77A;
--warn: #C97050;
--warn-soft: rgba(201, 112, 80, 0.14);
--warn-line: rgba(201, 112, 80, 0.45);
--ok: #7BA47B;
--ok-soft: rgba(123, 164, 123, 0.14);
--ok-line: rgba(123, 164, 123, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
}
:root[data-skin="solarized-dark"] {
--bg-elev-2: #0A4150;
--bg-elev-3: #134E5E;
--line: rgba(147, 161, 161, 0.20);
--text: #EEE8D5;
--text-dim: #93A1A1;
--text-faint: #657B83;
--accent: #268BD2;
--accent-soft: rgba(38, 139, 210, 0.18);
--accent-line: rgba(38, 139, 210, 0.45);
--accent-glow: rgba(38, 139, 210, 0.22);
--accent-2: #B58900;
--warn: #DC322F;
--warn-soft: rgba(220, 50, 47, 0.18);
--warn-line: rgba(220, 50, 47, 0.45);
--ok: #859900;
--ok-soft: rgba(133, 153, 0, 0.18);
--ok-line: rgba(133, 153, 0, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
}
:root[data-skin="nord"] {
--bg-elev-2: #434C5E;
--bg-elev-3: #4C566A;
--line: rgba(229, 233, 240, 0.10);
--text: #ECEFF4;
--text-dim: #D8DEE9;
--text-faint: #88909D;
--accent: #88C0D0;
--accent-soft: rgba(136, 192, 208, 0.18);
--accent-line: rgba(136, 192, 208, 0.45);
--accent-glow: rgba(136, 192, 208, 0.22);
--accent-2: #81A1C1;
--warn: #BF616A;
--warn-soft: rgba(191, 97, 106, 0.18);
--warn-line: rgba(191, 97, 106, 0.45);
--ok: #A3BE8C;
--ok-soft: rgba(163, 190, 140, 0.18);
--ok-line: rgba(163, 190, 140, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
}
:root[data-skin="catppuccin"] {
--bg-elev-2: #313244;
--bg-elev-3: #45475A;
--line: rgba(205, 214, 244, 0.10);
--text: #CDD6F4;
--text-dim: #BAC2DE;
--text-faint: #6C7086;
--accent: #CBA6F7;
--accent-soft: rgba(203, 166, 247, 0.18);
--accent-line: rgba(203, 166, 247, 0.45);
--accent-glow: rgba(203, 166, 247, 0.22);
--accent-2: #F38BA8;
--warn: #F38BA8;
--warn-soft: rgba(243, 139, 168, 0.18);
--warn-line: rgba(243, 139, 168, 0.45);
--ok: #A6E3A1;
--ok-soft: rgba(166, 227, 161, 0.18);
--ok-line: rgba(166, 227, 161, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
}
:root[data-skin="vercel-light"] {
--bg-elev-2: #F4F4F5;
--bg-elev-3: #E4E4E7;
--line: rgba(20, 22, 38, 0.08);
--text: #18181B;
--text-dim: #50525A;
--text-faint: #A1A1AA;
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.10);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.18);
--accent-2: #6E78C7;
--warn: #DC2626;
--warn-soft: rgba(220, 38, 38, 0.10);
--warn-line: rgba(220, 38, 38, 0.40);
--ok: #16A34A;
--ok-soft: rgba(22, 163, 74, 0.10);
--ok-line: rgba(22, 163, 74, 0.40);
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
}
/* 兼容旧 [data-theme] 写法(无 [data-skin])—— 落到 linear-dark 默认值 */
:root[data-theme]:not([data-skin]) {
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
}
/* === Minimal reset: 浮窗专用精简版 ===
background: transparent 是关键 —— 否则覆盖不住桌布,卡片会变黑底。
font-family 不在这里写:会被各 floating-*/styles.css html, body 块覆盖,
styles.css font-family 才是当前生效的版本如果要加中文回退,应该改
styles.css 而不是这里(否则被 styles.css 覆盖失效) */
html, body {
margin: 0; padding: 0;
background: transparent;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden; height: 100vh;
}
* { box-sizing: border-box; }
</style>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root">
<button id="close" title="关闭浮窗" aria-label="关闭浮窗">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
<div id="time"><span class="hh">--</span><span class="colon">:</span><span class="mm">--</span><span class="colon">:</span><span class="sec">--</span></div>
<div id="date"></div>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
// 浮窗时钟:紧凑卡片,只读显示本地时间 + 日期。
// - 拖拽走 OS#root 的 -webkit-app-region: drag
// - 关闭路径:×按钮 / 双击 / Esc / 右键菜单 / 触屏长按
import { t } from '../i18n';
import { attachFloatingWindowBehavior, subscribeFloatingTheme } from '../floating-shared';
import { localDateKey } from '../secondTick';
const root = document.getElementById('root')!;
const time = document.querySelector<HTMLElement>('#time')!;
const timeHH = time.querySelector<HTMLElement>('.hh')!;
const timeMM = time.querySelector<HTMLElement>('.mm')!;
const timeSEC = time.querySelector<HTMLElement>('.sec')!;
const date = document.querySelector<HTMLElement>('#date')!;
// === 时间显示:复用主页的"colon+sec 弱化"视觉但尺寸小一档36px vs 56px ===
function paintTime(now: string): void {
const parts = now.split(':');
timeHH.textContent = parts[0] ?? '--';
timeMM.textContent = parts[1] ?? '--';
timeSEC.textContent = parts[2] ?? '--';
}
// === 日期星期X M月D日。每分钟对一次避免日切时没刷新。i18n 走 t() 以便后续扩展 locale。 ===
const weekdayStr = t('floating.clock.weekdayChars'); // '日一二三四五六'
function paintDate(d: Date): void {
const w = weekdayStr[d.getDay()] ?? '';
const dateText = t('floating.clock.dateFmt', String(w), String(d.getMonth() + 1), String(d.getDate()));
if (date.textContent !== dateText) date.textContent = dateText;
}
// 首屏:用本地时间填值(避免 IPC 异步返回前的占位符闪烁),推送到了再覆盖。
const now0 = new Date();
paintTime(
`${String(now0.getHours()).padStart(2, '0')}:${String(now0.getMinutes()).padStart(2, '0')}:${String(now0.getSeconds()).padStart(2, '0')}`
);
paintDate(now0);
// 共用:右键菜单 / 双击关闭 / 触屏长按 / Esc / × 按钮
attachFloatingWindowBehavior(root, 'clock');
// 主题订阅(与 pomodoro / countdown / alarms 共用同一份实现)
const unsubTheme = subscribeFloatingTheme();
// 所有 on.* 订阅统一收口beforeunload 一次性释放 —— 与浮窗模板对齐。
const unsubs: Array<() => void> = [
unsubTheme,
window.api.on.clockTick((now) => {
paintTime(now);
})
];
// 跨天后立即刷新(最坏延迟 1 分钟 → 立刻。lastDayKey 在挂载时即初始化,
// 避免首个 30s tick 总是无意义地"发现变化 → 重画"。
let lastDayKey = localDateKey(now0);
const dayTimer = window.setInterval(() => {
const key = localDateKey();
if (key !== lastDayKey) {
lastDayKey = key;
paintDate(new Date());
}
}, 30_000);
window.addEventListener('beforeunload', () => {
window.clearInterval(dayTimer);
unsubs.forEach(fn => fn());
});

View File

@@ -0,0 +1,93 @@
/* Floating clock — 200×80 read-only card */
html, body {
margin: 0; padding: 0;
background: transparent;
color: var(--text);
font-family: Inter, system-ui, sans-serif;
overflow: hidden; height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
position: relative;
width: 200px; height: 80px;
margin: 0;
background: var(--bg-elev-2);
border-radius: 14px;
border: 1px solid var(--line);
box-shadow: var(--shadow-2);
cursor: grab;
user-select: none;
-webkit-app-region: drag;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 4px;
}
#root:active { cursor: grabbing; }
#close {
position: absolute;
top: 6px; right: 6px;
width: 18px; height: 18px;
display: grid; place-items: center;
background: var(--line);
border: 0;
border-radius: 50%;
color: var(--text-dim);
cursor: pointer;
-webkit-app-region: no-drag;
opacity: 0;
transition: opacity 120ms, background 120ms, color 120ms, transform 80ms;
}
#close svg { width: 9px; height: 9px; display: block; }
#root:hover #close { opacity: 1; }
#close:hover { background: var(--warn-soft); color: var(--warn); }
#close:active { transform: scale(0.9); }
#root:focus-within #close { opacity: 1; }
#close:focus-visible { opacity: 1; outline: 2px solid var(--accent-line); outline-offset: 2px; }
@media (hover: none) {
#close { opacity: 1; }
}
#time {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 36px;
color: var(--text);
font-feature-settings: "tnum" 1;
font-variant-numeric: tabular-nums;
letter-spacing: -1px;
line-height: 1;
}
#time .colon {
color: var(--accent);
padding: 0 1px;
font-weight: 700;
animation: clockColonBlink 1s steps(1, end) infinite;
}
@keyframes clockColonBlink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0.45; }
}
#time .sec {
color: var(--text-dim);
font-size: 22px;
padding-left: 2px;
font-weight: 500;
letter-spacing: 0;
display: inline-block;
}
#date {
font-size: 11px;
font-weight: 500;
color: var(--text-faint);
letter-spacing: 0.04em;
/* 不抢主时间的视觉重心 */
margin-top: 2px;
}
@media (prefers-reduced-motion: reduce) {
#time .colon { animation: none; }
}

View File

@@ -0,0 +1,165 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' file:" />
<title>倒计时</title>
<style>
/* === 浮窗专用 CSS 变量(6 皮肤,每皮肤只声明浮窗实际用到的 16 个) ===
原先加载 global.css + themes.css 合并产物 ~75 KB,实际只用 ~5%。
内联后 0 round-trip,且不依赖主进程预创建后的额外资源请求。 */
:root {
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
--line: rgba(255, 255, 255, 0.06);
--text: #F2F2F5;
--text-dim: #B4B4BC;
--text-faint: #9090A0;
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.14);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.20);
--accent-2: #7C8AE5;
--warn: #D9695E;
--warn-soft: rgba(217, 105, 94, 0.12);
--warn-line: rgba(217, 105, 94, 0.40);
--ok: #4CB782;
--ok-soft: rgba(76, 183, 130, 0.14);
--ok-line: rgba(76, 183, 130, 0.40);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
}
:root[data-skin="brass-dark"] {
--bg-elev-2: #2B231B;
--bg-elev-3: #38291E;
--line: rgba(255, 235, 200, 0.08);
--text: #F1E4C7;
--text-dim: #C9B998;
--text-faint: #8A7E66;
--accent: #D9B25F;
--accent-soft: rgba(217, 178, 95, 0.14);
--accent-line: rgba(217, 178, 95, 0.45);
--accent-glow: rgba(217, 178, 95, 0.22);
--accent-2: #E8C77A;
--warn: #C97050;
--warn-soft: rgba(201, 112, 80, 0.14);
--warn-line: rgba(201, 112, 80, 0.45);
--ok: #7BA47B;
--ok-soft: rgba(123, 164, 123, 0.14);
--ok-line: rgba(123, 164, 123, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
}
:root[data-skin="solarized-dark"] {
--bg-elev-2: #0A4150;
--bg-elev-3: #134E5E;
--line: rgba(147, 161, 161, 0.20);
--text: #EEE8D5;
--text-dim: #93A1A1;
--text-faint: #657B83;
--accent: #268BD2;
--accent-soft: rgba(38, 139, 210, 0.18);
--accent-line: rgba(38, 139, 210, 0.45);
--accent-glow: rgba(38, 139, 210, 0.22);
--accent-2: #B58900;
--warn: #DC322F;
--warn-soft: rgba(220, 50, 47, 0.18);
--warn-line: rgba(220, 50, 47, 0.45);
--ok: #859900;
--ok-soft: rgba(133, 153, 0, 0.18);
--ok-line: rgba(133, 153, 0, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
}
:root[data-skin="nord"] {
--bg-elev-2: #434C5E;
--bg-elev-3: #4C566A;
--line: rgba(229, 233, 240, 0.10);
--text: #ECEFF4;
--text-dim: #D8DEE9;
--text-faint: #88909D;
--accent: #88C0D0;
--accent-soft: rgba(136, 192, 208, 0.18);
--accent-line: rgba(136, 192, 208, 0.45);
--accent-glow: rgba(136, 192, 208, 0.22);
--accent-2: #81A1C1;
--warn: #BF616A;
--warn-soft: rgba(191, 97, 106, 0.18);
--warn-line: rgba(191, 97, 106, 0.45);
--ok: #A3BE8C;
--ok-soft: rgba(163, 190, 140, 0.18);
--ok-line: rgba(163, 190, 140, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
}
:root[data-skin="catppuccin"] {
--bg-elev-2: #313244;
--bg-elev-3: #45475A;
--line: rgba(205, 214, 244, 0.10);
--text: #CDD6F4;
--text-dim: #BAC2DE;
--text-faint: #6C7086;
--accent: #CBA6F7;
--accent-soft: rgba(203, 166, 247, 0.18);
--accent-line: rgba(203, 166, 247, 0.45);
--accent-glow: rgba(203, 166, 247, 0.22);
--accent-2: #F38BA8;
--warn: #F38BA8;
--warn-soft: rgba(243, 139, 168, 0.18);
--warn-line: rgba(243, 139, 168, 0.45);
--ok: #A6E3A1;
--ok-soft: rgba(166, 227, 161, 0.18);
--ok-line: rgba(166, 227, 161, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
}
:root[data-skin="vercel-light"] {
--bg-elev-2: #F4F4F5;
--bg-elev-3: #E4E4E7;
--line: rgba(20, 22, 38, 0.08);
--text: #18181B;
--text-dim: #50525A;
--text-faint: #A1A1AA;
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.10);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.18);
--accent-2: #6E78C7;
--warn: #DC2626;
--warn-soft: rgba(220, 38, 38, 0.10);
--warn-line: rgba(220, 38, 38, 0.40);
--ok: #16A34A;
--ok-soft: rgba(22, 163, 74, 0.10);
--ok-line: rgba(22, 163, 74, 0.40);
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
}
/* 兼容旧 [data-theme] 写法(无 [data-skin])—— 落到 linear-dark 默认值 */
:root[data-theme]:not([data-skin]) {
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
}
/* === Minimal reset: 浮窗专用精简版 ===
background: transparent 是关键 —— 否则覆盖不住桌布,卡片会变黑底。
font-family 不在这里写:会被各 floating-*/styles.css html, body 块覆盖,
styles.css font-family 才是当前生效的版本如果要加中文回退,应该改
styles.css 而不是这里(否则被 styles.css 覆盖失效) */
html, body {
margin: 0; padding: 0;
background: transparent;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden; height: 100vh;
}
* { box-sizing: border-box; }
</style>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root">
<button id="close" title="关闭浮窗" aria-label="关闭浮窗">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
<div id="time"><span class="hh">--</span><span class="colon">:</span><span class="mm">--</span><span class="colon">:</span><span class="sec">--</span></div>
<div id="state">空闲</div>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,104 @@
// 浮窗倒计时200×120 紧凑卡片,展示剩余时间 + 状态。
// - 拖拽走 OS#root 的 -webkit-app-region: drag
// - 关闭路径与浮窗番茄钟一致:×/双击/Esc/右键/触屏长按
// - 状态色边框表达 running / paused / idle / fired沿用主页 CountdownPanel 的 data-state 约定)
import { formatHMS } from '../../shared/time';
import { t } from '../i18n';
import { attachFloatingWindowBehavior, subscribeFloatingTheme } from '../floating-shared';
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
import type { CountdownActiveState } from '../../shared/types';
const root = document.getElementById('root')!;
const time = document.querySelector<HTMLElement>('#time')!;
const timeHH = time.querySelector<HTMLElement>('.hh')!;
const timeMM = time.querySelector<HTMLElement>('.mm')!;
const timeSEC = time.querySelector<HTMLElement>('.sec')!;
const state = document.querySelector<HTMLElement>('#state')!;
let active: CountdownActiveState | null = null;
let lastDurationMs = 5 * 60 * 1000;
// IIFE 拿一次 getState() 之后就不再被盖回去 — 理由同 floating-pomodoro
// countdownPhaseChanged 推过来的 active 是权威新值getState() 返回的
// state.current 有 300ms 防抖 + writeAtomic 延迟,期间拿到旧值会反向覆盖。
let bootstrapped = false;
// 睡到读数下一次改变即可(改造前是 250ms 轮询 = 4Hz
// idle显示上次设定时长与 paused 下读数不会自己变nextAt 返回 null不装定时器。
const ticker = createSecondTicker(paint, () =>
!active || active.paused ? null : nextCeilBoundary(active.endsAt)
);
// 同步首屏 paint:active=null 走 idle 分支,显示 lastDurationMs 默认 5:00 / 状态"空闲"
// 避免 IPC roundtrip 期间用户看到 HTML 默认占位符 "__:__:__"。
paint();
// 共用:右键菜单 / 双击关闭 / 触屏长按 / Esc / × 按钮
attachFloatingWindowBehavior(root, 'countdown');
// 主题订阅(与 pomodoro / clock / alarms 共用同一份实现)
const unsubTheme = subscribeFloatingTheme();
// 所有 on.* 订阅统一收口beforeunload 一次性释放 —— 与浮窗模板对齐。
const unsubs: Array<() => void> = [
unsubTheme,
window.api.on.countdownPhaseChanged((next) => {
bootstrapped = true;
active = next;
paint();
ticker.restart();
}),
window.api.on.storageChanged((s) => {
// lastDurationMs 由 storageChanged 推送(输入框改值时),保持同步
lastDurationMs = s.countdown.lastDurationMs;
paint();
})
];
(async () => {
const s = await window.api.clock.getState();
if (bootstrapped) return;
active = s.countdown.active;
lastDurationMs = s.countdown.lastDurationMs;
paint();
ticker.restart();
})();
function paint(): void {
if (!active) {
// idle显示上次设定时长
paintTime(Math.ceil(lastDurationMs / 1000));
state.textContent = t('floating.cd.stateIdle');
root.dataset.state = 'idle';
return;
}
const remain = active.paused
? active.remainingMs
: Math.max(0, active.endsAt - Date.now());
paintTime(Math.ceil(remain / 1000));
if (active.paused) {
state.textContent = t('floating.cd.statePaused');
root.dataset.state = 'paused';
} else {
state.textContent = t('floating.cd.stateRunning');
root.dataset.state = 'running';
}
}
// 直接写到预生成 span 上,避免 innerHTML 反复解析。
// 冒号单独挂在 <span class="colon"> 上以染 accent;这里只刷 hh / mm / sec 的纯数字。
function paintTime(totalSec: number): void {
const formatted = formatHMS(Math.max(0, totalSec));
const parts = formatted.split(':');
const hh = parts[0] ?? '--';
const mm = parts[1] ?? '--';
const ss = parts[2] ?? '--';
timeHH.textContent = hh;
timeMM.textContent = mm;
timeSEC.textContent = ss;
}
window.addEventListener('beforeunload', () => {
ticker.stop();
unsubs.forEach(fn => fn());
});

View File

@@ -0,0 +1,113 @@
/* Floating countdown — 200×120 card showing remaining time + state */
html, body {
margin: 0; padding: 0;
background: transparent;
color: var(--text);
font-family: Inter, system-ui, sans-serif;
overflow: hidden; height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
position: relative;
width: 200px; height: 120px;
margin: 0;
background: var(--bg-elev-2);
border-radius: 14px;
border: 1px solid var(--line);
box-shadow: var(--shadow-2);
cursor: grab;
user-select: none;
-webkit-app-region: drag;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 6px;
/* 状态色边框 + 柔光表达 running/paused/idle */
transition: border-color 160ms, box-shadow 160ms;
}
#root[data-state="running"] {
border-color: var(--accent-line);
box-shadow: var(--shadow-2), 0 0 0 1px var(--accent-line), 0 0 14px var(--accent-glow);
}
#root[data-state="paused"] {
border-color: rgba(217, 105, 94, 0.50);
box-shadow: var(--shadow-2), 0 0 0 1px rgba(217, 105, 94, 0.50);
}
/* idle 不加额外色,保持空态克制 */
#root[data-state="idle"] {
border-color: var(--line);
opacity: 0.92;
}
#root:active { cursor: grabbing; }
#close {
position: absolute;
top: 6px; right: 6px;
width: 18px; height: 18px;
display: grid; place-items: center;
background: var(--line);
border: 0;
border-radius: 50%;
color: var(--text-dim);
cursor: pointer;
-webkit-app-region: no-drag;
opacity: 0;
transition: opacity 120ms, background 120ms, color 120ms, transform 80ms;
}
#close svg { width: 9px; height: 9px; display: block; }
#root:hover #close { opacity: 1; }
#close:hover { background: var(--warn-soft); color: var(--warn); }
#close:active { transform: scale(0.9); }
#root:focus-within #close { opacity: 1; }
#close:focus-visible { opacity: 1; outline: 2px solid var(--accent-line); outline-offset: 2px; }
@media (hover: none) {
#close { opacity: 1; }
}
#time {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 36px;
color: var(--text);
font-feature-settings: "tnum" 1;
font-variant-numeric: tabular-nums;
letter-spacing: -1px;
line-height: 1;
}
#time .colon {
color: var(--accent);
padding: 0 1px;
font-weight: 700;
}
#root[data-state="paused"] #time .colon { color: var(--warn); }
#time .sec {
color: var(--text-dim);
font-size: 22px;
font-weight: 500;
letter-spacing: 0;
display: inline-block;
}
#state {
font-size: 10px;
font-weight: 700;
color: var(--text-dim);
letter-spacing: 0.16em;
text-transform: uppercase;
padding: 3px 10px;
border-radius: 99px;
border: 1px solid var(--line);
background: rgba(14, 14, 18, 0.4);
transition: color 120ms, border-color 120ms, background 120ms;
}
#root[data-state="running"] #state {
color: var(--accent);
border-color: var(--accent-line);
background: var(--accent-soft);
}
#root[data-state="paused"] #state {
color: var(--warn);
border-color: rgba(217, 105, 94, 0.40);
background: var(--warn-soft);
}

View File

@@ -0,0 +1,179 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' file:" />
<title>番茄钟</title>
<style>
/* === 浮窗专用 CSS 变量(6 皮肤,每皮肤只声明浮窗实际用到的 16 个) ===
原先加载 global.css + themes.css 合并产物 ~75 KB,实际只用 ~5%。
内联后 0 round-trip,且不依赖主进程预创建后的额外资源请求。 */
:root {
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
--line: rgba(255, 255, 255, 0.06);
--text: #F2F2F5;
--text-dim: #B4B4BC;
--text-faint: #9090A0;
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.14);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.20);
--accent-2: #7C8AE5;
--warn: #D9695E;
--warn-soft: rgba(217, 105, 94, 0.12);
--warn-line: rgba(217, 105, 94, 0.40);
--ok: #4CB782;
--ok-soft: rgba(76, 183, 130, 0.14);
--ok-line: rgba(76, 183, 130, 0.40);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
}
:root[data-skin="brass-dark"] {
--bg-elev-2: #2B231B;
--bg-elev-3: #38291E;
--line: rgba(255, 235, 200, 0.08);
--text: #F1E4C7;
--text-dim: #C9B998;
--text-faint: #8A7E66;
--accent: #D9B25F;
--accent-soft: rgba(217, 178, 95, 0.14);
--accent-line: rgba(217, 178, 95, 0.45);
--accent-glow: rgba(217, 178, 95, 0.22);
--accent-2: #E8C77A;
--warn: #C97050;
--warn-soft: rgba(201, 112, 80, 0.14);
--warn-line: rgba(201, 112, 80, 0.45);
--ok: #7BA47B;
--ok-soft: rgba(123, 164, 123, 0.14);
--ok-line: rgba(123, 164, 123, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
}
:root[data-skin="solarized-dark"] {
--bg-elev-2: #0A4150;
--bg-elev-3: #134E5E;
--line: rgba(147, 161, 161, 0.20);
--text: #EEE8D5;
--text-dim: #93A1A1;
--text-faint: #657B83;
--accent: #268BD2;
--accent-soft: rgba(38, 139, 210, 0.18);
--accent-line: rgba(38, 139, 210, 0.45);
--accent-glow: rgba(38, 139, 210, 0.22);
--accent-2: #B58900;
--warn: #DC322F;
--warn-soft: rgba(220, 50, 47, 0.18);
--warn-line: rgba(220, 50, 47, 0.45);
--ok: #859900;
--ok-soft: rgba(133, 153, 0, 0.18);
--ok-line: rgba(133, 153, 0, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
}
:root[data-skin="nord"] {
--bg-elev-2: #434C5E;
--bg-elev-3: #4C566A;
--line: rgba(229, 233, 240, 0.10);
--text: #ECEFF4;
--text-dim: #D8DEE9;
--text-faint: #88909D;
--accent: #88C0D0;
--accent-soft: rgba(136, 192, 208, 0.18);
--accent-line: rgba(136, 192, 208, 0.45);
--accent-glow: rgba(136, 192, 208, 0.22);
--accent-2: #81A1C1;
--warn: #BF616A;
--warn-soft: rgba(191, 97, 106, 0.18);
--warn-line: rgba(191, 97, 106, 0.45);
--ok: #A3BE8C;
--ok-soft: rgba(163, 190, 140, 0.18);
--ok-line: rgba(163, 190, 140, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
}
:root[data-skin="catppuccin"] {
--bg-elev-2: #313244;
--bg-elev-3: #45475A;
--line: rgba(205, 214, 244, 0.10);
--text: #CDD6F4;
--text-dim: #BAC2DE;
--text-faint: #6C7086;
--accent: #CBA6F7;
--accent-soft: rgba(203, 166, 247, 0.18);
--accent-line: rgba(203, 166, 247, 0.45);
--accent-glow: rgba(203, 166, 247, 0.22);
--accent-2: #F38BA8;
--warn: #F38BA8;
--warn-soft: rgba(243, 139, 168, 0.18);
--warn-line: rgba(243, 139, 168, 0.45);
--ok: #A6E3A1;
--ok-soft: rgba(166, 227, 161, 0.18);
--ok-line: rgba(166, 227, 161, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
}
:root[data-skin="vercel-light"] {
--bg-elev-2: #F4F4F5;
--bg-elev-3: #E4E4E7;
--line: rgba(20, 22, 38, 0.08);
--text: #18181B;
--text-dim: #50525A;
--text-faint: #A1A1AA;
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.10);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.18);
--accent-2: #6E78C7;
--warn: #DC2626;
--warn-soft: rgba(220, 38, 38, 0.10);
--warn-line: rgba(220, 38, 38, 0.40);
--ok: #16A34A;
--ok-soft: rgba(22, 163, 74, 0.10);
--ok-line: rgba(22, 163, 74, 0.40);
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
}
/* 兼容旧 [data-theme] 写法(无 [data-skin])—— 落到 linear-dark 默认值 */
:root[data-theme]:not([data-skin]) {
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
}
/* === Minimal reset: 浮窗专用精简版 ===
background: transparent 是关键 —— 否则覆盖不住桌布,卡片会变黑底。
font-family 不在这里写:会被各 floating-*/styles.css html, body 块覆盖,
styles.css font-family 才是当前生效的版本如果要加中文回退,应该改
styles.css 而不是这里(否则被 styles.css 覆盖失效) */
html, body {
margin: 0; padding: 0;
background: transparent;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden; height: 100vh;
}
* { box-sizing: border-box; }
</style>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root">
<button id="close" title="关闭浮窗" aria-label="关闭浮窗">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
<div id="phase" aria-hidden="true"></div>
<div id="time"><span class="mm">--</span><span class="colon">:</span><span class="sec">--</span></div>
<div id="actions" role="group" aria-label="番茄钟控制">
<button id="playPause" class="control" title="开始 / 暂停" aria-label="开始或暂停番茄钟" type="button">
<svg id="playPauseIcon" viewBox="0 0 16 16" fill="currentColor" stroke="none" aria-hidden="true" data-icon="play">
<path class="icon-play" d="M4.5 3.5v9l8-4.5z" />
<path class="icon-pause" d="M5 3.5h2v9H5zM9 3.5h2v9H9z" />
</svg>
</button>
<button id="reset" class="control" title="重置" aria-label="重置番茄钟" type="button">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 8a5 5 0 1 0 1.5-3.5L3 6" />
<path d="M3 3v3h3" />
</svg>
</button>
</div>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,139 @@
import { formatHMS } from '../../shared/time';
import { t } from '../i18n';
import { attachFloatingWindowBehavior, subscribeFloatingTheme } from '../floating-shared';
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
import type { PomodoroPhase } from '../../shared/types';
const root = document.getElementById('root')!;
const time = document.querySelector<HTMLElement>('#time')!;
const timeMM = time.querySelector<HTMLElement>('.mm')!;
const timeSEC = time.querySelector<HTMLElement>('.sec')!;
const phase = document.querySelector<HTMLElement>('#phase')!;
const actions = document.querySelector<HTMLElement>('#actions')!;
const playPauseBtn = document.querySelector<HTMLButtonElement>('#playPause')!;
const playPauseIcon = document.querySelector<SVGElement>('#playPauseIcon')!;
const resetBtn = document.querySelector<HTMLButtonElement>('#reset')!;
// 拖拽完全交给 OS —— #root 的 -webkit-app-region: drag 已经接管。
// 早期这里还绑了一份 mousedown/mousemove 会让窗口跑 2 倍距离OS 移一次,
// 我们又算 dx/dy 移一次),已移除。
// === 状态显示 ===
type ActiveLite = { phase: PomodoroPhase; endsAt: number; paused: boolean; remainingMs: number };
let active: ActiveLite | null = null;
// pomodoroPhaseChanged 已经覆盖过一次之后,初始 getState() 不能再盖回去:
// getState() 返回的是 state.current,而 state.current 要等 300ms 防抖 + writeAtomic
// 之后才更新(参 AppStateContainer.update)。期间 getState() 仍是旧值,会覆盖掉
// 真正的 onPhaseChange IPC 推过来的新 active —— 表现为"已经收到变化,又被旧值拉回"。
// 正常场景下二者最终一致,只在启动瞬间连点 start / 重启留有 active 时才暴露。
let bootstrapped = false;
// 睡到读数下一次改变即可(改造前是 250ms 轮询 = 4Hz
// 暂停 / 未开始时读数不会自己变nextAt 返回 null完全不装定时器。
const ticker = createSecondTicker(tick, () =>
!active || active.paused ? null : nextCeilBoundary(active.endsAt)
);
(async () => {
const s = await window.api.clock.getState();
if (bootstrapped) return;
active = s.pomodoro.active ?? null;
tick();
ticker.restart();
})();
// 共用:右键菜单 / 双击关闭 / 触屏长按 / Esc / × 按钮(行为见 floating-shared.ts)
attachFloatingWindowBehavior(root, 'pomodoro');
// 主题订阅:浮窗没有完整 store 子集,单独拉 settings.themeId 同步到 <html data-skin>。
// 切换主题后立即刷新,无延迟;unsub 在 beforeunload 一并释放。
const unsubTheme = subscribeFloatingTheme();
// 所有 on.* 订阅统一收口beforeunload 一次性释放 —— 与浮窗模板对齐。
const unsubs: Array<() => void> = [
unsubTheme,
window.api.on.pomodoroPhaseChanged((next) => {
bootstrapped = true;
active = next;
tick();
ticker.restart();
})
];
window.addEventListener('beforeunload', () => {
ticker.stop();
unsubs.forEach(fn => fn());
});
// === 控制按钮play / pause / reset ===
// 一个按钮按当前状态切换:
// - 没有 active → 显示"播放",点击 start()
// - active && !paused → 显示"暂停",点击 pause()
// - active && paused → 显示"播放",点击 resume()
function onPlayPauseClick(e: MouseEvent): void {
e.stopPropagation();
if (!active) {
void window.api.pomodoro.start();
} else if (active.paused) {
void window.api.pomodoro.resume();
} else {
void window.api.pomodoro.pause();
}
}
playPauseBtn.addEventListener('click', onPlayPauseClick);
playPauseBtn.addEventListener('mousedown', (e) => e.stopPropagation());
playPauseBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
// reset只有存在 active 时才有点击意义;按钮也只在有 active 时可见
resetBtn.addEventListener('click', (e) => {
e.stopPropagation();
void window.api.pomodoro.reset();
});
resetBtn.addEventListener('mousedown', (e) => e.stopPropagation());
resetBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
function tick(): void {
if (!active) {
timeMM.textContent = '--';
timeSEC.textContent = '--';
phase.textContent = t('pomo.idle');
root.dataset.state = 'idle';
playPauseIcon.dataset.icon = 'play';
playPauseBtn.title = t('pomo.start');
playPauseBtn.setAttribute('aria-label', t('pomo.start'));
// idle 时只显播放;重置藏在 hover 之外(反正没东西可重置)
actions.dataset.state = 'idle';
return;
}
const remain = active.paused
? active.remainingMs
: Math.max(0, active.endsAt - Date.now());
const formatted = formatHMS(Math.ceil(remain / 1000));
// 番茄钟最长 25 分钟HH 永远为 00 — 卡片里只显示 MM:SS秒弱化。
const parts = formatted.split(':');
const mm = parts[1] ?? '--';
const ss = parts[2] ?? '--';
timeMM.textContent = mm;
timeSEC.textContent = ss;
const label = ({ focus: t('pomo.focus'), shortBreak: t('pomo.shortBreak'), longBreak: t('pomo.longBreak') } as Record<PomodoroPhase, string>)[active.phase] ?? t('pomo.idle');
phase.textContent = active.paused ? `${label} · ${t('pomo.paused')}` : label;
// data-state 决定浮窗整体配色paused 警告break 绿running 紫
if (active.paused) root.dataset.state = 'paused';
else if (active.phase !== 'focus') root.dataset.state = 'break';
else root.dataset.state = 'running';
// 播放/暂停图标互换paused → 播放running → 暂停。两条 path 都在 DOM 里,
// 用 data-icon 切显示,避免每次 innerHTML 重新解析(SVG 在 Chromium 里 innerHTML 很贵)。
if (active.paused) {
playPauseIcon.dataset.icon = 'play';
playPauseBtn.title = t('pomo.resume');
playPauseBtn.setAttribute('aria-label', t('pomo.resume'));
actions.dataset.state = 'paused';
} else {
playPauseIcon.dataset.icon = 'pause';
playPauseBtn.title = t('pomo.pause');
playPauseBtn.setAttribute('aria-label', t('pomo.pause'));
actions.dataset.state = 'running';
}
}

View File

@@ -0,0 +1,137 @@
/* Floating pomodoro — compact card (120×120) */
html, body {
margin: 0; padding: 0;
background: transparent;
color: var(--text);
font-family: Inter, system-ui, sans-serif;
overflow: hidden; height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
position: relative; width: 120px; height: 120px;
margin: 0;
background: var(--bg-elev-2);
border-radius: 16px;
border: 1px solid var(--line);
box-shadow: var(--shadow-2);
cursor: grab;
user-select: none;
-webkit-app-region: drag;
display: grid; place-items: center;
transition: border-color 160ms, box-shadow 160ms;
}
/* 状态仅通过 1px 边框色 + 柔光表达,去掉重动画与重渐变 */
#root[data-state="running"] {
border-color: var(--accent-line);
box-shadow: var(--shadow-2), 0 0 0 1px var(--accent-line), 0 0 14px var(--accent-glow);
}
#root[data-state="break"] {
border-color: rgba(76, 183, 130, 0.50);
box-shadow: var(--shadow-2), 0 0 0 1px rgba(76, 183, 130, 0.50);
}
#root[data-state="paused"] {
border-color: rgba(217, 105, 94, 0.50);
box-shadow: var(--shadow-2), 0 0 0 1px rgba(217, 105, 94, 0.50);
}
#root:active { cursor: grabbing; }
#close {
position: absolute;
top: 6px; right: 6px;
width: 18px; height: 18px;
display: grid; place-items: center;
background: var(--line);
border: 0;
border-radius: 50%;
color: var(--text-dim);
cursor: pointer;
-webkit-app-region: no-drag;
opacity: 0;
transition: opacity 120ms, background 120ms, color 120ms, transform 80ms;
}
#close svg { width: 9px; height: 9px; display: block; }
#root:hover #close { opacity: 1; }
#close:hover { background: var(--warn-soft); color: var(--warn); }
#close:active { transform: scale(0.9); }
#root:focus-within #close { opacity: 1; }
#close:focus-visible { opacity: 1; outline: 2px solid var(--accent-line); outline-offset: 2px; }
/* 触屏hover 不可达,常驻显示 × */
@media (hover: none) {
#close { opacity: 1; }
}
/* 控制按钮(播放/暂停 + 重置):底栏居中排布,与 × 一致的 hover 显隐。
* idle 状态下重置无意义单独弱化hover 时一起显出,方便停掉意外启动的。 */
#actions {
position: absolute;
bottom: 8px; left: 50%;
transform: translateX(-50%);
display: flex; gap: 6px;
opacity: 0;
transition: opacity 120ms;
pointer-events: none;
}
#root:hover #actions,
#root:focus-within #actions {
opacity: 1;
pointer-events: auto;
}
@media (hover: none) {
#actions { opacity: 1; pointer-events: auto; }
}
.control {
width: 22px; height: 22px;
display: grid; place-items: center;
background: var(--line);
border: 0;
border-radius: 50%;
color: var(--text-dim);
cursor: pointer;
-webkit-app-region: no-drag;
transition: background 120ms, color 120ms, transform 80ms;
}
.control svg { width: 11px; height: 11px; display: block; }
/* play / pause 两个 path 同时存在,按 #playPauseIcon[data-icon] 切显隐,避免 innerHTML 反复解析 */
#playPauseIcon .icon-play,
#playPauseIcon .icon-pause { display: none; }
#playPauseIcon[data-icon="play"] .icon-play { display: inline; }
#playPauseIcon[data-icon="pause"] .icon-pause { display: inline; }
.control:hover { background: var(--bg-elev-3); color: var(--text); }
.control:active { transform: scale(0.9); }
.control:focus-visible {
outline: 2px solid var(--accent-line);
outline-offset: 2px;
}
/* 暂停色用警告色编码,与 #root[data-state="paused"] 边框色保持一致 */
#root[data-state="paused"] #playPause { color: var(--warn); }
.control:focus-visible { opacity: 1; }
#time {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 24px;
color: var(--text);
font-feature-settings: "tnum" 1;
letter-spacing: -0.5px;
font-variant-numeric: tabular-nums;
line-height: 1;
}
#time .colon { color: var(--accent); padding: 0 1px; font-weight: 700; }
#root[data-state="break"] #time .colon { color: var(--ok); }
#time .sec { color: var(--text-dim); font-size: 16px; padding-left: 1px; }
#phase {
position: absolute;
top: 10px; left: 12px;
color: var(--text-dim);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
transition: color 120ms;
}
#root[data-state="break"] #phase { color: var(--ok); }
#root[data-state="paused"] #phase { color: var(--warn); }
#root[data-state="running"] #phase { color: var(--accent); }

View File

@@ -0,0 +1,114 @@
// src/renderer/floating-shared.ts
// 4 个浮窗(pomodoro / clock / alarms / countdown)的输入行为是逐字复制的:
// 鼠标右键 / 双击 / 触屏长按 / Esc 键 / × 按钮,只有 IPC kind 不同。
// 这里把它们收成单一函数,各浮窗 main.ts 调用一次即可,不再复制 ~50 行事件处理。
//
// 调用方如果有自己的按钮(如 pomodoro 的 play/pause/reset),在自己按钮上
// stopPropagation 即可,与这里的 #root 级监听器不冲突。
import type { AppState, FloatingKind } from '../shared/types';
import { DEFAULT_SKIN_ID, modeForSkin, normalizeSkinId } from '../shared/theme';
/**
* 把 AppState.settings.themeId 应用到 <html data-skin>,并写 data-theme。
* 浮窗没有完整 store 子集,直接读写 DOM 即可;themes.css 用 [data-skin] /
* [data-theme] 选择器覆盖变量。
*
* - 首屏调用:传入 getState() 的快照,让 CSS 变量先填上,避免用户切皮肤后
* 浮窗还停在默认配色。
* - 持续同步:监听 storageChanged,在 settings.themeId 变化时再次调用,主题
* "立即生效"路径与主窗走同一条 IPC 推送链路,所以一次订阅就足够。
*/
export function syncFloatingSkin(state: AppState): void {
const id = normalizeSkinId(state.settings.themeId ?? DEFAULT_SKIN_ID);
document.documentElement.dataset.skin = id;
document.documentElement.dataset.theme = modeForSkin(id);
}
/**
* 同步浮窗主题 + 订阅主题变化。返回的函数用于 beforeunload 时解绑 listener。
* 调用方式:`const unsubTheme = subscribeFloatingTheme(); ... window.addEventListener('beforeunload', unsubTheme);`
*/
export function subscribeFloatingTheme(): () => void {
// 首屏:拉一次 state 把当前主题写到 <html>,免得看着像 Linear 暗色过几秒才切。
void window.api.clock.getState().then((s) => syncFloatingSkin(s));
// 之后settings.push 走 storageChanged,主题变更会一并过来。
return window.api.on.storageChanged((s) => syncFloatingSkin(s));
}
export function attachFloatingWindowBehavior(root: HTMLElement, kind: FloatingKind): () => void {
const api = window.api.windows;
const onContextMenu = (e: MouseEvent): void => {
e.preventDefault();
void api.showFloatingContextMenu(kind);
};
const onDblClick = (e: MouseEvent): void => {
e.preventDefault();
void api.closeFloating(kind);
};
// 触屏长按contextmenu 在触屏上不触发,用 setTimeout ~500ms 模拟。
let pressTimer: number | null = null;
let pressStartX = 0;
let pressStartY = 0;
const onPointerDown = (e: PointerEvent): void => {
if (e.pointerType !== 'touch') return;
pressStartX = e.clientX;
pressStartY = e.clientY;
pressTimer = window.setTimeout(() => {
pressTimer = null;
void api.showFloatingContextMenu(kind);
}, 500);
};
const cancelPress = (e: PointerEvent): void => {
if (e.pointerType !== 'touch') return;
// 移动超过 10px 视为"拖动而非长按"
if (pressTimer !== null && (Math.abs(e.clientX - pressStartX) > 10 || Math.abs(e.clientY - pressStartY) > 10)) {
window.clearTimeout(pressTimer);
pressTimer = null;
}
};
const onPointerMove = (e: PointerEvent): void => { cancelPress(e); };
const onPointerUp = (): void => {
if (pressTimer !== null) { window.clearTimeout(pressTimer); pressTimer = null; }
};
const onPointerCancel = (): void => {
if (pressTimer !== null) { window.clearTimeout(pressTimer); pressTimer = null; }
};
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') void api.closeFloating(kind);
};
root.addEventListener('contextmenu', onContextMenu);
root.addEventListener('dblclick', onDblClick);
root.addEventListener('pointerdown', onPointerDown);
root.addEventListener('pointermove', onPointerMove);
root.addEventListener('pointerup', onPointerUp);
root.addEventListener('pointercancel', onPointerCancel);
window.addEventListener('keydown', onKeyDown);
// × 按钮(可选)。stopPropagation 防止触发 #root 的 drag 起点。
const closeBtn = document.getElementById('close');
if (closeBtn) {
const onCloseClick = (e: MouseEvent): void => {
e.stopPropagation();
void api.closeFloating(kind);
};
const stop = (e: Event): void => { e.stopPropagation(); };
closeBtn.addEventListener('click', onCloseClick);
closeBtn.addEventListener('mousedown', stop);
closeBtn.addEventListener('pointerdown', stop);
}
return () => {
root.removeEventListener('contextmenu', onContextMenu);
root.removeEventListener('dblclick', onDblClick);
root.removeEventListener('pointerdown', onPointerDown);
root.removeEventListener('pointermove', onPointerMove);
root.removeEventListener('pointerup', onPointerUp);
root.removeEventListener('pointercancel', onPointerCancel);
window.removeEventListener('keydown', onKeyDown);
};
}

171
src/renderer/i18n.ts Normal file
View File

@@ -0,0 +1,171 @@
// 极简文案:只有中文一种语言;保持 t() 调用点的 API 不变以便后续扩展。
// 缺失键时静默回落到键名本身(开发期容易发现)。
// 简单 {0} {1} 占位符替换。
const dict = {
// 顶栏 / 侧栏
'app.brand': 'Time',
'nav.clock': '时钟',
'nav.alarms': '闹钟',
'nav.pomodoro': '番茄钟',
'nav.countdown': '倒计时',
/** TabStrip 整条 tablist 的无障碍名称 */
'nav.mainTabs': '主导航',
'topbar.pinOn': '已置顶',
'topbar.pinOff': '置顶',
'topbar.settings': '设置',
'topbar.floatingGroup': '浮窗小卡片',
'topbar.floatingPomodoro': '浮窗番茄钟',
'topbar.floatingClock': '浮窗时钟',
'topbar.floatingAlarms': '浮窗闹钟',
'topbar.floatingCountdown': '浮窗倒计时',
// 时钟面板
'clock.subtitle': '当前时间',
'clock.nextNone': '暂无闹钟',
/** {0}=时刻 {1}=剩余分钟 {2}=标签 */
'clock.nextFmtMin': '下一个 {0} · {1} 分钟后 · {2}',
/** {0}=时刻 {1}=小时 {2}=分钟 {3}=标签 */
'clock.nextFmtHm': '下一个 {0} · {1}h {2}m 后 · {3}',
'clock.statEnabledSub': '闹钟',
'clock.pomoStateIdle': '未开始',
// 闹钟
'alarms.title': '闹钟',
'alarms.empty': '暂无闹钟',
'alarms.emptyHint': '点击右上"+ 新建闹钟"开始',
'alarms.newBtn': '+ 新建闹钟',
'alarms.cancelNew': '取消新建',
'alarms.edit': '编辑',
'alarms.delete': '删除',
'alarms.confirmDelete': '确认删除',
'alarms.toggle': '启用',
'alarms.toggleAria': '启用闹钟',
'alarms.countFmt': '{0} / {1} 启用',
'alarms.untitled': '(无标题)',
'alarms.repeatOnce': '一次',
'alarms.repeatDaily': '每日',
// 编辑器
'editor.newTitle': '新建闹钟',
'editor.editTitle': '编辑闹钟',
'editor.label': '标签',
'editor.labelPh': '例如:晨间起床',
'editor.time': '时间',
'editor.save': '保存',
'editor.saving': '保存中…',
'editor.cancel': '取消',
'editor.deleteBtn': '删除',
'editor.timeInvalid': '时间格式错误',
'editor.behaviorNotify': '系统通知',
'editor.behaviorSilent': '静默',
'editor.saveFailedPrefix': '保存失败:',
'editor.deleteFailedPrefix': '删除失败:',
'editor.settingsLoading': '设置尚未加载,请稍后重试。',
'editor.defaultLabel': '闹钟',
'editor.ariaSetTime': '设定闹钟时间',
'editor.ariaHours': '小时',
'editor.ariaMinutes': '分钟',
'editor.ariaSeconds': '秒',
'editor.srHours': '小时',
'editor.srMinutes': '分钟',
'editor.srSeconds': '秒',
'editor.repeat': '每日重复',
'editor.repeatOnce': '一次',
'editor.repeatDaily': '每日',
// 番茄钟
'pomo.idle': '未开始',
'pomo.focus': '专注',
'pomo.shortBreak': '短休',
'pomo.longBreak': '长休',
'pomo.paused': '已暂停',
'pomo.subFocus': '保持专注',
'pomo.subBreak': '休息一下',
'pomo.start': '开始',
'pomo.pause': '暂停',
'pomo.resume': '继续',
'pomo.skip': '跳过',
'pomo.reset': '重置',
'pomo.today': '今日完成',
'pomo.todayUnit': '个番茄',
'pomo.footHint': '长休出现在每完成 4 轮之后',
'pomo.roundPill': '第 {0} 轮',
'pomo.ariaDisplay': '番茄钟显示',
'pomo.ariaIdle': '番茄钟未开始,下一阶段 {0} {1} 分钟',
'pomo.ariaRemain': '{0}{1},剩余 {2}',
'pomo.ariaPaused': '(已暂停)',
// 倒计时
'countdown.title': '倒计时',
'countdown.stateIdle': '空闲',
'countdown.stateRunning': '运行中',
'countdown.statePaused': '暂停',
'countdown.ariaSetDuration': '设定时长',
'countdown.ariaHours': '小时',
'countdown.ariaMinutes': '分钟',
'countdown.ariaSeconds': '秒',
'countdown.ariaDisplay': '倒计时显示',
'countdown.srHours': '小时',
'countdown.srMinutes': '分钟',
'countdown.srSeconds': '秒',
'countdown.startBtn': '开始',
'countdown.pauseBtn': '暂停',
'countdown.resumeBtn': '继续',
'countdown.resetBtn': '重置',
'countdown.doneBtn': '完成',
'countdown.timeUp': '时间到',
'countdown.setN': '设定 {0}',
'countdown.inputEmpty': '请输入时间',
'countdown.ariaPausedFmt': '倒计时已暂停,剩余 {0}',
'countdown.ariaRunningFmt': '倒计时运行中,剩余 {0}',
'countdown.fmtHoursMinutes': '{0} 小时 {1} 分',
'countdown.fmtMinutes': '{0} 分钟',
'countdown.fmtSeconds': '{0} 秒',
// 浮窗番茄钟的文案沿用 pomo.* 共享一份id 全套启停/重置与主页一致);
// 后续若浮窗需要独立文案(如"开始 / 暂停"按钮 title 这类)再拆 floating.pomo.*。
// 浮窗闹钟
'floating.alarms.next': '下一个',
'floating.alarms.empty': '暂无闹钟',
'floating.alarms.tagDefault': '闹钟',
'floating.alarms.titleFmt': '{0} · 共 {1} 个启用',
'floating.alarms.titleOnly': '{0}',
'floating.alarms.countN': '共 {0} 个启用',
'floating.alarms.diffSec': '{0} 秒后',
'floating.alarms.diffMin': '{0} 分后',
'floating.alarms.diffHM': '{0}h {1}m 后',
'floating.alarms.diffH': '{0}h 后',
// 浮窗倒计时
'floating.cd.stateIdle': '空闲',
'floating.cd.stateRunning': '运行中',
'floating.cd.statePaused': '已暂停',
// 浮窗时钟
'floating.clock.dateFmt': '星期{0} {1}月{2}日',
'floating.clock.weekdayChars': '日一二三四五六',
// 设置面板
'settings.title': '设置',
'settings.sub': '个性化外观',
'settings.skinTitle': '外观',
'settings.skinDesc': '点击卡片即可切换整套配色,立即生效。',
'settings.windowTitle': '窗口',
'settings.alwaysOnTop': '窗口置顶',
'settings.alwaysOnTopDesc': '让主窗口悬浮在其它窗口之上',
'settings.modeDark': '暗色',
'settings.modeLight': '亮色',
'settings.aboutTitle': '关于',
'settings.aboutDeveloper': '开发者:关济寰',
'settings.aboutDeveloperAria': '访问关济寰的个人网站',
'settings.aboutOpenFailed': '无法在系统浏览器中打开链接',
'settings.foot': '所有设置都会自动保存到本地',
// 通用
'common.cancel': '取消',
'toast.close': '关闭',
// 错误toast
'error.unhandled': '未捕获的错误:{0}',
'error.unknown': '未知',
'error.script': '脚本错误:{0}',
} as const;
type Dict = typeof dict;
export function t(key: keyof Dict, ...args: unknown[]): string {
let s = (dict as Record<string, string>)[key as string] ?? (key as string);
if (args.length > 0) {
s = s.replace(/\{(\d+)\}/g, (_m, idx) => String(args[Number(idx)] ?? ''));
}
return s;
}

14
src/renderer/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>Time</title>
<link rel="stylesheet" href="./styles/global.css" />
<link rel="stylesheet" href="./styles/themes.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

32
src/renderer/main.ts Normal file
View File

@@ -0,0 +1,32 @@
import { mount } from './App';
// 顶层 catchmount() await bootstrap() 时如果 IPC 失败 / settings 数据损坏,
// 没有 catch 就只是一个永远挂起的 promise + 一片空 #app用户看到 0 反馈。
// 提前挂 unhandledrejection / error 处理 + toast host再 mount
// 这样 mount 失败时也能弹 toast。
import { mountToastHost, toast } from './components/Toast';
import { t } from './i18n';
// 提前挂一个 hostmount() 内部也会调一次,但因 ToastBus.attach 幂等,
// 这里永远不会出现"两份 host"的视觉重复。挂 document.body 是为了
// mount() 失败前(pre-mount)也能弹 toast。
mountToastHost(document.body);
window.addEventListener('error', (e) => {
if (!e.message) return;
toast(t('error.script', e.message), 'error');
});
window.addEventListener('unhandledrejection', (e) => {
const reason = e.reason instanceof Error ? e.reason.message : String(e.reason);
toast(t('error.unhandled', reason || t('error.unknown')), 'error');
});
try {
await mount(document.getElementById('app')!);
} catch (err) {
// mount() 抛错bootstrap 拒绝 / settings 损坏 / 任一渲染组件崩。
// 全局 handler 与 toast host 在 try 之前已挂好,这里直接弹。
const msg = err instanceof Error ? err.message : String(err);
toast(t('error.unhandled', msg || t('error.unknown')), 'error');
// 控制台留完整 stack方便调试
console.error('[renderer] mount failed:', err);
}

View File

@@ -0,0 +1,79 @@
// src/renderer/secondTick.ts
// 统一的「每显示秒重画一次」调度器,替代各面板自己的 rAF / setTimeout(250) 循环。
//
// 为什么不是简单的 setInterval(1000)
// 重新导出浮窗需要的日期工具,避免浮窗再绕到 shared/constants 拉(仅模块内部使用)。
export { localDateKey } from '../shared/constants';
// 倒计时显示的是 Math.ceil((endsAt - now) / 1000),它的变化时刻锁在 endsAt 的相位上,
// 跟墙上时钟的整秒边界没有关系。用整秒对齐的定时器驱动会产生 0~999ms 的固定滞后
// (最坏情况下 00:00 会比铃声晚将近一秒才出现)——比它要替换掉的 240ms 闸门更差。
// 所以这里睡到「显示值下一次真正改变的那一刻」:唤醒次数是 1 次/秒,而重画延迟
// 反而从 ≤240ms 降到 ≤1 帧。
//
// 为什么必须处理 visibilitychange
// rAF 在窗口隐藏时天然停摆、显示时立即恢复setTimeout 不是 —— 隐藏页面的定时器会被
// Chromium 对齐到 1s约 5 分钟后进一步降到 1 次/分钟。若不管,从托盘恢复主窗时会看到
// 最长一分钟的陈旧读数。这里隐藏时直接停表、显示时立刻重画并重排,
// 既复刻了 rAF 原本的语义,也比它更省(隐藏期间零唤醒)。
export interface SecondTicker {
/** 状态变化后重排下一次唤醒endsAt 变了、开始/暂停/重置了)。 */
restart(): void;
stop(): void;
}
/**
* @param draw 重画回调
* @param nextAt 返回「显示值下一次改变」的绝对时间戳;返回 null 表示当前状态下
* 显示值不会自己变化idle / paused此时不装定时器。
*/
export function createSecondTicker(draw: () => void, nextAt: () => number | null): SecondTicker {
let timer = 0;
const clear = (): void => {
if (timer) { window.clearTimeout(timer); timer = 0; }
};
const schedule = (): void => {
clear();
if (document.hidden) return; // 隐藏期间不唤醒show 时由 onVisibility 补画
const at = nextAt();
if (at === null) return;
// clamp 到 [4, 1000]:即使 nextAt() 的相位算错,最坏也退化成 1Hz 轮询,
// 不会比「每秒刷新一次」更差。
const delay = Math.min(1000, Math.max(4, at - Date.now()));
timer = window.setTimeout(() => {
timer = 0;
draw();
schedule();
}, delay);
};
const onVisibility = (): void => {
if (document.hidden) clear();
else { draw(); schedule(); }
};
document.addEventListener('visibilitychange', onVisibility);
schedule();
return {
restart: schedule,
stop: () => {
clear();
document.removeEventListener('visibilitychange', onVisibility);
}
};
}
/**
* 倒数显示 Math.ceil(remain/1000) 下一次变化的绝对时刻。
* 例endsAt = now+5500 → 当前显示 6将在 now+504 变成 5。
* +4ms 让 ceil 稳定跨过边界,避免在临界点上重复算出同一个时刻。
*/
export function nextCeilBoundary(endsAt: number, now: number = Date.now()): number {
const remain = Math.max(0, endsAt - now);
const shown = Math.ceil(remain / 1000);
return shown <= 0 ? now + 1000 : endsAt - (shown - 1) * 1000 + 4;
}

183
src/renderer/store.ts Normal file
View File

@@ -0,0 +1,183 @@
import { DEFAULT_REMINDER, DEFAULT_SETTINGS, localDateKey } from '../shared/constants';
import { DEFAULT_SKIN_ID, normalizeSkinId } from '../shared/theme';
import { formatLocalHMS } from '../shared/time';
import type {
Alarm, AppState, PomodoroActiveState, PomodoroHistory,
Route, Settings, CountdownActiveState, FloatingKind
} from '../shared/types';
// 上面 localDateKey 仅在首屏初始化 clockStr / PomodoroPanel 的 dayTimer 兜底用,
// 本文件不再单独维护 dayKey atom。
type Listener<T> = (v: T) => void;
function withDefaults(s: AppState['settings'] | null): Settings {
// 兼容老存档settings 可能没有 reminder 字段(甚至 reminder.behaviors 缺省),
// 用 DEFAULT_REMINDER 兜底。早期实现里 `behaviors: [...base.reminder.behaviors]`
// 没判 undefined → 一旦 reminder 缺 behaviors 就会抛 TypeError把 bootstrap 拖崩
// 进而让用户看到空窗(详见 App.ts mount 的兜底说明)。
const base = s ?? DEFAULT_SETTINGS;
const baseBehaviors = Array.isArray(base.reminder?.behaviors)
? [...base.reminder.behaviors]
: [...DEFAULT_REMINDER.behaviors];
return {
...DEFAULT_SETTINGS,
...base,
reminder: base.reminder
? { ...DEFAULT_REMINDER, ...base.reminder, behaviors: baseBehaviors }
: { ...DEFAULT_REMINDER, behaviors: baseBehaviors }
};
}
// 浅比较:只看顶层 key。对原子 store(对象类型的 settings/alarms/pomodoro/history 等)
// 足以避免一次 update 触发 N 个无变化的 listener。对嵌套对象(数组里的某一项)则不适用,
// 此时调用方需要自己保证引用变更。
function shallowEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
const ka = Object.keys(a as object);
const kb = Object.keys(b as object);
if (ka.length !== kb.length) return false;
for (const k of ka) {
if (!Object.is((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;
}
return true;
}
/** 结构相等递归。state 全是 JSON 形状(对象 / 数组 / 原始值),所以直接递归即可。
*
* 为什么需要它:主进程每次 storageChanged 推的都是 JSON.parse(JSON.stringify(...))
* 出来的全新深拷贝,数组和嵌套对象的引用必然与上一份不同 —— 浅比较在 alarms[0]
* 这一层就判定"变了",于是**改一次音量也会让整个闹钟列表 innerHTML 重建、
* 每行重绑监听、皮肤网格与行为胶囊一起重画**。按值比较后,只有内容真的变了才通知。 */
function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
const aArr = Array.isArray(a);
if (aArr !== Array.isArray(b)) return false;
if (aArr) {
const x = a as unknown[];
const y = b as unknown[];
if (x.length !== y.length) return false;
return x.every((v, i) => deepEqual(v, y[i]));
}
const ka = Object.keys(a as object);
const kb = Object.keys(b as object);
if (ka.length !== kb.length) return false;
return ka.every(k =>
Object.prototype.hasOwnProperty.call(b, k) &&
deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])
);
}
class Atom<T> {
private value: T;
private listeners = new Set<Listener<T>>();
/** eq 默认浅比较;承载 IPC 深拷贝快照的原子传 deepEqual见下方 store 定义)。 */
constructor(initial: T, private eq: (a: unknown, b: unknown) => boolean = shallowEqual) { this.value = initial; }
get(): T { return this.value; }
set(v: T): void {
// 同值不广播 → 避免拖音量条 / 高频 input 时所有 listener 跟着重画。
if (this.eq(this.value, v)) return;
this.value = v;
this.listeners.forEach(l => l(v));
}
subscribe(l: Listener<T>): () => void {
this.listeners.add(l);
return () => { this.listeners.delete(l); };
}
}
export const store = {
// 以下原子的值都来自主进程的全量深拷贝快照,必须按值比较(见 deepEqual 注释)。
alarms: new Atom<Alarm[]>([], deepEqual),
settings: new Atom<Settings | null>(null, deepEqual),
pomodoro: new Atom<{
active: PomodoroActiveState | null;
rounds: number;
history: PomodoroHistory;
}>({
active: null, rounds: 0, history: { date: '', count: 0 }
}, deepEqual),
countdown: new Atom<{
active: CountdownActiveState | null;
lastDurationMs: number;
}>({
active: null, lastDurationMs: 5 * 60 * 1000
}, deepEqual),
// clockStr 初值用本地时间(避免渲染进程启动到首个 IPC clockTick 之间闪现 "--:--:--")。
// formatLocalHMS 始终返回合法 "HH:MM:SS",若 Date 构造异常(极少见,如系统时间被破坏)
// 会得到 "NaN:NaN:NaN" —— 这种情况下 ClockPanel.ts 的 isValidHHMMSS 会兜底再 new Date()。
clockStr: new Atom<string>(formatLocalHMS(new Date())),
route: new Atom<Route>('clock'),
topbar: new Atom<{ alwaysOnTop: boolean }>({ alwaysOnTop: false }),
/** 当前生效的皮肤 id —— themes.css 据此决定变量覆盖 */
skin: new Atom<string>(DEFAULT_SKIN_ID),
/** 4 个浮窗小卡片的开关状态topbar 按钮 on/off 指示 + aria-pressed 同步)。 */
floatingOpen: new Atom<Record<FloatingKind, boolean>>({
pomodoro: false, clock: false, alarms: false, countdown: false
})
};
// 与 ClockPanel.ts 中相同的格式校验,集中放在这里便于后续抽到 shared/time.ts 复用。
// 保留 ClockPanel.ts 内的本地副本以避免组件渲染时再走一次跨模块依赖。
const HHMMSS_RE = /^\d{2}:\d{2}:\d{2}$/;
function isValidHHMMSS(s: unknown): s is string {
return typeof s === 'string' && HHMMSS_RE.test(s);
}
export async function bootstrap(): Promise<() => void> {
const s = await window.api.clock.getState();
store.alarms.set(s.alarms);
store.settings.set(withDefaults(s.settings));
store.pomodoro.set({
active: s.pomodoro.active,
rounds: s.pomodoro.completedRounds,
history: s.pomodoro.history
});
store.countdown.set({
active: s.countdown.active,
lastDurationMs: s.countdown.lastDurationMs
});
store.topbar.set({ alwaysOnTop: s.settings.alwaysOnTop === true });
// 旧存档(无 themeId 字段)会回退 DEFAULT_SETTINGS.themeId如果是未知值
// normalizeSkinId 再兜一次,避免把 CSS 选择器写成 data-skin="garbage"。
const skinId = normalizeSkinId((s.settings as Settings).themeId ?? DEFAULT_SETTINGS.themeId);
store.skin.set(skinId);
// 把所有 IPC 订阅的 unsubscribe 收集起来 → HMR / 测试场景下二次 bootstrap 时
// 旧 listener 不会被叠成 N 份。返回的 dispose 函数给调用方在需要时调一次。
const unsubs: Array<() => void> = [
// IPC clockTick 校验:主进程正常发 "HH:MM:SS",但 webContents.send 在 channel disposed
// 期间可能让渲染端拿到 undefined/空串(理论不该发生,但 1Hz 跑一整天积累起来就会暴露)。
// 非法值直接丢弃,保留当前 store 值;ClockPanel 的 isValidHHMMSS 是最后一道兜底。
window.api.on.clockTick((t) => {
if (isValidHHMMSS(t)) store.clockStr.set(t);
}),
window.api.on.storageChanged((s) => {
store.alarms.set(s.alarms); store.settings.set(withDefaults(s.settings));
store.pomodoro.set({
active: s.pomodoro.active,
rounds: s.pomodoro.completedRounds,
history: s.pomodoro.history
});
store.countdown.set({
active: s.countdown.active,
lastDurationMs: s.countdown.lastDurationMs
});
store.topbar.set({ alwaysOnTop: s.settings.alwaysOnTop === true });
store.skin.set(normalizeSkinId((s.settings as Settings).themeId ?? DEFAULT_SETTINGS.themeId));
}),
window.api.on.alarmFired((_a) => { /* 主进程已广播 storageChangedUI 自行重画 */ }),
window.api.on.pomodoroPhaseChanged((active, rounds) => {
const cur = store.pomodoro.get();
store.pomodoro.set({ active, rounds, history: cur.history });
}),
window.api.on.countdownPhaseChanged((active) => {
store.countdown.set({ active, lastDurationMs: store.countdown.get().lastDurationMs });
}),
window.api.on.topbarChanged((t) => { store.topbar.set(t); }),
window.api.on.floatingWindowsChanged((open) => { store.floatingOpen.set(open); })
];
return () => { for (const u of unsubs) u(); };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
/* =========================================================================
Skin variants — 渲染端换肤
-------------------------------------------------------------------------
一切变量在 :root 默认值里给出global.css 提供 Linear Dark 默认皮肤)。
这里每个皮肤用 :root[data-skin="<id>"] 选择器覆盖整套 CSS 变量。
选择任一皮肤即生效;同时设置 <html data-skin data-theme>。
========================================================================= */
/* === Linear Dark默认皮肤也用作 fallback === */
:root[data-skin="linear-dark"],
:root:not([data-skin]) {
--bg: #08080B;
--bg-elev-1: #0E0E12;
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
--sidebar-bg: #0A0A0C;
--line: rgba(255, 255, 255, 0.06);
--line-strong: rgba(255, 255, 255, 0.10);
--line-soft: rgba(255, 255, 255, 0.04);
--text: #F2F2F5;
--text-dim: #B4B4BC;
--text-faint: #9090A0;
--accent: #5E6AD2;
--accent-hover: #6E78E0;
--accent-soft: rgba(94, 106, 210, 0.14);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.20);
--accent-2: #7C8AE5;
--accent-2-soft:rgba(124, 138, 229, 0.14);
--accent-2-line:rgba(124, 138, 229, 0.40);
--on-accent: #FFFFFF;
--warn: #D9695E;
--warn-soft: rgba(217, 105, 94, 0.12);
--warn-line: rgba(217, 105, 94, 0.40);
--ok: #4CB782;
--ok-soft: rgba(76, 183, 130, 0.14);
--ok-line: rgba(76, 183, 130, 0.40);
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.45);
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.55);
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
/* === Brass Dark暗金 · 复古仪表盘) === */
:root[data-skin="brass-dark"] {
--bg: #1A1612;
--bg-elev-1: #221C16;
--bg-elev-2: #2B231B;
--bg-elev-3: #38291E;
--sidebar-bg: #18130F;
--line: rgba(255, 235, 200, 0.08);
--line-strong: rgba(255, 235, 200, 0.14);
--line-soft: rgba(255, 235, 200, 0.04);
--text: #F1E4C7;
--text-dim: #C9B998;
--text-faint: #8A7E66;
--accent: #D9B25F;
--accent-hover: #E5C078;
--accent-soft: rgba(217, 178, 95, 0.14);
--accent-line: rgba(217, 178, 95, 0.45);
--accent-glow: rgba(217, 178, 95, 0.22);
--accent-2: #E8C77A;
--accent-2-soft:rgba(232, 199, 122, 0.14);
--accent-2-line:rgba(232, 199, 122, 0.45);
--on-accent: #1A1612;
--warn: #C97050;
--warn-soft: rgba(201, 112, 80, 0.14);
--warn-line: rgba(201, 112, 80, 0.45);
--ok: #7BA47B;
--ok-soft: rgba(123, 164, 123, 0.14);
--ok-line: rgba(123, 164, 123, 0.45);
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.55);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.60);
--inset-1: inset 0 1px 0 rgba(255, 220, 160, 0.06);
}
/* === Solarized Dark === */
:root[data-skin="solarized-dark"] {
--bg: #002B36;
--bg-elev-1: #073642;
--bg-elev-2: #0A4150;
--bg-elev-3: #134E5E;
--sidebar-bg: #001F27;
--line: rgba(147, 161, 161, 0.20);
--line-strong: rgba(147, 161, 161, 0.32);
--line-soft: rgba(147, 161, 161, 0.10);
--text: #EEE8D5;
--text-dim: #93A1A1;
--text-faint: #657B83;
--accent: #268BD2;
--accent-hover: #3A9BE0;
--accent-soft: rgba(38, 139, 210, 0.18);
--accent-line: rgba(38, 139, 210, 0.45);
--accent-glow: rgba(38, 139, 210, 0.22);
--accent-2: #B58900;
--accent-2-soft:rgba(181, 137, 0, 0.18);
--accent-2-line:rgba(181, 137, 0, 0.45);
--on-accent: #FDF6E3;
--warn: #DC322F;
--warn-soft: rgba(220, 50, 47, 0.18);
--warn-line: rgba(220, 50, 47, 0.45);
--ok: #859900;
--ok-soft: rgba(133, 153, 0, 0.18);
--ok-line: rgba(133, 153, 0, 0.45);
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.60);
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
/* === Nord北极冷色 === */
:root[data-skin="nord"] {
--bg: #2E3440;
--bg-elev-1: #3B4252;
--bg-elev-2: #434C5E;
--bg-elev-3: #4C566A;
--sidebar-bg: #2A303D;
--line: rgba(229, 233, 240, 0.10);
--line-strong: rgba(229, 233, 240, 0.18);
--line-soft: rgba(229, 233, 240, 0.05);
--text: #ECEFF4;
--text-dim: #D8DEE9;
--text-faint: #88909D;
--accent: #88C0D0;
--accent-hover: #9FCDDB;
--accent-soft: rgba(136, 192, 208, 0.18);
--accent-line: rgba(136, 192, 208, 0.45);
--accent-glow: rgba(136, 192, 208, 0.22);
--accent-2: #81A1C1;
--accent-2-soft:rgba(129, 161, 193, 0.18);
--accent-2-line:rgba(129, 161, 193, 0.45);
--on-accent: #2E3440;
--warn: #BF616A;
--warn-soft: rgba(191, 97, 106, 0.18);
--warn-line: rgba(191, 97, 106, 0.45);
--ok: #A3BE8C;
--ok-soft: rgba(163, 190, 140, 0.18);
--ok-line: rgba(163, 190, 140, 0.45);
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.40);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.50);
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.60);
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* === Catppuccin Mocha === */
:root[data-skin="catppuccin"] {
--bg: #1E1E2E;
--bg-elev-1: #181825;
--bg-elev-2: #313244;
--bg-elev-3: #45475A;
--sidebar-bg: #181825;
--line: rgba(205, 214, 244, 0.10);
--line-strong: rgba(205, 214, 244, 0.18);
--line-soft: rgba(205, 214, 244, 0.05);
--text: #CDD6F4;
--text-dim: #BAC2DE;
--text-faint: #6C7086;
--accent: #CBA6F7;
--accent-hover: #D6B6FA;
--accent-soft: rgba(203, 166, 247, 0.18);
--accent-line: rgba(203, 166, 247, 0.45);
--accent-glow: rgba(203, 166, 247, 0.22);
--accent-2: #F38BA8;
--accent-2-soft:rgba(243, 139, 168, 0.18);
--accent-2-line:rgba(243, 139, 168, 0.45);
--on-accent: #1E1E2E;
--warn: #F38BA8;
--warn-soft: rgba(243, 139, 168, 0.18);
--warn-line: rgba(243, 139, 168, 0.45);
--ok: #A6E3A1;
--ok-soft: rgba(166, 227, 161, 0.18);
--ok-line: rgba(166, 227, 161, 0.45);
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.55);
--shadow-3: 0 24px 60px rgba(0, 0, 0, 0.65);
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
/* === Vercel / Geist Light === */
:root[data-skin="vercel-light"] {
--bg: #FFFFFF;
--bg-elev-1: #FAFAFA;
--bg-elev-2: #F4F4F5;
--bg-elev-3: #E4E4E7;
--sidebar-bg: #FAFAFA;
--line: rgba(20, 22, 38, 0.08);
--line-strong: rgba(20, 22, 38, 0.14);
--line-soft: rgba(20, 22, 38, 0.04);
--text: #18181B;
--text-dim: #50525A;
--text-faint: #A1A1AA;
--accent: #5E6AD2;
--accent-hover: #4F5BC0;
--accent-soft: rgba(94, 106, 210, 0.10);
--accent-line: rgba(94, 106, 210, 0.40);
--accent-glow: rgba(94, 106, 210, 0.18);
--accent-2: #6E78C7;
--accent-2-soft:rgba(110, 120, 199, 0.10);
--accent-2-line:rgba(110, 120, 199, 0.40);
--on-accent: #FFFFFF;
--warn: #DC2626;
--warn-soft: rgba(220, 38, 38, 0.10);
--warn-line: rgba(220, 38, 38, 0.40);
--ok: #16A34A;
--ok-soft: rgba(22, 163, 74, 0.10);
--ok-line: rgba(22, 163, 74, 0.40);
--shadow-1: 0 1px 2px rgba(20, 22, 38, 0.06);
--shadow-2: 0 12px 28px rgba(20, 22, 38, 0.10), 0 2px 4px rgba(20, 22, 38, 0.06);
--shadow-3: 0 24px 60px rgba(20, 22, 38, 0.14);
--inset-1: inset 0 1px 0 rgba(255, 255, 255, 0.60);
}
/* 兼容旧 data-theme/data-palette 写法:视作 linear-dark 兜底。
旧版本如果设置了 [data-theme="light"][data-palette="vscode"] 等无效组合,
也直接落到 linear-dark避免显示破图。 */
:root[data-theme]:not([data-skin]) {
--bg: #08080B;
--bg-elev-1: #0E0E12;
--bg-elev-2: #13141A;
--bg-elev-3: #1A1B22;
}

View File

@@ -0,0 +1,41 @@
// src/renderer/themeApply.ts
// 把 store 中的 skin 写入 <html data-skin data-theme>。CSS 端 themes.css 据此覆盖变量。
// 单点写入:把 dataset 的更新收敛到一对订阅,任何来源(bootstrap / setSkin /
// storageChanged)都不用单独写 DOM,store 一变,DOM 自动同步。
// 必须先 import './store' 然后才能用其副作用 —— 注意 import 顺序,单测里也是。
import { store } from './store';
import { modeForSkin, normalizeSkinId, type SkinId } from '../shared/theme';
// HMR 重入时HMR 反复调 mount() → applyTheme())必须保证只有一个全局订阅在写 DOM。
// 模块级变量持有最近一次安装的订阅的 dispose,HMR 重挂时先解绑旧的,再装新的。
let installed: (() => void) | null = null;
export function applyTheme(): void {
// 1. 第一次挂载 / 后续每次重挂:把当前值写到 DOM(避免订阅触发之前 UI 闪默认值)。
const id = store.skin.get();
document.documentElement.dataset.skin = id;
document.documentElement.dataset.theme = modeForSkin(id);
// 2. 注册响应式订阅:之后任何来源改变 store 都会自动同步到 DOM。
// data-theme 跟着 skin 派生 —— 与浮窗 floating-shared.ts 用同一份 modeForSkin,
// 保证主窗与浮窗在切换 Vercel Light 等亮色皮肤时同时进入 light 模式。
// 卸载上一轮(如果存在),避免 HMR 重复挂载造成订阅堆积、DOM 多次写入。
installed?.();
installed = store.skin.subscribe((next) => {
document.documentElement.dataset.skin = next;
document.documentElement.dataset.theme = modeForSkin(next);
});
}
/** 让 React/组件直接调用:原子更新 skin 同时落盘 settings(IPC 异步)。 */
export function setSkin(id: string): void {
const next: SkinId = normalizeSkinId(id);
store.skin.set(next);
// dataset.skin / dataset.theme 由 store.skin 订阅自动写入,这里不再手动写。
void window.api.settings.set({ themeId: next });
}
/** HMR dispose 钩子:解除 store.skin 订阅,避免重挂时叠加。 */
export function disposeTheme(): void {
installed?.();
installed = null;
}

6
src/renderer/types.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
import type { AppApi } from '../preload';
declare global {
interface Window { api: AppApi }
}

84
src/shared/constants.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { Settings, AppState, PomodoroPhase, ReminderConfig } from './types';
import { DEFAULT_SKIN_ID } from './theme';
export const STATE_VERSION = 1 as const;
/** 默认提醒配置:闹钟 / 番茄钟 / 倒计时 共用。当前唯一通道是系统通知。 */
export const DEFAULT_REMINDER: ReminderConfig = {
behaviors: ['notify']
};
export const DEFAULT_SETTINGS: Settings = {
alwaysOnTop: false,
themeId: DEFAULT_SKIN_ID,
reminder: { behaviors: [...DEFAULT_REMINDER.behaviors] }
};
export const POMODORO_DURATIONS_MS: Record<PomodoroPhase, number> = {
focus: 25 * 60 * 1000,
shortBreak: 5 * 60 * 1000,
longBreak: 15 * 60 * 1000
};
export const ROUNDS_BEFORE_LONG_BREAK = 4;
/** local date 字符串YYYY-MM-DD */
export function localDateKey(d: Date = new Date()): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export const DEFAULT_STATE: AppState = {
version: STATE_VERSION,
settings: DEFAULT_SETTINGS,
alarms: [],
pomodoro: { active: null, completedRounds: 0, history: { date: localDateKey(), count: 0 } },
countdown: { active: null, lastDurationMs: 5 * 60 * 1000 }
};
export const IPC = {
clockGetState: 'clock:getState',
alarmAdd: 'alarm:add',
alarmUpdate: 'alarm:update',
alarmDelete: 'alarm:delete',
pomoStart: 'pomodoro:start',
pomoPause: 'pomodoro:pause',
pomoResume: 'pomodoro:resume',
pomoSkip: 'pomodoro:skip',
pomoReset: 'pomodoro:reset',
settingsSet: 'settings:set',
winToggleFloating: 'window:toggleFloating',
winToggleAlwaysOnTop: 'window:toggleAlwaysOnTop',
winFloatingContextMenu: 'window:floatingContextMenu',
winCloseFloating: 'window:closeFloating',
winMinimize: 'window:minimize',
winToggleMaximize: 'window:toggleMaximize',
winClose: 'window:close',
cdStart: 'countdown:start',
cdPause: 'countdown:pause',
cdResume: 'countdown:resume',
cdReset: 'countdown:reset',
cdSetDuration: 'countdown:setDuration',
/**
* 用户在倒计时"完成"按钮点确认后,渲染端让主进程把仍在跑的提醒路径停掉。
* 当前唯一通道是系统通知cancelAll 已是空操作;保留 IPC 名字是为了不破坏
* 渲染端的调用点,未来若恢复声音/全屏闪也无需再改协议。
*/
notifierCancel: 'notifier:cancel',
/** 用系统默认浏览器打开外部链接(设置面板"关于"区会用到) */
shellOpenExternal: 'shell:openExternal'
} as const;
export const PUSH = {
clockTick: 'clock:tick',
alarmFired: 'alarm:fired',
pomodoroPhaseChanged: 'pomodoro:phaseChanged',
storageChanged: 'storage:changed',
topbarChanged: 'topbar:changed',
countdownPhaseChanged: 'countdown:phaseChanged',
storageError: 'storage:error',
openRoute: 'route:open',
floatingWindowsChanged: 'floating:windowsChanged'
} as const;

92
src/shared/theme.ts Normal file
View File

@@ -0,0 +1,92 @@
// src/shared/theme.ts
// 皮肤Skin用户可在"设置"里切换的整套配色变量。
// 渲染端 themes.css 通过 :root[data-skin="<id>"] 选择对应的覆盖;
// 这里只列元数据id、中文名、是否为暗色供 UI 渲染皮肤选择器。
// 任何"运行时主题"由 CSS 端的 data-skin 控制 —— 本文件不参与运行时计算。
export const DEFAULT_SKIN_ID = 'linear-dark' as const;
export type SkinId =
| 'linear-dark' // Linear 经典紫 · 深色
| 'brass-dark' // 黄铜 · 深色(保留原 brass 风格作为低调备选)
| 'solarized-dark' // Solarized Dark
| 'nord' // Nord · 极地冷色
| 'catppuccin' // Catppuccin Mocha · 暖紫
| 'vercel-light'; // Vercel / Geist 浅色
export interface SkinMeta {
id: SkinId;
/** 渲染端用 i18n key 之外的回退文本;这里直接给 name 兜底用。 */
name: string;
/** 描述一句话:色系/风格 */
blurb: string;
/** 'dark' 暗 / 'light' 亮 —— 给 UI 在卡片上画一个"暗/亮"标记 */
mode: 'dark' | 'light';
/** 卡片预览色 — 4 个十六进制色用于 quick preview不参与运行时 */
preview: [string, string, string, string];
}
export const SKINS: readonly SkinMeta[] = [
{
id: 'linear-dark',
name: 'Linear Dark',
blurb: '深灰背景 + Linear 紫,开发者气息',
mode: 'dark',
preview: ['#08080B', '#5E6AD2', '#7C8AE5', '#F2F2F5']
},
{
id: 'brass-dark',
name: '黄铜暗色',
blurb: '暗金质感,偏复古仪表盘',
mode: 'dark',
preview: ['#1A1612', '#D9B25F', '#E8C77A', '#F1E4C7']
},
{
id: 'solarized-dark',
name: 'Solarized',
blurb: '经典护眼蓝灰 + 琥珀',
mode: 'dark',
preview: ['#002B36', '#268BD2', '#B58900', '#93A1A1']
},
{
id: 'nord',
name: 'Nord',
blurb: '北极冷色:雾蓝 / 冰青绿',
mode: 'dark',
preview: ['#2E3440', '#88C0D0', '#81A1C1', '#E5E9F0']
},
{
id: 'catppuccin',
name: 'Catppuccin Mocha',
blurb: '暖紫色调,圆润糖系感',
mode: 'dark',
preview: ['#1E1E2E', '#CBA6F7', '#F38BA8', '#CDD6F4']
},
{
id: 'vercel-light',
name: 'Vercel Light',
blurb: '白底 + 黑灰 + Linear 紫,亮色生产力',
mode: 'light',
preview: ['#FFFFFF', '#5E6AD2', '#1A1B25', '#9099A8']
}
];
const KNOWN = new Set<string>(SKINS.map(s => s.id));
/** 任意输入 → 已知 SkinId未知 → 默认皮肤,避免 CSS 端写 :root[data-skin=""] 出错 */
export function normalizeSkinId(raw: unknown): SkinId {
return typeof raw === 'string' && KNOWN.has(raw) ? (raw as SkinId) : DEFAULT_SKIN_ID;
}
/**
* 皮肤 → light/dark 模式。多数皮肤是暗色;只有少数标记 light。从 SKINS 派生,
* 单一来源避免重复声明(L-3 修复)。
* themes.css 暂未按 [data-theme] 区分样式,但保留该映射为未来扩展(高对比 / 印刷
* 风格等皮肤需要在 data-theme 上叠加覆盖)。
*/
const SKIN_MODE: Readonly<Record<string, 'dark' | 'light'>> =
Object.fromEntries(SKINS.map(s => [s.id, s.mode]));
/** 取皮肤对应的亮/暗模式;未知皮肤回退到暗色。 */
export function modeForSkin(id: string): 'dark' | 'light' {
return SKIN_MODE[id] ?? 'dark';
}

97
src/shared/time.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { Alarm } from './types';
export interface HMS { h: number; m: number; s: number; }
/**
* 解析 "HH:mm" 或 "HH:mm:ss";秒段缺省视为 0。
* 旧数据 `"08:00"` 继续可用 —— 正则把 :ss 设为可选。
*/
export function parseHHmm(s: string): HMS | null {
const m = /^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/.exec(s);
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
const sec = m[3] === undefined ? 0 : Number(m[3]);
if (h < 0 || h > 23 || min < 0 || min > 59 || sec < 0 || sec > 59) return null;
return { h, m: min, s: sec };
}
/** 把 HMS 序列化为 "HH:mm[:ss]" —— 秒为 0 时省略 :ss避免无意义的 ":00" 显示。 */
export function formatHHMMSS(t: HMS): string {
const p = (n: number) => String(n).padStart(2, '0');
return t.s === 0 ? `${p(t.h)}:${p(t.m)}` : `${p(t.h)}:${p(t.m)}:${p(t.s)}`;
}
export function formatHMS(totalSec: number): string {
const s = Math.max(0, Math.floor(totalSec));
const hh = Math.floor(s / 3600);
const mm = Math.floor((s % 3600) / 60);
const ss = s % 60;
const p = (n: number) => String(n).padStart(2, '0');
return `${p(hh)}:${p(mm)}:${p(ss)}`;
}
/** 把本地 Date 序列化为 HH:MM:SS不依赖 UTC 偏移,所以不会出现"差 8 小时")。
* 主进程的时钟推送用这个UI 拿到字符串后无需再做任何时区修正。 */
export function formatLocalHMS(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/**
* 把闹钟的 HH:mm[:ss] 转为下一触发时刻(毫秒时间戳,本地时区)。
*
* 闹钟是单次的(本应用不提供重复闹钟):今天该时刻还没到就用今天,已过则顺延到明天。
* 顺延到明天只是为了让"下一个闹钟"有个可展示的时刻 —— 真正响过一次后主进程会把
* enabled 置 false不会等到明天再响。
*
* 时间串非法 → +Infinity调度器会自然把它排到最后。
*
* DST 安全:不能直接 setHours(t.h, t.m, 0)。在春令时跳过的时段(本地 02:00~03:00)
* setHours(2, 30) 会被规范化到 03:30 ——闹钟晚响一小时。在秋令时重复时段(本地
* 01:00~02:00 出现两次)setDate(+1) 横跨 25h 日,让闹钟恰好落在重复时段,
* 调度器 60s 去重窗口远远小于 1h会触发两次响。
* 正确做法:用本地时间拼装 wall-clock把"今天 YYYY-MM-DD" + HH:MM:SS 当字符串),
* 让 Date 构造函数 + Date#getTimezoneOffset 的差值修正被一次性消化掉。
*/
export function nextFireAt(alarm: Alarm, now: Date = new Date()): number {
const parsed = parseHHmm(alarm.time);
if (!parsed) return Number.POSITIVE_INFINITY;
const timePart = `${pad2(parsed.h)}:${pad2(parsed.m)}:${pad2(parsed.s)}`;
// 用今天的本地 wall-clockYYYY-MM-DDTHH:MM:SS无 Z拼出"今天该时刻"。
// ISO 字符串本地构造避开 setHours 在 DST 边界把 wall-clock 滑到下个小时的陷阱。
const todayMs = new Date(`${ymd(now)}T${timePart}`).getTime();
if (todayMs > now.getTime()) return todayMs;
// 跨日:不能用 todayMs + 86400000。23/25h 日会让 wall-clock 漂移 ±1h。
// 改用 Date(y,m,d+1) 拿到明日的本地日历日期,该构造器自带月/年/闰年环绕,
// 再以同样 wall-clock 字符串构造,得到正确的"明天该时刻"毫秒时间戳。
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
return new Date(`${ymd(tomorrow)}T${timePart}`).getTime();
}
function ymd(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function pad2(n: number): string { return String(n).padStart(2, '0'); }
/**
* 从一组闹钟里挑出"最近要响的那一个"。
* 只考虑 enabled 的闹钟;全部禁用/为空时 alarm=null、nextAt=+Infinity。
* 浮窗闹钟 + 首页"下一个"都用这同一份实现:
* 早先两边各写一份,首页直接取数组第一条会把最远的误当成最近。
*/
export function pickNextAlarm(
alarms: readonly Alarm[],
now: Date = new Date()
): { alarm: Alarm | null; nextAt: number } {
let best: { alarm: Alarm | null; nextAt: number } = {
alarm: null,
nextAt: Number.POSITIVE_INFINITY
};
for (const a of alarms) {
if (!a.enabled) continue;
const at = nextFireAt(a, now);
if (at < best.nextAt) best = { alarm: a, nextAt: at };
}
return best;
}

109
src/shared/types.ts Normal file
View File

@@ -0,0 +1,109 @@
// src/shared/types.ts
// 跨主进程 / 渲染进程 / preload 共享的类型契约。
// - 主进程 AppStateContainer 负责把磁盘 JSON 反序列化成 AppState;
// - 渲染端 store.ts bootstrap() 拿到 IPC 推送来的 state 时,字段形状必须与此文件一致;
// - IPC handler 参数解构也用这里的类型。
// 修改本文件时务必同步检查:
// 1) src/main/appState.ts 的默认值与迁移逻辑;
// 2) src/renderer/store.ts 的 bootstrap() 解构;
// 3) floating-* / components/* 各面板的字段引用。
/**
* 提醒通道(仅剩"系统通知")。数组形式仅为留出"将来再加通道"的位置,
* 运行时永远只有一个 'notify'。
*/
export type Behavior = 'notify';
/** 提醒配置:闹钟、番茄钟、倒计时共用一份。 */
export interface ReminderConfig {
behaviors: Behavior[];
}
/** 重复模式:'once' = 触发一次后自动停用;'daily' = 每天都响,直到用户关掉开关。 */
export type AlarmRepeat = 'once' | 'daily';
/**
* 闹钟。
* - repeat='once'(默认 / 老存档):到点触发后由主进程置 enabled=false。
* - repeat='daily':到点触发后不关开关,下一天同一时刻自动再排程。
*/
export interface Alarm {
id: string;
label: string;
time: string; // "HH:mm[:ss]" 本地,秒为 0 时省略 :ss
enabled: boolean;
createdAt: number;
updatedAt: number;
/** 重复模式。可选:老存档没有此字段 → withCompatibilityDefaults 兜底为 'once'。 */
repeat?: AlarmRepeat;
/**
* 旧版本留下的可选字段。新闹钟不再写入此字段(统一走系统通知),但读盘
* 时仍需容错(旧的"静默闹钟"在 UI 上要继续显示"静默",避免行为差异)。
* 渲染端 / sanitize 路径对合法值(仅含 'notify')做向下兼容。
*/
behavior?: Behavior[];
}
/**
* 用户可见的设置项。
* - alwaysOnTop / themeId偏好
* - reminder统一提醒设置闹钟 / 番茄钟 / 倒计时共用)
*/
export interface Settings {
alwaysOnTop: boolean;
/** 皮肤 id渲染端 themes.css 按它切变量。空或未知值回退到默认皮肤。 */
themeId: string;
/** 统一提醒配置。 */
reminder: ReminderConfig;
}
export type PomodoroPhase = 'focus' | 'shortBreak' | 'longBreak';
export interface PomodoroActiveState {
phase: PomodoroPhase;
endsAt: number; // 毫秒
paused: boolean;
remainingMs: number; // paused 时累计剩余
currentRound: number; // 1..4
}
export interface CountdownActiveState {
durationMs: number; // 设定的总时长
endsAt: number; // 结束时刻(毫秒时间戳)
paused: boolean;
remainingMs: number; // 暂停时累计剩余;运行时 = endsAt - now
}
export interface PomodoroHistory {
/** 形如 "2026-07-29"local date午夜自动重置 */
date: string;
/** 当日完成 focus 次数 */
count: number;
/** 历史 7/30 天key 为 YYYY-MM-DDvalue 是完成的 focus 次数。 */
/** 老格式({date, count})保留以向后兼容 —— 见 appState / settings.ts 的 migration 逻辑。 */
byDate?: Record<string, number>;
}
export interface AppState {
version: 1;
settings: Settings;
alarms: Alarm[];
pomodoro: {
active: PomodoroActiveState | null;
completedRounds: number;
/** 当日历史,用于"今日完成 X 个番茄"展示,跨重启持久 */
history: PomodoroHistory;
};
countdown: { // [新]
active: CountdownActiveState | null;
lastDurationMs: number; // 默认 5*60*1000
};
}
export interface WindowGeometry {
x: number; y: number; width: number; height: number;
}
export type Route = 'clock' | 'pomodoro' | 'countdown' | 'alarms' | 'settings';
/** 浮窗小卡片的种类。每个 kind 一个独立 BrowserWindow。 */
export type FloatingKind = 'pomodoro' | 'clock' | 'alarms' | 'countdown';

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["vite/client"]
},
"include": ["src/renderer/**/*", "src/shared/**/*"]
}

15
tsconfig.node.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/main/**/*", "src/preload/**/*", "src/shared/**/*", "electron.vite.config.ts"]
}