fix: stabilize calendar detail lifecycle
This commit is contained in:
@@ -4,7 +4,7 @@ const app=readFileSync('src/App.vue','utf8');const utils=readFileSync('src/lib/m
|
||||
describe('calendar shell integration',()=>{
|
||||
it('puts calendar beside the other first-level tools on desktop and mobile',()=>{const nav=app.slice(app.indexOf('<nav class="primary-nav">'),app.indexOf('</nav>',app.indexOf('<nav class="primary-nav">')));expect(nav.indexOf("switchView('habits')")).toBeLessThan(nav.indexOf("switchView('countdowns')"));expect(nav.indexOf("switchView('countdowns')")).toBeLessThan(nav.indexOf("switchView('memos')"));expect(nav.indexOf("switchView('memos')")).toBeLessThan(nav.indexOf("switchView('calendar')"));const bottom=app.slice(app.indexOf('<nav class="bottom"'),app.indexOf('</nav>',app.indexOf('<nav class="bottom"')));for(const view of ['today','habits','countdowns','memos','calendar'])expect(bottom).toContain(`switchView('${view}')`);expect(bottom).not.toContain("switchView('settings')")})
|
||||
it('persists calendar navigation and mounts the panel',()=>{expect(utils).toContain("'calendar'");expect(app).toContain("import CalendarPanel from './CalendarPanel.vue'");expect(app).toContain("activeView==='calendar'")})
|
||||
it('places desktop event detail in the shell right pane while retaining the mobile sheet',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(app).toContain("'detail-open': Boolean(selectedTask) || habitDetailOpen || calendarDetailOpen");expect(app).toContain(':compact-layout="compactLayout" @detail="calendarDetailOpen=$event"');expect(app).toContain("selectedTask || habitDetailOpen || calendarDetailOpen");expect(app).toContain('watch([selectedTask, habitDetailOpen, calendarDetailOpen, sidebarCollapsed], reconcileDesktopPaneWidths)');expect(panel).toContain('defineProps<{ compactLayout: boolean }>()');expect(panel).toContain(":key=\"compactLayout?'calendar-event-mobile':'calendar-event-desktop'\"");expect(panel).toContain(':modal="compactLayout"');expect(panel).toContain('inline-target=".shell"');expect(panel).toContain("watch(selected,event=>emit('detail',Boolean(event)))");expect(panel).toContain("watch(()=>props.compactLayout,()=>{selected.value=null})");expect(css).toContain('@media(min-width:931px){.calendar-view{padding-top:4px}.calendar-event-detail')})
|
||||
it('places desktop event detail in the shell right pane while retaining the mobile sheet',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(app).toContain("'detail-open': Boolean(selectedTask) || habitDetailOpen || calendarDetailOpen");expect(app).toContain(':compact-layout="compactLayout" @detail="calendarDetailOpen=$event"');expect(app).toContain("selectedTask || habitDetailOpen || calendarDetailOpen");expect(app).toContain('watch([selectedTask, habitDetailOpen, calendarDetailOpen, sidebarCollapsed], reconcileDesktopPaneWidths)');expect(panel).toContain('defineProps<{ compactLayout: boolean }>()');expect(panel).toContain(':modal="compactLayout"');expect(panel).toContain('inline-target=".shell"');expect(panel).toContain("watch(selected,event=>emit('detail',Boolean(event)))");expect(panel).toContain("watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'})");expect(panel).not.toContain("onBeforeUnmount(()=>emit('detail',false))");expect(css).toContain('@media(min-width:931px){.calendar-view{padding-top:4px}.calendar-event-detail')})
|
||||
it('keeps five 44px mobile targets across required breakpoints',()=>{const shellCss=readFileSync('src/style.css','utf8');expect(shellCss).toContain('grid-template-columns:repeat(5,minmax(0,1fr))');expect(css).toContain('min-height:44px');expect(css).toContain('@media(max-width:720px)');expect(css).toContain('@media(min-width:931px)');expect(css).toContain('@media(min-width:1440px)')})
|
||||
it('uses the shell page title once and contains the horizontal source scroller',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(panel).not.toContain('<h1>日历订阅</h1>');expect(css).toContain('.calendar-view{min-width:0;');expect(css).toContain('.calendar-filters{min-width:0;max-width:100%;')})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ const subscriptions = [{ id:'s1', name:'工作', url:'https://example.com/work.i
|
||||
const events = [{ id:'e1', title:'发布会', starts_at:'2026-09-22T02:00:00Z', ends_at:'2026-09-22T03:00:00Z', all_day:false, description:'产品发布', location:'会议室', source_id:'s1', source_name:'工作', color:'#f15a29' }]
|
||||
const json = (value:unknown, status=200) => new Response(JSON.stringify(value), { status, headers:{'content-type':'application/json'} })
|
||||
async function flush(){ await Promise.resolve(); await new Promise(r=>vi.isFakeTimers()?vi.advanceTimersByTimeAsync(0).then(()=>r(undefined)):setTimeout(r,0)); await nextTick() }
|
||||
async function mount(fetchMock:ReturnType<typeof vi.fn>){ vi.stubGlobal('fetch',fetchMock); const host=document.createElement('div');document.body.append(host);const notices:string[]=[];const app=createApp(()=>h(CalendarPanel,{compactLayout:true,onNotice:(v:string)=>notices.push(v)}));app.mount(host);cleanups.push(()=>{app.unmount();host.remove()});await flush();return {host,notices} }
|
||||
async function mount(fetchMock:ReturnType<typeof vi.fn>,compactLayout=true){ vi.stubGlobal('fetch',fetchMock); const shell=document.createElement('div');shell.className='shell';const host=document.createElement('div');shell.append(host);document.body.append(shell);const notices:string[]=[];const errors:unknown[]=[];const app=createApp(()=>h(CalendarPanel,{compactLayout,onNotice:(v:string)=>notices.push(v)}));app.config.errorHandler=error=>errors.push(error);app.mount(host);cleanups.push(()=>{app.unmount();shell.remove()});await flush();return {host,shell,notices,errors} }
|
||||
afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.useRealTimers();vi.unstubAllGlobals();vi.restoreAllMocks()})
|
||||
|
||||
describe('CalendarPanel',()=>{
|
||||
@@ -66,6 +66,17 @@ describe('CalendarPanel',()=>{
|
||||
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('opens and closes desktop event detail without teleport patch errors',async()=>{
|
||||
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,22,10))
|
||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events,sources:[]}:subscriptions)))
|
||||
const {host,shell,errors}=await mount(fetchMock,false)
|
||||
host.querySelector<HTMLButtonElement>('[data-event-id="e1"]')!.click();await nextTick()
|
||||
expect(shell.querySelector('.calendar-event-detail')).not.toBeNull()
|
||||
shell.querySelector<HTMLButtonElement>('[aria-label="关闭日程详情"]')!.click();await nextTick()
|
||||
expect(shell.querySelector('.calendar-event-detail')).toBeNull()
|
||||
expect(errors).toEqual([])
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('filters duplicate source names by source id',async()=>{
|
||||
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
|
||||
const duplicateSubscriptions=[subscriptions[0],{...subscriptions[0],id:'s2',url:'https://example.com/personal.ics',color:'#334455'}]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-vue-next'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { formatApiErrorDetail } from './lib/mvp-utils'
|
||||
@@ -56,8 +56,7 @@ async function refresh(item:Subscription){if(busyId.value)return;busyId.value=it
|
||||
async function remove(item:Subscription){if(busyId.value)return;if(await appDialog.value?.show({title:`删除“${item.name}”?`,description:'该来源的事件也会从日历中移除。',danger:true,confirmText:'删除'})!==true)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'DELETE'});hiddenSources.value.delete(item.id);await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice','日历订阅已删除')}catch(reason){error.value=reason instanceof Error?reason.message:'删除失败'}finally{busyId.value=''}}
|
||||
watch(manageOpen,open=>{if(!open)formOpen.value=false})
|
||||
watch(selected,event=>emit('detail',Boolean(event)))
|
||||
watch(()=>props.compactLayout,()=>{selected.value=null})
|
||||
onBeforeUnmount(()=>emit('detail',false))
|
||||
watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'})
|
||||
onMounted(()=>void load())
|
||||
</script>
|
||||
|
||||
@@ -70,7 +69,7 @@ onMounted(()=>void load())
|
||||
<div v-if="subscriptions.length" class="calendar-filters" aria-label="筛选日历源"><label v-for="source in subscriptions" :key="source.id"><input type="checkbox" :aria-label="`筛选${source.name}`" :checked="!hiddenSources.has(source.id)" @change="toggleFilter(source.id)"><i :style="{background:source.color}"/>{{source.name}}</label></div>
|
||||
<div v-if="visibleEvents.length" class="calendar-agenda"><section><h2>{{selectedDayLabel}} · {{visibleEvents.length}} 个日程</h2><button v-for="event in visibleEvents" :key="eventKey(event)" :data-event-id="event.id" class="calendar-event-row" @click="selected=event"><i :style="{background:eventColor(event)}"/><time>{{displayTime(event)}}</time><span><b>{{eventTitle(event)}}</b><small>{{eventSource(event)}}<template v-if="event.location"> · {{event.location}}</template></small></span><ChevronRight/></button></section></div>
|
||||
<div v-else-if="!loading" class="calendar-empty"><CalendarDays/><b>这一天还没有日程</b><span>{{subscriptions.length?'可以选择本周其他日期或检查来源筛选':'先添加一个 iCal 日历订阅'}}</span><button v-if="!subscriptions.length" class="primary-small" @click="manageOpen=true;openCreate()">添加日历源</button></div>
|
||||
<AppSheet :key="compactLayout?'calendar-event-mobile':'calendar-event-desktop'" :open="Boolean(selected)" :modal="compactLayout" inline-target=".shell" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" :close-on-scrim="compactLayout" @close="selected=null"><template v-if="selected"><header class="app-sheet__header"><h3 id="calendar-event-title">{{eventTitle(selected)}}</h3><button aria-label="关闭日程详情" @click="selected=null"><X/></button></header><div class="app-sheet__body"><dl><div><dt>时间</dt><dd>{{displayDay(localDayKey(eventStart(selected)))}} {{displayTime(selected)}}<template v-if="eventEnd(selected) && !selected.all_day"> – {{displayTime({...selected,starts_at:eventEnd(selected)})}}</template></dd></div><div><dt>来源</dt><dd><i :style="{background:eventColor(selected)}"/>{{eventSource(selected)}}</dd></div><div v-if="selected.location"><dt>地点</dt><dd>{{selected.location}}</dd></div></dl><section v-if="selected.description"><h4>备注</h4><p>{{selected.description}}</p></section></div></template></AppSheet>
|
||||
<AppSheet :open="Boolean(selected)" :modal="compactLayout" inline-target=".shell" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" :close-on-scrim="compactLayout" @close="selected=null"><template v-if="selected"><header class="app-sheet__header"><h3 id="calendar-event-title">{{eventTitle(selected)}}</h3><button aria-label="关闭日程详情" @click="selected=null"><X/></button></header><div class="app-sheet__body"><dl><div><dt>时间</dt><dd>{{displayDay(localDayKey(eventStart(selected)))}} {{displayTime(selected)}}<template v-if="eventEnd(selected) && !selected.all_day"> – {{displayTime({...selected,starts_at:eventEnd(selected)})}}</template></dd></div><div><dt>来源</dt><dd><i :style="{background:eventColor(selected)}"/>{{eventSource(selected)}}</dd></div><div v-if="selected.location"><dt>地点</dt><dd>{{selected.location}}</dd></div></dl><section v-if="selected.description"><h4>备注</h4><p>{{selected.description}}</p></section></div></template></AppSheet>
|
||||
<AppSheet :open="manageOpen" variant="detail" panel-class="calendar-sources-sheet" title-id="calendar-sources-title" initial-focus="button[aria-label='关闭日历源']" @close="manageOpen=false"><header class="app-sheet__header"><h3 id="calendar-sources-title">日历源</h3><button aria-label="关闭日历源" @click="manageOpen=false"><X/></button></header><div class="app-sheet__body"><button class="primary-small calendar-source-add" aria-label="添加日历订阅" @click="openCreate"><Plus/>添加订阅</button><div class="calendar-source-list"><article v-for="source in subscriptions" :key="source.id"><div class="calendar-source-copy"><b><i :style="{background:source.color}"/>{{source.name}}</b><small>{{source.url}}</small><small v-if="source.last_error" class="calendar-source-error" role="alert">{{source.last_error}}</small></div><label class="calendar-source-toggle"><input type="checkbox" :aria-label="`启用${source.name}`" :checked="source.enabled" :disabled="Boolean(busyId)" @change="toggleEnabled(source)"><span>启用</span></label><button :aria-label="`刷新${source.name}`" :disabled="Boolean(busyId)" @click="refresh(source)"><RefreshCw/></button><button :aria-label="`编辑${source.name}`" :disabled="Boolean(busyId)" @click="openEdit(source)"><Pencil/></button><button class="danger-text" :aria-label="`删除${source.name}`" :disabled="Boolean(busyId)" @click="remove(source)"><Trash2/></button></article></div></div></AppSheet>
|
||||
<AppSheet :open="formOpen" variant="create" panel-class="calendar-subscription-form" title-id="calendar-form-title" initial-focus="input[aria-label='订阅名称']" :busy="busyId==='form'" @close="formOpen=false" @submit.prevent="save"><header class="app-sheet__header"><h3 id="calendar-form-title">{{editing?'编辑订阅':'添加订阅'}}</h3><button type="button" aria-label="关闭订阅表单" @click="formOpen=false"><X/></button></header><div class="app-sheet__body"><label>名称<input v-model="form.name" aria-label="订阅名称" maxlength="120" required placeholder="例如:工作"></label><label>iCal 地址<input v-model="form.url" aria-label="订阅地址" type="url" required placeholder="https://example.com/calendar.ics"></label><label>颜色<input v-model="form.color" aria-label="订阅颜色" type="color"></label><label class="calendar-form-toggle"><input v-model="form.enabled" type="checkbox">启用此订阅</label></div><footer class="app-sheet__footer"><button type="button" class="secondary" @click="formOpen=false">取消</button><button type="submit" class="primary-small" :disabled="Boolean(busyId)||!form.name.trim()||!form.url.trim()">保存</button></footer></AppSheet>
|
||||
<AppDialog ref="appDialog"/>
|
||||
|
||||
Reference in New Issue
Block a user