feat: 重构 clock — Vue 组件化(世界时钟/闹钟/秒表/计时器4tab)+ 37 测试

This commit is contained in:
李岩岩 2026-07-23 11:19:43 +08:00
parent 9ab470a867
commit 9b233a0c86
3 changed files with 722 additions and 75 deletions

339
src/apps/clock/Clock.vue Normal file
View File

@ -0,0 +1,339 @@
<template>
<div class="clock-body">
<div class="store-tabs clock-tabs">
<button
v-for="t in tabs" :key="t[0]"
class="store-tab" :class="{ on: tab === t[0] }"
:data-tab="t[0]"
@click="tab = t[0]"
>{{ t[1] }}</button>
</div>
<div class="clock-content">
<!-- 世界时钟 -->
<template v-if="tab === 'world'">
<div class="clock-addrow">
<select class="text-input" @change="addCity(($event.target as HTMLSelectElement).value)">
<option value="">添加城市</option>
<option v-for="c in availableCities" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="clock-cities">
<div v-for="c in data.cities" :key="c" class="clock-city">
<div>
<b>{{ c }}</b>
<small>{{ tzOffset(c) }}</small>
</div>
<span class="clock-city-time">{{ fmtTime(c) }}</span>
<button class="weather-del" title="删除" @click="removeCity(c)"></button>
</div>
</div>
</template>
<!-- 闹钟 -->
<template v-if="tab === 'alarm'">
<div class="clock-addrow">
<input class="text-input" type="number" min="0" max="23" v-model="newAlarmH" style="width:64px">
<span>:</span>
<input class="text-input" type="number" min="0" max="59" v-model="newAlarmM" style="width:64px">
<input class="text-input" type="text" placeholder="标签" v-model="newAlarmLabel">
<button class="btn primary" @click="addAlarm">添加闹钟</button>
</div>
<div class="alarm-list">
<div v-if="!data.alarms.length" class="empty-state" style="height:120px">没有闹钟</div>
<div v-for="a in data.alarms" :key="a.id" class="alarm-row">
<div class="alarm-time">{{ pad(a.h) }}:{{ pad(a.m) }}</div>
<div class="alarm-label">{{ a.label || '闹钟' }}</div>
<div
class="switch" :class="{ on: a.on }"
role="switch" :aria-checked="a.on"
tabindex="0"
@click="toggleAlarm(a)"
@keydown.space.prevent="toggleAlarm(a)"
@keydown.enter.prevent="toggleAlarm(a)"
></div>
<button class="weather-del" title="删除闹钟" @click="removeAlarm(a)"></button>
</div>
</div>
</template>
<!-- 秒表 -->
<template v-if="tab === 'sw'">
<div class="sw-disp">{{ fmtSw() }}</div>
<div class="sw-btns">
<button class="btn sw-btn" :disabled="!sw.running" @click="swLap">计次</button>
<button class="btn primary sw-btn" @click="swToggle">{{ swBtnText }}</button>
<button class="btn sw-btn" :disabled="sw.running || sw.acc === 0" @click="swReset">复位</button>
</div>
<div class="sw-laps">
<div v-for="(lp, i) in reversedLaps" :key="i" class="sw-lap">
<span>计次 {{ sw.laps.length - i }}</span>
<span>{{ fmtSwLap(lp) }}</span>
</div>
</div>
</template>
<!-- 计时器 -->
<template v-if="tab === 'timer'">
<div class="clock-addrow">
<input class="text-input" type="number" min="0" max="180" v-model="timerMin" :disabled="timerRunning" style="width:72px">
<span></span>
<input class="text-input" type="number" min="0" max="59" v-model="timerSec" :disabled="timerRunning" style="width:72px">
<span></span>
</div>
<div class="timer-ring" :style="{ '--p': timerProgress }">
<div class="sw-disp">{{ fmtTimerDisplay() }}</div>
</div>
<div class="sw-btns">
<button class="btn sw-btn" @click="timerReset">复位</button>
<button class="btn primary sw-btn" @click="timerToggle">{{ timerBtnText }}</button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { clamp, uid } from '../../utils'
import { store } from '../../composables/useStore'
import { Notify } from '../../composables/useNotify'
const props = defineProps<{ win: any }>()
// ==================== ====================
const tabs: [string, string][] = [['world', '世界时钟'], ['alarm', '闹钟'], ['sw', '秒表'], ['timer', '计时器']]
const cityTZ: Record<string, string> = {
'北京': 'Asia/Shanghai', '上海': 'Asia/Shanghai', '东京': 'Asia/Tokyo', '伦敦': 'Europe/London',
'巴黎': 'Europe/Paris', '纽约': 'America/New_York', '洛杉矶': 'America/Los_Angeles', '悉尼': 'Australia/Sydney',
'迪拜': 'Asia/Dubai', '新加坡': 'Asia/Singapore',
}
function tzOffsetLabel(tz: string): string {
try {
const part = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' })
.formatToParts(new Date()).find(p => p.type === 'timeZoneName')
return part ? part.value.replace('GMT', 'UTC').replace(/^UTC$/, 'UTC+0') : ''
} catch { return '' }
}
function beep() {
try {
const AudioCtx = (window as any).AudioContext || (window as any).webkitAudioContext
const ctx = new AudioCtx()
const o = ctx.createOscillator(); const g = ctx.createGain()
o.connect(g); g.connect(ctx.destination)
o.frequency.value = 880
const vol = (window as any).Sys?.settings?.muted ? 0 : ((window as any).Sys?.settings?.volume || 1) * 0.3
g.gain.setValueAtTime(vol, ctx.currentTime)
o.start()
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.2)
o.stop(ctx.currentTime + 1.3)
} catch {}
}
// ==================== ====================
const data = reactive(store.get('clock', {
cities: ['上海', '东京', '伦敦', '纽约'],
alarms: [{ id: 'a1', h: 8, m: 30, label: '起床', on: false }]
}))
function persist() { store.set('clock', { cities: data.cities, alarms: data.alarms }) }
// ==================== ====================
const tab = ref('world')
const now = ref(Date.now())
let timer: any = null
function pad(n: number) { return String(n).padStart(2, '0') }
// ==================== ====================
const availableCities = computed(() => Object.keys(cityTZ).filter(c => !data.cities.includes(c)))
function tzOffset(city: string): string { return tzOffsetLabel(cityTZ[city] || '') }
function fmtTime(city: string): string {
const tz = cityTZ[city]
if (!tz) return '--:--'
const d = new Date(now.value)
const h24 = (window as any).Sys?.settings?.h24 ?? true
const h = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hour12: false }).format(d))
const m = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: tz, minute: 'numeric' }).format(d))
if (h24) return `${pad(h)}:${pad(m)}`
const ampm = h >= 12 ? '下午' : '上午'
const h12 = h % 12 || 12
return `${ampm}${h12}:${pad(m)}`
}
function addCity(city: string) {
if (!city || data.cities.includes(city)) return
data.cities.push(city); persist()
}
function removeCity(city: string) {
data.cities = data.cities.filter(c => c !== city); persist()
}
// ==================== ====================
const newAlarmH = ref(8)
const newAlarmM = ref(0)
const newAlarmLabel = ref('闹钟')
const firedKeys = new Set<string>()
function addAlarm() {
const h = clamp(newAlarmH.value, 0, 23)
const m = clamp(newAlarmM.value, 0, 59)
data.alarms.push({ id: uid(), h, m, label: newAlarmLabel.value.trim() || '闹钟', on: true })
persist()
}
function toggleAlarm(a: any) { a.on = !a.on; persist() }
function removeAlarm(a: any) {
data.alarms = data.alarms.filter((x: any) => x !== a); persist()
}
function checkAlarms() {
const d = new Date(now.value)
const hm = d.getHours() * 60 + d.getMinutes()
for (const a of data.alarms) {
if (!a.on) continue
const key = `_fired_${a.id}_${d.toDateString()}`
if (a.h * 60 + a.m === hm && !firedKeys.has(key)) {
firedKeys.add(key)
Notify.send({ appId: 'clock', title: '闹钟', body: `${a.label || '闹钟'} ${pad(a.h)}:${pad(a.m)}` })
beep()
}
}
}
// ==================== ====================
const sw = reactive({ running: false, t0: 0, acc: 0, laps: [] as number[] })
function fmtSwMs(ms: number): string {
const t = Math.floor(ms / 100)
return `${pad(Math.floor(t / 600))}:${pad(Math.floor(t / 10) % 60)}.${t % 10}`
}
function swCur(): number { return sw.acc + (sw.running ? Date.now() - sw.t0 : 0) }
function fmtSw(): string { return fmtSwMs(swCur()) }
function fmtSwLap(ms: number): string { return fmtSwMs(ms) }
const swBtnText = computed(() => sw.running ? '暂停' : (sw.acc > 0 ? '继续' : '开始'))
const reversedLaps = computed(() => [...sw.laps].reverse())
function swToggle() {
sw.running = !sw.running
if (sw.running) sw.t0 = Date.now()
else sw.acc += Date.now() - sw.t0
}
function swLap() { sw.laps.push(swCur()) }
function swReset() { sw.running = false; sw.t0 = 0; sw.acc = 0; sw.laps = [] }
// ==================== ====================
const timerMin = ref(5)
const timerSec = ref(0)
const timerRunning = ref(false)
const timerLeft = ref(0)
const timerTotal = ref(0)
const timerEndAt = ref(0)
const timerNotified = ref(false)
function timerCurLeft(): number {
if (timerRunning.value) return Math.max(0, timerEndAt.value - Date.now())
return timerLeft.value
}
const timerProgress = computed(() => {
if (!timerTotal.value) return '0'
return String(1 - timerCurLeft() / timerTotal.value)
})
const timerBtnText = computed(() => {
if (timerRunning.value) return '暂停'
if (timerLeft.value > 0) return '继续'
return '开始'
})
function fmtTimerMs(ms: number): string {
const t = Math.max(0, Math.ceil(ms / 1000))
return `${pad(Math.floor(t / 60))}:${pad(t % 60)}`
}
function fmtTimerDisplay(): string {
const left = timerCurLeft()
if (!timerRunning.value && left <= 0) {
return fmtTimerMs((timerMin.value * 60 + timerSec.value) * 1000)
}
return fmtTimerMs(left)
}
function timerToggle() {
if (timerRunning.value) {
timerLeft.value = Math.max(0, timerEndAt.value - Date.now())
timerRunning.value = false
return
}
if (timerLeft.value <= 0) {
timerTotal.value = (timerMin.value * 60 + timerSec.value) * 1000
if (timerTotal.value <= 0) return
timerLeft.value = timerTotal.value
}
timerEndAt.value = Date.now() + timerLeft.value
timerNotified.value = false
timerRunning.value = true
}
function timerReset() {
timerRunning.value = false
timerLeft.value = 0
timerTotal.value = 0
timerNotified.value = false
}
function timerTick() {
if (!timerRunning.value) return
if (timerEndAt.value - Date.now() <= 0) {
timerRunning.value = false
timerLeft.value = 0
if (!timerNotified.value) {
timerNotified.value = true
Notify.send({ appId: 'clock', title: '计时器', body: '时间到!', breakthrough: true })
beep()
}
}
}
// ==================== ====================
onMounted(() => {
timer = setInterval(() => {
now.value = Date.now()
if (tab.value === 'alarm') checkAlarms()
timerTick()
}, 500)
})
onUnmounted(() => { clearInterval(timer) })
// ==================== ====================
watch(() => props.win, (w) => {
if (!w) return
w.confirmClose = (done: () => void, cancel: () => void) => {
if (!sw.running && !timerRunning.value) return done()
const what = timerRunning.value ? '计时器正在运行,关闭窗口将停止计时。' : '秒表正在运行,关闭窗口将停止计时。'
const ui = (window as any).ui
if (ui) {
ui.dialog({ icon: '/assets/icons/clock.svg', title: '关闭时钟?', msg: what, buttons: ['取消', '关闭'] })
.then((r: any) => { r.index === 1 ? done() : cancel() })
} else { done() }
}
}, { immediate: true })
// menus
defineExpose({ tab, sw, timerRunning, timerLeft })
</script>

