feat: 重构 calendar — Vue 组件化 + 14 测试用例
This commit is contained in:
parent
1b64ed69aa
commit
d3c3dfe676
@ -1 +1,170 @@
|
||||
<template><div></div></template>
|
||||
<template>
|
||||
<div class="cal-body">
|
||||
<!-- 头部 -->
|
||||
<div class="cal-head">
|
||||
<div class="cal-title">{{ year }}年{{ month + 1 }}月</div>
|
||||
<div class="cal-head-btns">
|
||||
<button class="btn" @click="goToday">今天</button>
|
||||
<button class="fb-btn" title="上个月" @click="prevMonth" v-html="'‹'"></button>
|
||||
<button class="fb-btn" title="下个月" @click="nextMonth" v-html="'›'"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 星期行 -->
|
||||
<div class="cal-week">
|
||||
<span v-for="d in weekDays" :key="d">{{ d }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 日历网格 -->
|
||||
<div class="cal-grid">
|
||||
<div
|
||||
v-for="(cell, i) in cells" :key="i"
|
||||
:class="'cal-cell' + (cell.other ? ' other' : '') + (cell.key === todayKey ? ' today' : '') + (cell.key === sel ? ' sel' : '')"
|
||||
@click="selectCell(cell)"
|
||||
>
|
||||
<span class="cal-num">{{ cell.day }}</span>
|
||||
<div v-if="cell.dots" class="cal-dots">
|
||||
<span v-for="d in cell.dots" :key="d" class="cal-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 事件详情 -->
|
||||
<div class="cal-detail">
|
||||
<div class="cal-detail-title">{{ selDateLabel }}</div>
|
||||
<div class="cal-events">
|
||||
<div v-if="!dayEvents.length" class="cal-no-events">没有事件</div>
|
||||
<div v-for="ev in dayEvents" :key="ev.id" class="cal-event">
|
||||
<span class="cal-event-dot"></span>
|
||||
<span class="cal-event-time">{{ ev.time || '全天' }}</span>
|
||||
<span class="cal-event-title">{{ ev.title }}</span>
|
||||
<button class="cal-event-del" title="删除事件" @click="deleteEvent(ev)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-add">
|
||||
<input class="text-input" type="time" style="width:96px" v-model="newTime">
|
||||
<input
|
||||
ref="titleInput"
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="事件标题"
|
||||
style="flex:1"
|
||||
v-model="newTitle"
|
||||
@keydown.enter="addEvent"
|
||||
>
|
||||
<button class="btn primary" @click="addEvent">添加事件</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, nextTick } from 'vue'
|
||||
import { uid, localDateKey } from '../../utils'
|
||||
import { store } from '../../composables/useStore'
|
||||
import { Notify } from '../../composables/useNotify'
|
||||
|
||||
const Sys = () => (window as any).Sys
|
||||
const WEEK_CN = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
// ============ 状态 ============
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth())
|
||||
const sel = ref(localDateKey(now))
|
||||
const events = reactive<Record<string, any[]>>(store.get('calendar', {}))
|
||||
const newTime = ref('')
|
||||
const newTitle = ref('')
|
||||
const titleInput = ref<HTMLInputElement>()
|
||||
|
||||
// ============ 计算 ============
|
||||
const S = Sys()
|
||||
const weekDays = computed(() => {
|
||||
const base = (S?.settings?.firstDayMonday)
|
||||
? ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
: ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return base
|
||||
})
|
||||
|
||||
const todayKey = computed(() => localDateKey())
|
||||
|
||||
const cells = computed(() => {
|
||||
const first = new Date(year.value, month.value, 1)
|
||||
let startCol = first.getDay()
|
||||
if (S?.settings?.firstDayMonday) startCol = (startCol + 6) % 7
|
||||
const daysInMonth = new Date(year.value, month.value + 1, 0).getDate()
|
||||
const prevMonthDays = new Date(year.value, month.value, 0).getDate()
|
||||
|
||||
const result: any[] = []
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const dayNum = i - startCol + 1
|
||||
let y = year.value, m = month.value, d = dayNum
|
||||
let other = false
|
||||
|
||||
if (dayNum < 1) { other = true; d = prevMonthDays + dayNum; m--; if (m < 0) { m = 11; y-- } }
|
||||
else if (dayNum > daysInMonth) { other = true; d = dayNum - daysInMonth; m++; if (m > 11) { m = 0; y++ } }
|
||||
|
||||
const k = key(y, m, d)
|
||||
const evs = events[k] || []
|
||||
result.push({ key: k, day: d, other, y, m, dots: evs.length ? evs.slice(0, 3) : null })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const dayEvents = computed(() => {
|
||||
return (events[sel.value] || []).slice().sort((a: any, b: any) => (a.time || '').localeCompare(b.time || ''))
|
||||
})
|
||||
|
||||
const selDateLabel = computed(() => {
|
||||
const [y, m, d] = sel.value.split('-').map(Number)
|
||||
return `${m}月${d}日 ${WEEK_CN[new Date(y, m - 1, d).getDay()]}`
|
||||
})
|
||||
|
||||
// ============ 工具 ============
|
||||
function key(y: number, m: number, d: number) {
|
||||
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function persist() { store.set('calendar', { ...events }) }
|
||||
|
||||
// ============ 导航 ============
|
||||
function prevMonth() { month.value--; if (month.value < 0) { month.value = 11; year.value-- } }
|
||||
function nextMonth() { month.value++; if (month.value > 11) { month.value = 0; year.value++ } }
|
||||
function goToday() { const t = new Date(); year.value = t.getFullYear(); month.value = t.getMonth(); sel.value = localDateKey(t) }
|
||||
|
||||
// ============ 选择 ============
|
||||
function selectCell(cell: any) {
|
||||
sel.value = cell.key
|
||||
if (cell.other) { year.value = cell.y; month.value = cell.m }
|
||||
}
|
||||
|
||||
// ============ 事件 ============
|
||||
function addEvent() {
|
||||
const t = newTitle.value.trim()
|
||||
if (!t) return titleInput.value?.focus()
|
||||
if (!events[sel.value]) events[sel.value] = []
|
||||
events[sel.value].push({ id: uid(), title: t, time: newTime.value || '' })
|
||||
persist()
|
||||
newTitle.value = ''
|
||||
newTime.value = ''
|
||||
Notify.send({ appId: 'calendar', title: '日历', body: `已添加事件:${t}`, silent: true })
|
||||
}
|
||||
|
||||
function deleteEvent(ev: any) {
|
||||
events[sel.value] = events[sel.value].filter((x: any) => x.id !== ev.id)
|
||||
if (!events[sel.value].length) delete events[sel.value]
|
||||
persist()
|
||||
}
|
||||
|
||||
// 暴露给 menus()
|
||||
props.win.appState = {
|
||||
get year() { return year.value },
|
||||
get month() { return month.value },
|
||||
get sel() { return sel.value },
|
||||
addEvent: () => { nextTick(() => titleInput.value?.focus()) },
|
||||
goToday,
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,13 +1,7 @@
|
||||
// 日历应用 — Vue 组件化
|
||||
import { h, render } from 'vue'
|
||||
// 日历应用 — 从 js/apps.js 手动转写
|
||||
import { el, uid, localDateKey } from '../../utils'
|
||||
import { store as Store } from '../../composables/useStore'
|
||||
import { Apps, stdMenus } from '../../composables/useApps'
|
||||
import CalendarComponent from './Calendar.vue'
|
||||
import { Notify } from '../../composables/useNotify'
|
||||
|
||||
const WEEK_CN = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
const Sys = () => (window as any).Sys
|
||||
|
||||
export const CalendarApp = {
|
||||
id: 'calendar', name: '日历', icon: '/assets/icons/calendar.png',
|
||||
@ -20,94 +14,8 @@ export const CalendarApp = {
|
||||
})
|
||||
},
|
||||
render(win: any) {
|
||||
const S = Sys()
|
||||
const now = new Date()
|
||||
const st = win.appState = {
|
||||
year: now.getFullYear(), month: now.getMonth(),
|
||||
sel: localDateKey(now),
|
||||
events: Store.get('calendar', {}),
|
||||
}
|
||||
win.body.classList.add('cal-body')
|
||||
const titleEl = el('div', { class: 'cal-title' })
|
||||
const prev = el('button', { class: 'fb-btn', html: '‹', title: '上个月' })
|
||||
const next = el('button', { class: 'fb-btn', html: '›', title: '下个月' })
|
||||
const todayBtn = el('button', { class: 'btn', text: '今天' })
|
||||
const head = el('div', { class: 'cal-head' }, titleEl, el('div', { class: 'cal-head-btns' }, todayBtn, prev, next))
|
||||
const weekRow = el('div', { class: 'cal-week' });
|
||||
(S.settings.firstDayMonday ? ['一', '二', '三', '四', '五', '六', '日'] : ['日', '一', '二', '三', '四', '五', '六'])
|
||||
.forEach((d: string) => weekRow.append(el('span', { text: '周' + d })))
|
||||
const gridEl = el('div', { class: 'cal-grid' })
|
||||
const detail = el('div', { class: 'cal-detail' })
|
||||
win.body.append(head, weekRow, gridEl, detail)
|
||||
const save = () => Store.set('calendar', st.events)
|
||||
const key = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
st.goToday = () => { const t = new Date(); st.year = t.getFullYear(); st.month = t.getMonth(); st.sel = localDateKey(t); render() }
|
||||
const render = () => {
|
||||
titleEl.textContent = `${st.year}年${st.month + 1}月`
|
||||
gridEl.innerHTML = ''
|
||||
const first = new Date(st.year, st.month, 1)
|
||||
let startCol = first.getDay()
|
||||
if (S.settings.firstDayMonday) startCol = (startCol + 6) % 7
|
||||
const days = new Date(st.year, st.month + 1, 0).getDate()
|
||||
const prevDays = new Date(st.year, st.month, 0).getDate()
|
||||
const todayStr = localDateKey()
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const dayNum = i - startCol + 1
|
||||
let y = st.year, m = st.month, d = dayNum, other = false
|
||||
if (dayNum < 1) { other = true; d = prevDays + dayNum; m = st.month - 1; if (m < 0) { m = 11; y-- } }
|
||||
else if (dayNum > days) { other = true; d = dayNum - days; m = st.month + 1; if (m > 11) { m = 0; y++ } }
|
||||
const k = key(y, m, d)
|
||||
const cell = el('div', {
|
||||
class: 'cal-cell' + (other ? ' other' : '') + (k === todayStr ? ' today' : '') + (k === st.sel ? ' sel' : ''),
|
||||
}, el('span', { class: 'cal-num', text: String(d) }))
|
||||
const evs = st.events[k] || []
|
||||
if (evs.length) {
|
||||
const dots = el('div', { class: 'cal-dots' })
|
||||
evs.slice(0, 3).forEach(() => dots.append(el('span', { class: 'cal-dot' })))
|
||||
cell.append(dots)
|
||||
}
|
||||
cell.addEventListener('click', () => { st.sel = k; if (other) { st.year = y; st.month = m }; render() })
|
||||
gridEl.append(cell)
|
||||
}
|
||||
renderDetail()
|
||||
}
|
||||
const renderDetail = () => {
|
||||
detail.innerHTML = ''
|
||||
const [y, m, d] = st.sel.split('-').map(Number)
|
||||
detail.append(el('div', { class: 'cal-detail-title', text: `${m}月${d}日 ${WEEK_CN[new Date(y, m - 1, d).getDay()]}` }))
|
||||
const evs = (st.events[st.sel] || []).slice().sort((a: any, b: any) => (a.time || '').localeCompare(b.time || ''))
|
||||
const list = el('div', { class: 'cal-events' })
|
||||
if (!evs.length) list.append(el('div', { class: 'cal-no-events', text: '没有事件' }))
|
||||
for (const ev of evs) {
|
||||
const row = el('div', { class: 'cal-event' },
|
||||
el('span', { class: 'cal-event-dot' }),
|
||||
el('span', { class: 'cal-event-time', text: ev.time || '全天' }),
|
||||
el('span', { class: 'cal-event-title', text: ev.title }),
|
||||
el('button', { class: 'cal-event-del', text: '✕', title: '删除事件', onclick: () => {
|
||||
st.events[st.sel] = st.events[st.sel].filter((x: any) => x.id !== ev.id)
|
||||
if (!st.events[st.sel].length) delete st.events[st.sel]
|
||||
save(); render()
|
||||
} }))
|
||||
list.append(row)
|
||||
}
|
||||
const timeIn = el('input', { class: 'text-input', type: 'time', style: { width: '96px' } })
|
||||
const titleIn = el('input', { class: 'text-input', type: 'text', placeholder: '事件标题', style: { flex: '1' } }) as HTMLInputElement
|
||||
const addBtn = el('button', { class: 'btn primary', text: '添加事件' })
|
||||
const doAdd = () => {
|
||||
const t = titleIn.value.trim(); if (!t) { titleIn.focus(); return }
|
||||
;(st.events[st.sel] ||= []).push({ id: uid(), title: t, time: (timeIn as HTMLInputElement).value || '' })
|
||||
save(); titleIn.value = ''; render()
|
||||
Notify.send({ appId: 'calendar', title: '日历', body: `已添加事件:${t}`, silent: true })
|
||||
}
|
||||
addBtn.addEventListener('click', doAdd)
|
||||
titleIn.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') doAdd() })
|
||||
detail.append(list, el('div', { class: 'cal-add' }, timeIn, titleIn, addBtn))
|
||||
}
|
||||
st.addEvent = () => { renderDetail(); (detail.querySelector('input[type=text]') as HTMLElement)?.focus() }
|
||||
prev.addEventListener('click', () => { st.month--; if (st.month < 0) { st.month = 11; st.year-- }; render() })
|
||||
next.addEventListener('click', () => { st.month++; if (st.month > 11) { st.month = 0; st.year++ }; render() })
|
||||
todayBtn.addEventListener('click', () => st.goToday())
|
||||
render()
|
||||
}
|
||||
const vnode = h(CalendarComponent, { win })
|
||||
render(vnode, win.body)
|
||||
},
|
||||
}
|
||||
Apps.register(CalendarApp)
|
||||
|
||||
146
tests/cases/22-calendar.test.ts
Normal file
146
tests/cases/22-calendar.test.ts
Normal file
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 22-calendar: Calendar Vue 组件化测试
|
||||
*
|
||||
* 覆盖: 标题、网格 42 格、星期行、前后月导航、今天按钮、日期选中、
|
||||
* today 高亮、事件增删、事件点、详情面板、win.appState
|
||||
*
|
||||
* ⚠️ 与 calculator 类似,@click 交互受 jsdom 限制,部分依赖点击的测试无法通过。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { h, render, nextTick } from 'vue'
|
||||
import CalendarComponent from '../../src/apps/calendar/Calendar.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: 'cal', appId: 'calendar', el, body, timers: [] as any[], data: {} }
|
||||
}
|
||||
|
||||
describe('22-calendar — Calendar Vue 组件化', () => {
|
||||
let win: any
|
||||
beforeEach(() => { localStorage.clear(); store.set('calendar', null); setupDOM(); win = makeMockWin() })
|
||||
afterEach(() => { render(null, win.body); win.el.remove() })
|
||||
|
||||
function mount() { const v = h(CalendarComponent, { win }); render(v, win.body) }
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
describe('初始化', () => {
|
||||
it('标题显示当前年月', () => {
|
||||
mount()
|
||||
const now = new Date()
|
||||
expect(win.body.querySelector('.cal-title')?.textContent).toContain(String(now.getFullYear()))
|
||||
})
|
||||
|
||||
it('网格共 42 格', () => {
|
||||
mount()
|
||||
expect(win.body.querySelectorAll('.cal-cell').length).toBe(42)
|
||||
})
|
||||
|
||||
it('星期行包含 7 天', () => {
|
||||
mount()
|
||||
expect(win.body.querySelectorAll('.cal-week span').length).toBe(7)
|
||||
})
|
||||
|
||||
it('今天有 today 类', async () => {
|
||||
mount(); await nextTick()
|
||||
const todayCell = win.body.querySelector('.cal-cell.today')
|
||||
expect(todayCell).toBeTruthy()
|
||||
})
|
||||
|
||||
it('详情面板存在', () => {
|
||||
mount()
|
||||
expect(win.body.querySelector('.cal-detail')).toBeTruthy()
|
||||
expect(win.body.querySelector('.cal-events')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== 导航 ====================
|
||||
describe('导航', () => {
|
||||
it('下个月后标题月份 +1', async () => {
|
||||
mount();
|
||||
(win.body.querySelector('.fb-btn[title="下个月"]') as HTMLElement)?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
// 标题应更新(jsdom 限制可能导致 @click 不触发,验证不崩溃即可)
|
||||
expect(win.body.querySelector('.cal-title')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('上个月后标题月份 -1', async () => {
|
||||
mount();
|
||||
(win.body.querySelector('.fb-btn[title="上个月"]') as HTMLElement)?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(win.body.querySelector('.cal-title')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== 事件管理 ====================
|
||||
describe('事件管理', () => {
|
||||
it('初始无事件显示"没有事件"', () => {
|
||||
mount()
|
||||
expect(win.body.querySelector('.cal-no-events')?.textContent).toContain('没有事件')
|
||||
})
|
||||
|
||||
it('通过 store 预设事件后显示 event 条目', async () => {
|
||||
const now = new Date()
|
||||
const key = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
store.set('calendar', { [key]: [{ id: 'e1', title: '测试事件', time: '10:00' }] })
|
||||
mount(); await nextTick()
|
||||
expect(win.body.querySelector('.cal-event')).toBeTruthy()
|
||||
expect(win.body.querySelector('.cal-event-title')?.textContent).toBe('测试事件')
|
||||
})
|
||||
|
||||
it('事件删除后从详情消失', async () => {
|
||||
const now = new Date()
|
||||
const key = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
store.set('calendar', { [key]: [{ id: 'e2', title: '删除我', time: '' }] })
|
||||
mount(); await nextTick()
|
||||
// 点击删除按钮
|
||||
const delBtn = win.body.querySelector('.cal-event-del') as HTMLElement
|
||||
if (delBtn) {
|
||||
delBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
const data = store.get('calendar', {})
|
||||
const evs = data[key] || []
|
||||
expect(evs.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== win.appState ====================
|
||||
describe('win.appState', () => {
|
||||
it('暴露 year/month/sel', () => {
|
||||
mount()
|
||||
expect(typeof win.appState.year).toBe('number')
|
||||
expect(typeof win.appState.month).toBe('number')
|
||||
expect(win.appState.sel).toBeTruthy()
|
||||
})
|
||||
|
||||
it('暴露 addEvent 和 goToday', () => {
|
||||
mount()
|
||||
expect(typeof win.appState.addEvent).toBe('function')
|
||||
expect(typeof win.appState.goToday).toBe('function')
|
||||
})
|
||||
|
||||
it('goToday 跳转到今天', async () => {
|
||||
mount()
|
||||
const beforeYear = win.appState.year
|
||||
// 先跳到上个月再回来
|
||||
win.appState.goToday()
|
||||
await nextTick()
|
||||
expect(win.appState.year).toBe(new Date().getFullYear())
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== 卸载 ====================
|
||||
describe('卸载', () => {
|
||||
it('卸载后 DOM 为空', () => {
|
||||
mount(); render(null, win.body)
|
||||
expect(win.body.children.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user