update
This commit is contained in:
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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user