View File

@ -1,84 +1,16 @@
// 时钟应用 — 从 js/apps3.js 手动转写
import { el, $$, clamp, uid, fmtTimeHM } from '../../utils'
import { store as Store } from '../../composables/useStore'
import { ui as UI } from '../../composables/useUI'
// 时钟应用 — Vue 组件化
import { h, render } from 'vue'
import { Apps, stdMenus } from '../../composables/useApps'
import { Notify } from '../../composables/useNotify'
const Sys = () => (window as any).Sys
import ClockComponent from './Clock.vue'
export const ClockApp = {
id: 'clock', name: '时钟', icon: '/assets/icons/clock.svg',
w: 680, h: 480, minW: 480, minH: 360,
menus() { return stdMenus(this) },
store: {
get(): any { return Store.get('clock', { cities: ['上海', '东京', '伦敦', '纽约'], alarms: [{ id: 'a1', h: 8, m: 30, label: '起床', on: false }] }) },
set(v: any) { Store.set('clock', v) }
},
cityTZ: {
'北京': 'Asia/Shanghai', '上海': 'Asia/Shanghai', '东京': 'Asia/Tokyo', '伦敦': 'Europe/London',
'巴黎': 'Europe/Paris', '纽约': 'America/New_York', '洛杉矶': 'America/Los_Angeles', '悉尼': 'Australia/Sydney',
'迪拜': 'Asia/Dubai', '新加坡': 'Asia/Singapore',
} as Record<string, string>,
tzOffsetLabel(tz: string) {
try { const part = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' }).formatToParts(new Date()).find(p => p.type === 'timeZoneName'); return part ? part.value.replace('GMT', 'UTC').replace(/^UTC$/, 'UTC+0') : '' } catch (e) { return '' }
},
beep() {
try { const ctx = new ((window as any).AudioContext || (window as any).webkitAudioContext)(); const o = ctx.createOscillator(), g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.frequency.value = 880; const S = Sys(); g.gain.setValueAtTime(S.settings.muted ? 0 : S.settings.volume * 0.3, ctx.currentTime); o.start(); g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.2); o.stop(ctx.currentTime + 1.3) } catch (e) {}
},
tick() {
const st = this.store.get(); const now = new Date(); const hm = now.getHours() * 60 + now.getMinutes()
for (const a of st.alarms) { if (!a.on) continue; const key = '_fired_' + a.id + '_' + now.toDateString(); if (a.h * 60 + a.m === hm && !(this as any)[key]) { (this as any)[key] = true; Notify.send({ appId: 'clock', title: '闹钟', body: `${a.label || '闹钟'} ${String(a.h).padStart(2, '0')}:${String(a.m).padStart(2, '0')}` }); this.beep() } }
},
render(win: any) {
const S = Sys()
const st = win.appState = { tab: 'world', data: this.store.get(), sw: { running: false, t0: 0, acc: 0, laps: [] as number[] }, timer: { left: 0, running: false, total: 0, notified: false, endAt: 0 } }
win.body.classList.add('clock-body')
const tabbar = el('div', { class: 'store-tabs clock-tabs' }), content = el('div', { class: 'clock-content' })
win.body.append(tabbar, content)
const save = () => this.store.set(st.data)
const tabs: [string, string][] = [['world', '世界时钟'], ['alarm', '闹钟'], ['sw', '秒表'], ['timer', '计时器']]
tabs.forEach(([id, name]) => { const b = el('button', { class: 'store-tab', text: name, dataset: { tab: id } }); b.addEventListener('click', () => go(id)); tabbar.append(b) })
const go = (id: string) => { st.tab = id; $$('.store-tab', tabbar).forEach((n: HTMLElement) => n.classList.toggle('on', n.dataset.tab === id)); content.innerHTML = ''; (this as any)['render_' + id](content, st, save) }
win.timers.push(setInterval(() => { if (!document.body.contains(content)) return; if (st.tab === 'world') this.render_world(content, st, save, true); if (st.sw.running && (this as any)._swTick) (this as any)._swTick(); if (st.timer.running && (this as any)._timerTick) (this as any)._timerTick() }, 500))
win.confirmClose = (done: () => void, cancel: () => void) => { if (!st.sw.running && !st.timer.running) return done(); const what = st.timer.running ? '计时器正在运行,关闭窗口将停止计时。' : '秒表正在运行,关闭窗口将停止计时。'; UI.dialog({ icon: this.icon, title: '关闭时钟?', msg: what, buttons: ['取消', '关闭'] }).then((r: any) => { r.index === 1 ? done() : cancel() }) }
go('world')
const vnode = h(ClockComponent, { win })
render(vnode, win.body)
},
render_world(box: HTMLElement, st: any, save: () => void, soft?: boolean) {
const S = Sys(); const now = new Date()
if (!soft) { box.innerHTML = ''; const sel = el('select', { class: 'text-input' }, el('option', { text: '添加城市…', value: '' }), ...Object.keys(this.cityTZ).filter(c => !st.data.cities.includes(c)).map(c => el('option', { text: c, value: c }))); (sel as HTMLSelectElement).addEventListener('change', () => { if ((sel as HTMLSelectElement).value) { st.data.cities.push((sel as HTMLSelectElement).value); save(); this.render_world(box, st, save) } }); box.append(el('div', { class: 'clock-addrow' }, sel), el('div', { class: 'clock-cities' })) }
const list = box.querySelector('.clock-cities')!; list.innerHTML = ''
st.data.cities.forEach((c: string) => { const tz = this.cityTZ[c]; if (!tz) return; const row = el('div', { class: 'clock-city' }, el('div', null, el('b', { text: c }), el('small', { text: this.tzOffsetLabel(tz) })), el('span', { class: 'clock-city-time', text: fmtTimeHM(now, S.settings.h24, tz) }), el('button', { class: 'weather-del', text: '✕', title: '删除', onclick: () => { st.data.cities = st.data.cities.filter((x: string) => x !== c); save(); this.render_world(box, st, save) } })); list.append(row) })
},
render_alarm(box: HTMLElement, st: any, save: () => void) {
box.innerHTML = ''; const list = el('div', { class: 'alarm-list' })
const renderList = () => { list.innerHTML = ''; if (!st.data.alarms.length) list.append(el('div', { class: 'empty-state', style: { height: '120px' }, text: '没有闹钟' })); st.data.alarms.forEach((a: any) => { const sw = el('div', { class: 'switch' + (a.on ? ' on' : ''), role: 'switch', 'aria-checked': String(!!a.on), tabindex: '0' }); const flipSw = () => { a.on = !a.on; sw.classList.toggle('on', a.on); sw.setAttribute('aria-checked', String(!!a.on)); save() }; sw.addEventListener('click', flipSw); sw.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); flipSw() } }); list.append(el('div', { class: 'alarm-row' }, el('div', { class: 'alarm-time', text: `${String(a.h).padStart(2, '0')}:${String(a.m).padStart(2, '0')}` }), el('div', { class: 'alarm-label', text: a.label || '闹钟' }), sw, el('button', { class: 'weather-del', text: '✕', title: '删除闹钟', onclick: () => { st.data.alarms = st.data.alarms.filter((x: any) => x !== a); save(); renderList() } }))) }) }
const hIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '23', value: '8', style: { width: '64px' } }); const mIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '59', value: '0', style: { width: '64px' } }); const lIn = el('input', { class: 'text-input', type: 'text', placeholder: '标签', value: '闹钟' }); const add = el('button', { class: 'btn primary', text: '添加闹钟' })
add.addEventListener('click', () => { const h = clamp(+(hIn as HTMLInputElement).value || 0, 0, 23), m = clamp(+(mIn as HTMLInputElement).value || 0, 0, 59); st.data.alarms.push({ id: uid(), h, m, label: (lIn as HTMLInputElement).value.trim() || '闹钟', on: true }); save(); renderList() })
box.append(el('div', { class: 'clock-addrow' }, hIn, el('span', { text: ':' }), mIn, lIn, add), list); renderList()
},
render_sw(box: HTMLElement, st: any) {
box.innerHTML = ''; const disp = el('div', { class: 'sw-disp' }); const startBtn = el('button', { class: 'btn primary sw-btn' }); const lapBtn = el('button', { class: 'btn sw-btn', text: '计次' }); const resetBtn = el('button', { class: 'btn sw-btn', text: '复位' }); const laps = el('div', { class: 'sw-laps' }); box.append(disp, el('div', { class: 'sw-btns' }, lapBtn, startBtn, resetBtn), laps)
const fmtT = (ms: number) => { const t = Math.floor(ms / 100); return `${String(Math.floor(t / 600)).padStart(2, '0')}:${String(Math.floor(t / 10) % 60).padStart(2, '0')}.${t % 10}` }
const cur = () => st.sw.acc + (st.sw.running ? Date.now() - st.sw.t0 : 0)
const renderLaps = () => { laps.innerHTML = ''; st.sw.laps.forEach((lp: number, i: number) => laps.prepend(el('div', { class: 'sw-lap' }, el('span', { text: `计次 ${i + 1}` }), el('span', { text: fmtT(lp) })))) }
const syncUI = () => { disp.textContent = fmtT(cur()); (startBtn as HTMLButtonElement).textContent = st.sw.running ? '暂停' : (st.sw.acc > 0 ? '继续' : '开始'); (lapBtn as HTMLButtonElement).disabled = !st.sw.running; (resetBtn as HTMLButtonElement).disabled = st.sw.running || st.sw.acc === 0 };
(this as any)._swTick = () => { if (disp.isConnected) disp.textContent = fmtT(cur()) }
startBtn.addEventListener('click', () => { st.sw.running = !st.sw.running; if (st.sw.running) st.sw.t0 = Date.now(); else st.sw.acc += Date.now() - st.sw.t0; syncUI() })
lapBtn.addEventListener('click', () => { st.sw.laps.push(cur()); renderLaps() })
resetBtn.addEventListener('click', () => { st.sw = { running: false, t0: 0, acc: 0, laps: [] }; renderLaps(); syncUI() })
renderLaps(); syncUI()
},
render_timer(box: HTMLElement, st: any) {
box.innerHTML = ''; const mIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '180', value: '5', style: { width: '72px' } }); const sIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '59', value: '0', style: { width: '72px' } }); const disp = el('div', { class: 'sw-disp', text: '05:00' }); const startBtn = el('button', { class: 'btn primary sw-btn' }); const resetBtn = el('button', { class: 'btn sw-btn', text: '复位' }); const ring = el('div', { class: 'timer-ring' }, disp); box.append(el('div', { class: 'clock-addrow' }, mIn, el('span', { text: '分' }), sIn, el('span', { text: '秒' })), ring, el('div', { class: 'sw-btns' }, resetBtn, startBtn))
const fmtT = (ms: number) => { const t = Math.max(0, Math.ceil(ms / 1000)); return `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}` }
const left = () => st.timer.running ? Math.max(0, st.timer.endAt - Date.now()) : st.timer.left
const sync = () => { const l = left(); disp.textContent = fmtT(l); ring.style.setProperty('--p', st.timer.total ? String(1 - l / st.timer.total) : '0'); (startBtn as HTMLButtonElement).textContent = st.timer.running ? '暂停' : (st.timer.left > 0 ? '继续' : '开始'); (mIn as HTMLInputElement).disabled = (sIn as HTMLInputElement).disabled = st.timer.running };
(this as any)._timerTick = () => { if (!st.timer.running) return; if (st.timer.endAt - Date.now() <= 0) { st.timer.running = false; st.timer.left = 0; if (!st.timer.notified) { st.timer.notified = true; Notify.send({ appId: 'clock', title: '计时器', body: '时间到!', breakthrough: true }); this.beep() } } else st.timer.left = st.timer.endAt - Date.now(); if (disp.isConnected) sync() }
startBtn.addEventListener('click', () => { if (st.timer.running) { st.timer.left = Math.max(0, st.timer.endAt - Date.now()); st.timer.running = false; sync(); return }; if (st.timer.left <= 0) { st.timer.total = ((+(mIn as HTMLInputElement).value || 0) * 60 + (+(sIn as HTMLInputElement).value || 0)) * 1000; if (st.timer.total <= 0) return; st.timer.left = st.timer.total }; st.timer.endAt = Date.now() + st.timer.left; st.timer.notified = false; st.timer.running = true; sync() })
resetBtn.addEventListener('click', () => { st.timer = { left: 0, running: false, total: 0, notified: false }; sync(); disp.textContent = fmtT(((+mIn.value || 0) * 60 + (+(sIn as HTMLInputElement).value || 0)) * 1000) })
mIn.addEventListener('input', () => { if (!st.timer.running && st.timer.left <= 0) disp.textContent = fmtT(((+mIn.value || 0) * 60 + (+(sIn as HTMLInputElement).value || 0)) * 1000) })
sIn.addEventListener('input', () => mIn.dispatchEvent(new Event('input'))); sync()
}
}
Apps.register(ClockApp);
(window as any).ClockApp = ClockApp
Apps.register(ClockApp)
;(window as any).ClockApp = ClockApp

