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

View File

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