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