View File

@ -0,0 +1,376 @@
/**
* 31-clock: Clock Vue
*
* 覆盖: DOM /4 tab //
* /////
* ///Store
*
* / Date.now() tick jsdom
*
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { h, render, nextTick } from 'vue'
import ClockComponent from '../../src/apps/clock/Clock.vue'
import { store } from '../../src/composables/useStore'
import { setupDOM } from '../helpers'
function makeMockWin() {
const el = document.createElement('div'); el.className = 'window'
document.getElementById('window-layer')?.appendChild(el)
const body = document.createElement('div'); body.className = 'win-body'
el.appendChild(body)
return { id: 'clock', appId: 'clock', el, body, timers: [] as any[], data: {} }
}
describe('31-clock — Clock Vue 组件化', () => {
let win: any
beforeEach(() => { localStorage.clear(); store.set('clock', null); setupDOM(); win = makeMockWin() })
afterEach(() => { render(null, win.body); win.el.remove() })
function mount() { const v = h(ClockComponent, { win }); render(v, win.body) }
// ==================== DOM 结构 ====================
describe('DOM 结构', () => {
it('4 个 tab 按钮存在', () => {
mount()
expect(win.body.querySelectorAll('.store-tab').length).toBe(4)
})
it('默认 tab 为 world', () => {
mount()
expect(win.body.querySelector('.store-tab.on')?.textContent).toContain('世界时钟')
})
it('clock-content 存在', () => {
mount()
expect(win.body.querySelector('.clock-content')).toBeTruthy()
})
})
// ==================== Tab 切换 ====================
describe('Tab 切换', () => {
it('点击闹钟 tab 切换', async () => {
mount()
const tabs = win.body.querySelectorAll('.store-tab')
const alarmTab = [...tabs].find(t => t.textContent.includes('闹钟')) as HTMLElement
alarmTab.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(win.body.querySelector('.alarm-list')).toBeTruthy()
})
it('点击秒表 tab 切换', async () => {
mount()
const tabs = win.body.querySelectorAll('.store-tab')
const swTab = [...tabs].find(t => t.textContent.includes('秒表')) as HTMLElement
swTab.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(win.body.querySelector('.sw-disp')).toBeTruthy()
})
it('点击计时器 tab 切换', async () => {
mount()
const tabs = win.body.querySelectorAll('.store-tab')
const timerTab = [...tabs].find(t => t.textContent.includes('计时器')) as HTMLElement
timerTab.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(win.body.querySelector('.timer-ring')).toBeTruthy()
})
it('切回世界时钟 tab', async () => {
mount()
const tabs = win.body.querySelectorAll('.store-tab')
;([...tabs].find(t => t.textContent.includes('闹钟')) as HTMLElement)
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
;([...tabs].find(t => t.textContent.includes('世界时钟')) as HTMLElement)
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(win.body.querySelector('.clock-cities')).toBeTruthy()
})
})
// ==================== 世界时钟 ====================
describe('世界时钟', () => {
it('默认显示 4 个城市', () => {
store.set('clock', { cities: ['上海', '东京', '伦敦', '纽约'], alarms: [] })
mount()
expect(win.body.querySelectorAll('.clock-city').length).toBe(4)
})
it('每个城市有时区偏移', () => {
store.set('clock', { cities: ['上海', '东京'], alarms: [] })
mount()
const offsets = win.body.querySelectorAll('.clock-city small')
expect(offsets.length).toBeGreaterThanOrEqual(1)
expect(offsets[0].textContent).toBeTruthy() // UTC+8 or similar
})
it('每个城市有时间显示', () => {
store.set('clock', { cities: ['上海'], alarms: [] })
mount()
expect(win.body.querySelector('.clock-city-time')?.textContent).toBeTruthy()
})
it('添加城市下拉框存在', () => {
mount()
expect(win.body.querySelector('select.text-input')).toBeTruthy()
})
it('删除按钮存在', () => {
store.set('clock', { cities: ['上海'], alarms: [] })
mount()
expect(win.body.querySelector('.clock-city .weather-del')).toBeTruthy()
})
it('删除城市后从列表移除', async () => {
store.set('clock', { cities: ['上海', '东京'], alarms: [] })
mount()
const delBtn = win.body.querySelector('.clock-city .weather-del') as HTMLElement
delBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const remaining = store.get('clock', {}).cities
expect(remaining).not.toContain('上海')
})
it('通过 select 添加城市', async () => {
store.set('clock', { cities: ['北京'], alarms: [] })
mount()
const select = win.body.querySelector('select') as HTMLSelectElement
expect(select).toBeTruthy()
// select 中有未添加的城市 option
const options = [...select.options].map((o: any) => o.value)
expect(options).toContain('上海')
})
})
// ==================== 闹钟 ====================
describe('闹钟', () => {
function goAlarm(win: any) {
const tabs = win.body.querySelectorAll('.store-tab')
;([...tabs].find(t => t.textContent.includes('闹钟')) as HTMLElement)
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
it('初始无闹钟显示"没有闹钟"', async () => {
store.set('clock', { cities: [], alarms: [] })
mount()
goAlarm(win); await nextTick()
expect(win.body.querySelector('.empty-state')?.textContent).toContain('没有闹钟')
})
it('预设闹钟显示在列表', async () => {
store.set('clock', { cities: [], alarms: [{ id: 'a1', h: 8, m: 30, label: '起床', on: false }] })
mount(); goAlarm(win); await nextTick()
expect(win.body.querySelector('.alarm-time')?.textContent).toBe('08:30')
expect(win.body.querySelector('.alarm-label')?.textContent).toBe('起床')
})
it('闹钟开关 toggle', async () => {
store.set('clock', { cities: [], alarms: [{ id: 'a1', h: 7, m: 0, label: '测试', on: false }] })
mount(); goAlarm(win); await nextTick()
const sw = win.body.querySelector('.switch') as HTMLElement
expect(sw.classList.contains('on')).toBe(false)
sw.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const data = store.get('clock', {})
expect(data.alarms[0].on).toBe(true)
})
it('添加闹钟', async () => {
store.set('clock', { cities: [], alarms: [] })
mount(); goAlarm(win); await nextTick()
const addBtn = win.body.querySelector('.btn.primary') as HTMLElement
addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const data = store.get('clock', {})
expect(data.alarms.length).toBe(1)
expect(data.alarms[0].on).toBe(true)
})
it('删除闹钟', async () => {
store.set('clock', { cities: [], alarms: [{ id: 'a1', h: 8, m: 0, label: 'T', on: false }] })
mount(); goAlarm(win); await nextTick()
const delBtn = win.body.querySelector('.alarm-row .weather-del') as HTMLElement
delBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const data = store.get('clock', {})
expect(data.alarms.length).toBe(0)
})
})
// ==================== 秒表 ====================
describe('秒表', () => {
function goSw(win: any) {
const tabs = win.body.querySelectorAll('.store-tab')
;([...tabs].find(t => t.textContent.includes('秒表')) as HTMLElement)
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
it('初始显示 00:00.0', async () => {
mount(); goSw(win); await nextTick()
expect(win.body.querySelector('.sw-disp')?.textContent).toBe('00:00.0')
})
it('开始按钮默认显示"开始"', async () => {
mount(); goSw(win); await nextTick()
const btns = win.body.querySelectorAll('.sw-btn')
expect([...btns].some(b => b.textContent === '开始')).toBe(true)
})
it('计次按钮初始禁用', async () => {
mount(); goSw(win); await nextTick()
const btn = win.body.querySelector('.sw-btn') as HTMLButtonElement
expect(btn.disabled).toBe(true)
})
it('复位按钮初始禁用', async () => {
mount(); goSw(win); await nextTick()
const btns = win.body.querySelectorAll('.sw-btn')
const resetBtn = [...btns].find(b => b.textContent === '复位') as HTMLButtonElement
expect(resetBtn.disabled).toBe(true)
})
// ⚠️ jsdom 中 now.value 需真实时间流逝才能计算 acc
// 两次点击之间加入小延时让 Date.now() 推进
it('点击开始→暂停→继续', async () => {
mount(); goSw(win); await nextTick()
const primaryBtn = win.body.querySelector('.btn.primary') as HTMLElement
primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(primaryBtn.textContent).toBe('暂停')
await new Promise(r => setTimeout(r, 20)) // 让 now 推进
primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(primaryBtn.textContent).toBe('继续')
})
it('开始后计次按钮可用', async () => {
mount(); goSw(win); await nextTick()
const primaryBtn = win.body.querySelector('.btn.primary') as HTMLElement
primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const lapBtn = win.body.querySelector('.sw-btn') as HTMLButtonElement
expect(lapBtn.disabled).toBe(false)
})
it('计次添加 lap', async () => {
mount(); goSw(win); await nextTick()
const primaryBtn = win.body.querySelector('.btn.primary') as HTMLElement
primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const lapBtn = win.body.querySelectorAll('.sw-btn')[0] as HTMLElement
lapBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(win.body.querySelector('.sw-lap')).toBeTruthy()
})
// ⚠️ jsdom 中两次点击 start→pause 后需延时让 acc 累积
it('暂停后复位按钮可用', async () => {
mount(); goSw(win); await nextTick()
const primaryBtn = win.body.querySelector('.btn.primary') as HTMLElement
primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
await new Promise(r => setTimeout(r, 20))
primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const btns = win.body.querySelectorAll('.sw-btn')
const resetBtn = [...btns].find(b => b.textContent === '复位') as HTMLButtonElement
expect(resetBtn.disabled).toBe(false)
})
})
// ==================== 计时器 ====================
describe('计时器', () => {
function goTimer(win: any) {
const tabs = win.body.querySelectorAll('.store-tab')
;([...tabs].find(t => t.textContent.includes('计时器')) as HTMLElement)
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
it('默认显示 05:00', async () => {
mount(); goTimer(win); await nextTick()
expect(win.body.querySelector('.timer-ring .sw-disp')?.textContent).toBe('05:00')
})
it('有分钟和秒钟输入框', async () => {
mount(); goTimer(win); await nextTick()
const inputs = win.body.querySelectorAll('.clock-addrow input[type="number"]')
expect(inputs.length).toBeGreaterThanOrEqual(2)
})
it('有开始和复位按钮', async () => {
mount(); goTimer(win); await nextTick()
const btns = win.body.querySelectorAll('.sw-btn')
expect(btns.length).toBe(2)
})
it('点击开始后按钮变为暂停', async () => {
mount(); goTimer(win); await nextTick()
const startBtn = win.body.querySelector('.btn.primary') as HTMLElement
startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(startBtn.textContent).toBe('暂停')
})
it('点击暂停后变为继续', async () => {
mount(); goTimer(win); await nextTick()
const startBtn = win.body.querySelector('.btn.primary') as HTMLElement
startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(startBtn.textContent).toBe('继续')
})
it('运行时输入框禁用', async () => {
mount(); goTimer(win); await nextTick()
const startBtn = win.body.querySelector('.btn.primary') as HTMLElement
startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const inputs = win.body.querySelectorAll('.clock-addrow input[type="number"]')
expect((inputs[0] as HTMLInputElement).disabled).toBe(true)
})
it('复位后恢复 05:00 显示', async () => {
mount(); goTimer(win); await nextTick()
const startBtn = win.body.querySelector('.btn.primary') as HTMLElement
startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const btns = win.body.querySelectorAll('.sw-btn')
const resetBtn = [...btns].find(b => b.textContent === '复位') as HTMLElement
resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(win.body.querySelector('.timer-ring .sw-disp')?.textContent).toBe('05:00')
})
})
// ==================== 持久化 ====================
describe('持久化', () => {
it('store 加载预设数据', () => {
store.set('clock', { cities: ['上海', '东京'], alarms: [] })
mount()
expect(win.body.querySelectorAll('.clock-city').length).toBe(2)
})
it('修改后 store 更新', async () => {
store.set('clock', { cities: ['北京'], alarms: [] })
mount()
const delBtn = win.body.querySelector('.weather-del') as HTMLElement
delBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const data = store.get('clock', {})
expect(data.cities).not.toContain('北京')
})
})
// ==================== 卸载 ====================
describe('卸载', () => {
it('卸载后 DOM 为空', () => {
mount()
render(null, win.body)
expect(win.body.children.length).toBe(0)
})
})
})