update
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
release/
|
||||
88
README.md
Normal file
88
README.md
Normal 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
25
electron-builder.yml
Normal 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
43
electron.vite.config.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
6312
package-lock.json
generated
Normal file
6312
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
142
src/main/appState.ts
Normal 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
143
src/main/countdown.ts
Normal 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。
|
||||
// 这一步至关重要 —— 若没有 yield,capture 与 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 2:write 期间若版本变了,说明 start/reset 已抢在 fire 之前;
|
||||
// 此时 fire 的 broadcast 会让 UI 看到"active 又变 null",并触发 spurious onFire。
|
||||
if (this.activeVersion !== versionAtStart) return;
|
||||
this.deps.onPhaseChange(null);
|
||||
this.deps.onFire();
|
||||
}
|
||||
}
|
||||
84
src/main/floatingLayout.ts
Normal file
84
src/main/floatingLayout.ts
Normal 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
318
src/main/index.ts
Normal 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 退出延迟 ~1s(scheduler 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':保留 enabled,scheduler 已为它排好下一天的同刻。
|
||||
// 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
275
src/main/ipc.ts
Normal 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
67
src/main/notifier.ts
Normal 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
288
src/main/pomodoro.ts
Normal 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 而不是 transition;null 表示 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 === cur(start)还是 prev !== cur(transition)。
|
||||
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 不改 phase,prevPhase 保持与当前一致,避免误触发"切换"提醒。
|
||||
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 仍是 null),start 立即
|
||||
// 通过 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,写盘失败时会变成 unhandledRejection;Node 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" 才 +1;skip() 调 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 activeVersion;transition 写完之后版本不一致,
|
||||
// 说明它已被新的 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
12
src/main/quitState.ts
Normal 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;
|
||||
}
|
||||
24
src/main/rendererTarget.ts
Normal file
24
src/main/rendererTarget.ts
Normal 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` 等。
|
||||
* 历史 bug:dev 分支写死加载 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
125
src/main/scheduler.ts
Normal 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 同步 state,stale 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
174
src/main/storage.ts
Normal 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) {
|
||||
// 版本不匹配:不要立刻 rename(rename 是不可逆的,让用户失去备份的机会),
|
||||
// 改用 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 正在飞行的那次 drain:drain() 首行是 `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
76
src/main/tray.ts
Normal 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.asar;dev 模式 `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
23
src/main/windowState.ts
Normal 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
521
src/main/windows.ts
Normal 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.ico(electron-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=true):attachCloseToTray 不拦,窗口 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;
|
||||
* - 渲染端还没 ready:did-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
111
src/preload/index.ts
Normal 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
109
src/renderer/App.ts
Normal 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 走顶部静态 import:store.ts 本来就被本文件和一堆组件静态引用,
|
||||
// 再动态 import 一次并不会拆出独立 chunk,只会让 Vite 报
|
||||
// "dynamically imported by ... but also statically imported" 的告警。
|
||||
await bootstrap();
|
||||
applyTheme();
|
||||
|
||||
// 通知权限:主进程自己用 Electron Notification 不需要渲染端授权,
|
||||
// 但设置面板的「试听通知」走 window.Notification(Web 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
262
src/renderer/components/AlarmEditor.ts
Normal file
262
src/renderer/components/AlarmEditor.ts
Normal 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 的 cleanup:mount 收到的回调(setEditing(null))在编辑器收起时触发。
|
||||
// 旧实现没有 cleanup → 4s revertTimer 会戳游离 DOM;wheel/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 期间点了"取消"会触发 wrappedClose,cleaned=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');
|
||||
}
|
||||
264
src/renderer/components/AlarmList.ts
Normal file
264
src/renderer/components/AlarmList.ts
Normal 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 / confirm),CSS 按
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
239
src/renderer/components/ClockPanel.ts
Normal file
239
src/renderer/components/ClockPanel.ts
Normal 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,居中),下方一行小卡片。
|
||||
* - Hero:HH: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 会让标签里的 & 显示成 &。
|
||||
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);
|
||||
};
|
||||
}
|
||||
325
src/renderer/components/CountdownPanel.ts
Normal file
325
src/renderer/components/CountdownPanel.ts
Normal 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';
|
||||
|
||||
/**
|
||||
* 通用倒计时面板:
|
||||
* - idle:HH/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 被 hide(draw 的 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
|
||||
// 附带 sanitize:type="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 +1,wheel-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 已经到点(<= now);reset 时 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();
|
||||
|
||||
// 输入框事件 listener:cleanup 时一起移除
|
||||
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();
|
||||
};
|
||||
}
|
||||
33
src/renderer/components/PinButton.ts
Normal file
33
src/renderer/components/PinButton.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
178
src/renderer/components/PomodoroPanel.ts
Normal file
178
src/renderer/components/PomodoroPanel.ts
Normal 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 的秒带 .sec(20px + 暗色)。
|
||||
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();
|
||||
};
|
||||
}
|
||||
222
src/renderer/components/SettingsPanel.ts
Normal file
222
src/renderer/components/SettingsPanel.ts
Normal 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 自己的
|
||||
* BrowserWindow(target=_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);
|
||||
};
|
||||
}
|
||||
220
src/renderer/components/TabStrip.ts
Normal file
220
src/renderer/components/TabStrip.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
243
src/renderer/components/Toast.ts
Normal file
243
src/renderer/components/Toast.ts
Normal 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 下挂两个 host:critical(error/warn,顶部居中)和 normal(info/ok,右下)。
|
||||
* 解绑时整体一起卸载。
|
||||
* 多入口共存的场景(主窗 + 编辑器)由调用方各调一次 attach,互不干扰。
|
||||
*
|
||||
* 幂等:同一 document 里已有 host(id 探测),则不再 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 的话
|
||||
// 长寿命 toast(6s 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 永久为 null,toast 永远不消失。
|
||||
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);
|
||||
}
|
||||
191
src/renderer/components/Topbar.ts
Normal file
191
src/renderer/components/Topbar.ts
Normal 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];
|
||||
// 状态没变就不写 DOM:toggle/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();
|
||||
};
|
||||
}
|
||||
14
src/renderer/components/escape.ts
Normal file
14
src/renderer/components/escape.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 共享的 HTML 转义工具,供多个组件复用。
|
||||
// 注意:这是给 textContent / 标签属性 / 内联子节点使用的最小集,
|
||||
// 不要把它套到 URL、style 等需要更严格语境处理的场景。
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c]!);
|
||||
}
|
||||
167
src/renderer/floating-alarms/index.html
Normal file
167
src/renderer/floating-alarms/index.html
Normal 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>
|
||||
132
src/renderer/floating-alarms/main.ts
Normal file
132
src/renderer/floating-alarms/main.ts
Normal 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());
|
||||
});
|
||||
109
src/renderer/floating-alarms/styles.css
Normal file
109
src/renderer/floating-alarms/styles.css
Normal 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;
|
||||
}
|
||||
167
src/renderer/floating-clock/index.html
Normal file
167
src/renderer/floating-clock/index.html
Normal 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>
|
||||
67
src/renderer/floating-clock/main.ts
Normal file
67
src/renderer/floating-clock/main.ts
Normal 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());
|
||||
});
|
||||
93
src/renderer/floating-clock/styles.css
Normal file
93
src/renderer/floating-clock/styles.css
Normal 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; }
|
||||
}
|
||||
165
src/renderer/floating-countdown/index.html
Normal file
165
src/renderer/floating-countdown/index.html
Normal 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>
|
||||
104
src/renderer/floating-countdown/main.ts
Normal file
104
src/renderer/floating-countdown/main.ts
Normal 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());
|
||||
});
|
||||
113
src/renderer/floating-countdown/styles.css
Normal file
113
src/renderer/floating-countdown/styles.css
Normal 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);
|
||||
}
|
||||
179
src/renderer/floating-pomodoro/index.html
Normal file
179
src/renderer/floating-pomodoro/index.html
Normal 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>
|
||||
139
src/renderer/floating-pomodoro/main.ts
Normal file
139
src/renderer/floating-pomodoro/main.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
137
src/renderer/floating-pomodoro/styles.css
Normal file
137
src/renderer/floating-pomodoro/styles.css
Normal 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); }
|
||||
114
src/renderer/floating-shared.ts
Normal file
114
src/renderer/floating-shared.ts
Normal 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
171
src/renderer/i18n.ts
Normal 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
14
src/renderer/index.html
Normal 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
32
src/renderer/main.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { mount } from './App';
|
||||
|
||||
// 顶层 catch:mount() 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';
|
||||
|
||||
// 提前挂一个 host:mount() 内部也会调一次,但因 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);
|
||||
}
|
||||
79
src/renderer/secondTick.ts
Normal file
79
src/renderer/secondTick.ts
Normal 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
183
src/renderer/store.ts
Normal 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) => { /* 主进程已广播 storageChanged,UI 自行重画 */ }),
|
||||
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(); };
|
||||
}
|
||||
1998
src/renderer/styles/global.css
Normal file
1998
src/renderer/styles/global.css
Normal file
File diff suppressed because it is too large
Load Diff
258
src/renderer/styles/themes.css
Normal file
258
src/renderer/styles/themes.css
Normal 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;
|
||||
}
|
||||
41
src/renderer/themeApply.ts
Normal file
41
src/renderer/themeApply.ts
Normal 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
6
src/renderer/types.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AppApi } from '../preload';
|
||||
|
||||
declare global {
|
||||
interface Window { api: AppApi }
|
||||
}
|
||||
|
||||
84
src/shared/constants.ts
Normal file
84
src/shared/constants.ts
Normal 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
92
src/shared/theme.ts
Normal 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
97
src/shared/time.ts
Normal 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-clock(YYYY-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
109
src/shared/types.ts
Normal 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-DD,value 是完成的 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
18
tsconfig.json
Normal 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
15
tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user