feat: redesign calendar around weekly focus
This commit is contained in:
@@ -200,6 +200,26 @@ test('all bottom destinations expose one active page and desktop layout stays un
|
|||||||
expect(desktopBottomGap).toBe(84)
|
expect(desktopBottomGap).toBe(84)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('calendar week focus keeps seven usable day controls without page overflow', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: [{ id:'calendar-source', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:null, last_error:null, stale:false }] }))
|
||||||
|
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } }))
|
||||||
|
await page.goto('/')
|
||||||
|
const mobile = (await page.viewportSize())!.width <= 930
|
||||||
|
if (mobile) await bottomTab(page, '日历订阅').click()
|
||||||
|
else await page.locator('.primary-nav').getByRole('button', { name: '日历订阅', exact: true }).click()
|
||||||
|
const strip = page.getByRole('group', { name: '选择日期' })
|
||||||
|
await expect(strip).toBeVisible()
|
||||||
|
await expect(strip.locator('.calendar-week-day')).toHaveCount(7)
|
||||||
|
const metrics = await strip.evaluate(element => ({
|
||||||
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
|
stripOverflow: element.scrollWidth > element.clientWidth,
|
||||||
|
buttons: [...element.querySelectorAll<HTMLElement>('.calendar-week-day')].map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height })),
|
||||||
|
}))
|
||||||
|
expect(metrics.documentOverflow).toBe(0)
|
||||||
|
expect(metrics.buttons.every(button => button.width >= 44 && button.height >= 44)).toBeTruthy()
|
||||||
|
if ((await page.viewportSize())!.width <= 390) expect(metrics.stripOverflow).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
|
test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await openSettings(page)
|
await openSettings(page)
|
||||||
|
|||||||
@@ -6,14 +6,55 @@ const cleanups: Array<() => void> = []
|
|||||||
const subscriptions = [{ id:'s1', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:'2026-09-20T08:00:00Z', last_error:null, stale:false }]
|
const subscriptions = [{ id:'s1', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:'2026-09-20T08:00:00Z', last_error:null, stale:false }]
|
||||||
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 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'} })
|
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=>setTimeout(r,0)); await nextTick() }
|
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,{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>){ vi.stubGlobal('fetch',fetchMock); const host=document.createElement('div');document.body.append(host);const notices:string[]=[];const app=createApp(()=>h(CalendarPanel,{onNotice:(v:string)=>notices.push(v)}));app.mount(host);cleanups.push(()=>{app.unmount();host.remove()});await flush();return {host,notices} }
|
||||||
afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.unstubAllGlobals();vi.restoreAllMocks()})
|
afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.useRealTimers();vi.unstubAllGlobals();vi.restoreAllMocks()})
|
||||||
|
|
||||||
describe('CalendarPanel',()=>{
|
describe('CalendarPanel',()=>{
|
||||||
it('loads subscriptions and the visible month then filters and opens event detail',async()=>{
|
it('focuses the current week and shows only the selected day events',async()=>{
|
||||||
|
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
|
||||||
|
const weekEvents=[
|
||||||
|
{...events[0],id:'today',title:'今天日程',starts_at:new Date(2026,8,20,9).toISOString(),ends_at:new Date(2026,8,20,10).toISOString()},
|
||||||
|
{...events[0],id:'monday',title:'周一日程',starts_at:new Date(2026,8,14,9).toISOString(),ends_at:new Date(2026,8,14,10).toISOString()},
|
||||||
|
]
|
||||||
|
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:weekEvents,sources:[]}:subscriptions)))
|
||||||
|
const {host}=await mount(fetchMock)
|
||||||
|
expect(host.querySelectorAll('.calendar-week-day')).toHaveLength(7)
|
||||||
|
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('20')
|
||||||
|
expect(host.textContent).toContain('今天日程');expect(host.textContent).not.toContain('周一日程')
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.calendar-week-day')[0].click();await nextTick()
|
||||||
|
expect(host.textContent).toContain('周一日程');expect(host.textContent).not.toContain('今天日程')
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
it('moves one week at a time and can return to today',async()=>{
|
||||||
|
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
|
||||||
|
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
|
||||||
|
const {host}=await mount(fetchMock)
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
|
||||||
|
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('21')
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush()
|
||||||
|
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('20')
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
it('navigates to the exact next Monday across month and year boundaries',async()=>{
|
||||||
|
vi.useFakeTimers();vi.setSystemTime(new Date(2026,11,31,10))
|
||||||
|
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
|
||||||
|
const {host}=await mount(fetchMock)
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
|
||||||
|
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.getAttribute('data-day')).toBe('2027-01-04')
|
||||||
|
expect(host.textContent).toContain('2027年1月')
|
||||||
|
const eventCalls=fetchMock.mock.calls.filter(([url])=>String(url).includes('calendar-events'))
|
||||||
|
const latest=new URL(String(eventCalls.at(-1)?.[0]),'http://localhost').searchParams
|
||||||
|
expect(latest.get('start')).toBe(new Date(2027,0,4).toISOString())
|
||||||
|
expect(latest.get('end')).toBe(new Date(2027,0,11).toISOString())
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
it('loads subscriptions and the visible week then filters and opens event detail',async()=>{
|
||||||
|
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
|
||||||
const fetchMock=vi.fn((url:string)=>url.includes('calendar-events')?Promise.resolve(json({events,sources:[{id:'s1',name:'工作',stale:false}]})):Promise.resolve(json(subscriptions)))
|
const fetchMock=vi.fn((url:string)=>url.includes('calendar-events')?Promise.resolve(json({events,sources:[{id:'s1',name:'工作',stale:false}]})):Promise.resolve(json(subscriptions)))
|
||||||
const {host}=await mount(fetchMock)
|
const {host}=await mount(fetchMock)
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>('[data-day="2026-09-22"]')!.click();await nextTick()
|
||||||
const eventsUrl=String(fetchMock.mock.calls.find(([url])=>String(url).includes('calendar-events'))?.[0])
|
const eventsUrl=String(fetchMock.mock.calls.find(([url])=>String(url).includes('calendar-events'))?.[0])
|
||||||
const params=new URL(eventsUrl,'http://localhost').searchParams
|
const params=new URL(eventsUrl,'http://localhost').searchParams
|
||||||
expect(params.get('start')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
expect(params.get('start')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
||||||
@@ -23,39 +64,49 @@ describe('CalendarPanel',()=>{
|
|||||||
expect(document.querySelector('.calendar-event-detail')?.textContent).toContain('产品发布')
|
expect(document.querySelector('.calendar-event-detail')?.textContent).toContain('产品发布')
|
||||||
host.querySelector<HTMLInputElement>('input[aria-label="筛选工作"]')!.click();await nextTick()
|
host.querySelector<HTMLInputElement>('input[aria-label="筛选工作"]')!.click();await nextTick()
|
||||||
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
|
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
it('filters duplicate source names by source id',async()=>{
|
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'}]
|
const duplicateSubscriptions=[subscriptions[0],{...subscriptions[0],id:'s2',url:'https://example.com/personal.ics',color:'#334455'}]
|
||||||
const duplicateEvents=[events[0],{...events[0],id:'e2',title:'私人日程',source_id:'s2',color:'#334455'}]
|
const duplicateEvents=[events[0],{...events[0],id:'e2',title:'私人日程',source_id:'s2',color:'#334455'}]
|
||||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:duplicateEvents,sources:[]}:duplicateSubscriptions)))
|
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:duplicateEvents,sources:[]}:duplicateSubscriptions)))
|
||||||
const {host}=await mount(fetchMock)
|
const {host}=await mount(fetchMock)
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>('[data-day="2026-09-22"]')!.click();await nextTick()
|
||||||
host.querySelectorAll<HTMLInputElement>('input[aria-label="筛选工作"]')[0].click();await nextTick()
|
host.querySelectorAll<HTMLInputElement>('input[aria-label="筛选工作"]')[0].click();await nextTick()
|
||||||
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
|
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
|
||||||
expect(host.querySelector('[data-event-id="e2"]')).not.toBeNull()
|
expect(host.querySelector('[data-event-id="e2"]')).not.toBeNull()
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
it('groups UTC events by the browser-local calendar day',async()=>{
|
it('groups UTC events by the browser-local calendar day',async()=>{
|
||||||
|
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
|
||||||
const boundary=[{...events[0],id:'boundary',starts_at:'2026-09-21T23:30:00Z',ends_at:'2026-09-22T00:30:00Z'}]
|
const boundary=[{...events[0],id:'boundary',starts_at:'2026-09-21T23:30:00Z',ends_at:'2026-09-22T00:30:00Z'}]
|
||||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:boundary,sources:[]}:subscriptions)))
|
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:boundary,sources:[]}:subscriptions)))
|
||||||
const {host}=await mount(fetchMock)
|
const {host}=await mount(fetchMock)
|
||||||
|
const boundaryDay=new Date(boundary[0].starts_at)
|
||||||
|
if(!host.querySelector(`[data-day="${boundaryDay.getFullYear()}-${String(boundaryDay.getMonth()+1).padStart(2,'0')}-${String(boundaryDay.getDate()).padStart(2,'0')}"]`)){host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()}
|
||||||
|
host.querySelector<HTMLButtonElement>(`[data-day="${boundaryDay.getFullYear()}-${String(boundaryDay.getMonth()+1).padStart(2,'0')}-${String(boundaryDay.getDate()).padStart(2,'0')}"]`)!.click();await nextTick()
|
||||||
const expected=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(boundary[0].starts_at))
|
const expected=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(boundary[0].starts_at))
|
||||||
expect(host.querySelector('.calendar-agenda h2')?.textContent).toBe(expected)
|
expect(host.querySelector('.calendar-agenda h2')?.textContent).toContain(expected)
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
it('keeps the newest month response when requests finish out of order',async()=>{
|
it('keeps the newest week response when requests finish out of order',async()=>{
|
||||||
const pending:Array<{url:string;resolve:(response:Response)=>void}>=[]
|
const pending:Array<{url:string;resolve:(response:Response)=>void}>=[]
|
||||||
const fetchMock=vi.fn((url:string)=>String(url).includes('calendar-events')?new Promise<Response>(resolve=>pending.push({url:String(url),resolve})):Promise.resolve(json(subscriptions)))
|
const fetchMock=vi.fn((url:string)=>String(url).includes('calendar-events')?new Promise<Response>(resolve=>pending.push({url:String(url),resolve})):Promise.resolve(json(subscriptions)))
|
||||||
const {host}=await mount(fetchMock)
|
const {host}=await mount(fetchMock)
|
||||||
expect(pending).toHaveLength(1)
|
expect(pending).toHaveLength(1)
|
||||||
host.querySelector<HTMLButtonElement>('[aria-label="下个月"]')!.click();await nextTick()
|
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await nextTick()
|
||||||
expect(pending).toHaveLength(2)
|
expect(pending).toHaveLength(2)
|
||||||
pending[1].resolve(json({events:[{...events[0],id:'new',title:'新月份'}],sources:[]}));await flush()
|
pending[1].resolve(json({events:[{...events[0],id:'new',title:'新一周',starts_at:'2026-09-21T02:00:00Z'}],sources:[]}));await flush()
|
||||||
pending[0].resolve(json({events:[{...events[0],id:'old',title:'旧月份'}],sources:[]}));await flush()
|
pending[0].resolve(json({events:[{...events[0],id:'old',title:'旧一周'}],sources:[]}));await flush()
|
||||||
expect(host.textContent).toContain('新月份')
|
expect(host.textContent).toContain('新一周')
|
||||||
expect(host.textContent).not.toContain('旧月份')
|
expect(host.textContent).not.toContain('旧一周')
|
||||||
})
|
})
|
||||||
it('supports month navigation and today',async()=>{
|
it('supports week navigation and today',async()=>{
|
||||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
|
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
|
||||||
const {host}=await mount(fetchMock);const before=fetchMock.mock.calls.length
|
const {host}=await mount(fetchMock);const before=fetchMock.mock.calls.length
|
||||||
host.querySelector<HTMLButtonElement>('[aria-label="下个月"]')!.click();await flush()
|
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
|
||||||
host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush()
|
host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush()
|
||||||
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(before+2)
|
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(before+2)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,20 +13,28 @@ type Form = { name:string; url:string; color:string; enabled:boolean }
|
|||||||
const emit=defineEmits<{notice:[message:string]}>()
|
const emit=defineEmits<{notice:[message:string]}>()
|
||||||
const subscriptions=ref<Subscription[]>([]),events=ref<CalendarEvent[]>([]),loading=ref(false),error=ref('')
|
const subscriptions=ref<Subscription[]>([]),events=ref<CalendarEvent[]>([]),loading=ref(false),error=ref('')
|
||||||
let eventsRequestGeneration=0
|
let eventsRequestGeneration=0
|
||||||
const month=ref(new Date(new Date().getFullYear(),new Date().getMonth(),1)),hiddenSources=ref(new Set<string>())
|
const selectedDay=ref(new Date(new Date().getFullYear(),new Date().getMonth(),new Date().getDate())),hiddenSources=ref(new Set<string>())
|
||||||
const selected=ref<CalendarEvent|null>(null),manageOpen=ref(false),formOpen=ref(false),editing=ref<Subscription|null>(null),busyId=ref('')
|
const selected=ref<CalendarEvent|null>(null),manageOpen=ref(false),formOpen=ref(false),editing=ref<Subscription|null>(null),busyId=ref('')
|
||||||
const form=ref<Form>({name:'',url:'',color:'#f15a29',enabled:true})
|
const form=ref<Form>({name:'',url:'',color:'#f15a29',enabled:true})
|
||||||
const appDialog=ref<{show:(options:AppDialogOptions)=>Promise<boolean|string|null>}|null>(null)
|
const appDialog=ref<{show:(options:AppDialogOptions)=>Promise<boolean|string|null>}|null>(null)
|
||||||
const request=async(path:string,options:RequestInit={})=>{const headers:Record<string,string>={...(options.headers as Record<string,string>||{})};if(options.body)headers['Content-Type']='application/json';Object.assign(headers,csrfHeader(options.method));const response=await fetch('/api/v1'+path,{credentials:'include',...options,headers});if(!response.ok){const body=await response.json().catch(()=>({}));throw new Error(formatApiErrorDetail((body as {detail?:unknown}).detail??body))}return response.status===204?null:response.json()}
|
const request=async(path:string,options:RequestInit={})=>{const headers:Record<string,string>={...(options.headers as Record<string,string>||{})};if(options.body)headers['Content-Type']='application/json';Object.assign(headers,csrfHeader(options.method));const response=await fetch('/api/v1'+path,{credentials:'include',...options,headers});if(!response.ok){const body=await response.json().catch(()=>({}));throw new Error(formatApiErrorDetail((body as {detail?:unknown}).detail??body))}return response.status===204?null:response.json()}
|
||||||
const key=(date:Date)=>`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
|
const key=(date:Date)=>`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
|
||||||
const range=computed(()=>{const start=new Date(month.value.getFullYear(),month.value.getMonth(),1);const end=new Date(month.value.getFullYear(),month.value.getMonth()+1,1);return{start:start.toISOString(),end:end.toISOString()}})
|
const startOfWeek=(value:Date)=>{const date=new Date(value.getFullYear(),value.getMonth(),value.getDate());date.setDate(date.getDate()-((date.getDay()+6)%7));return date}
|
||||||
const monthLabel=computed(()=>new Intl.DateTimeFormat('zh-CN',{year:'numeric',month:'long'}).format(month.value))
|
const weekStart=computed(()=>startOfWeek(selectedDay.value))
|
||||||
|
const weekDays=computed(()=>Array.from({length:7},(_,index)=>{const date=new Date(weekStart.value);date.setDate(date.getDate()+index);return date}))
|
||||||
|
const range=computed(()=>{const start=weekStart.value;const end=new Date(start);end.setDate(end.getDate()+7);return{start:start.toISOString(),end:end.toISOString()}})
|
||||||
|
const weekLabel=computed(()=>{const start=weekDays.value[0],end=weekDays.value[6];if(start.getFullYear()!==end.getFullYear())return `${start.getFullYear()}年${start.getMonth()+1}月${start.getDate()}日 - ${end.getFullYear()}年${end.getMonth()+1}月${end.getDate()}日`;return start.getMonth()===end.getMonth()?`${start.getFullYear()}年${start.getMonth()+1}月`:`${start.getFullYear()}年${start.getMonth()+1}月${start.getDate()}日 - ${end.getMonth()+1}月${end.getDate()}日`})
|
||||||
const eventStart=(event:CalendarEvent)=>event.starts_at
|
const eventStart=(event:CalendarEvent)=>event.starts_at
|
||||||
const eventEnd=(event:CalendarEvent)=>event.ends_at
|
const eventEnd=(event:CalendarEvent)=>event.ends_at
|
||||||
const eventKey=(event:CalendarEvent)=>event.id
|
const eventKey=(event:CalendarEvent)=>event.id
|
||||||
const visibleEvents=computed(()=>events.value.filter(event=>!hiddenSources.value.has(event.source_id)).sort((a,b)=>eventStart(a).localeCompare(eventStart(b))))
|
const filteredEvents=computed(()=>events.value.filter(event=>!hiddenSources.value.has(event.source_id)).sort((a,b)=>eventStart(a).localeCompare(eventStart(b))))
|
||||||
const localDayKey=(value:string)=>{const date=new Date(value);return Number.isNaN(date.getTime())?value.slice(0,10):key(date)}
|
const localDayKey=(value:string)=>{const date=new Date(value);return Number.isNaN(date.getTime())?value.slice(0,10):key(date)}
|
||||||
const groupedEvents=computed(()=>{const groups=new Map<string,CalendarEvent[]>();for(const event of visibleEvents.value){const day=localDayKey(eventStart(event));groups.set(day,[...(groups.get(day)??[]),event])}return [...groups].map(([day,items])=>({day,items}))})
|
const selectedDayKey=computed(()=>key(selectedDay.value))
|
||||||
|
const visibleEvents=computed(()=>filteredEvents.value.filter(event=>localDayKey(eventStart(event))===selectedDayKey.value))
|
||||||
|
const selectedDayLabel=computed(()=>displayDay(selectedDayKey.value))
|
||||||
|
const dayEventCount=(date:Date)=>filteredEvents.value.filter(event=>localDayKey(eventStart(event))===key(date)).length
|
||||||
|
const isToday=(date:Date)=>key(date)===key(new Date())
|
||||||
|
const weekDayLabel=(date:Date)=>new Intl.DateTimeFormat('zh-CN',{weekday:'short'}).format(date).replace('周','')
|
||||||
const eventTitle=(event:CalendarEvent)=>event.title||'未命名事件'
|
const eventTitle=(event:CalendarEvent)=>event.title||'未命名事件'
|
||||||
const eventSource=(event:CalendarEvent)=>event.source_name||'日历'
|
const eventSource=(event:CalendarEvent)=>event.source_name||'日历'
|
||||||
const eventColor=(event:CalendarEvent)=>event.color||'#f15a29'
|
const eventColor=(event:CalendarEvent)=>event.color||'#f15a29'
|
||||||
@@ -35,8 +43,9 @@ function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const
|
|||||||
async function loadSubscriptions(){subscriptions.value=await request('/calendar-subscriptions') as Subscription[]}
|
async function loadSubscriptions(){subscriptions.value=await request('/calendar-subscriptions') as Subscription[]}
|
||||||
async function loadEvents(){const generation=++eventsRequestGeneration;const requestedRange=range.value;const data=await request(`/calendar-events?start=${encodeURIComponent(requestedRange.start)}&end=${encodeURIComponent(requestedRange.end)}`) as EventResponse;if(generation===eventsRequestGeneration)events.value=data.events}
|
async function loadEvents(){const generation=++eventsRequestGeneration;const requestedRange=range.value;const data=await request(`/calendar-events?start=${encodeURIComponent(requestedRange.start)}&end=${encodeURIComponent(requestedRange.end)}`) as EventResponse;if(generation===eventsRequestGeneration)events.value=data.events}
|
||||||
async function load(){loading.value=true;error.value='';try{await Promise.all([loadSubscriptions(),loadEvents()])}catch(reason){error.value=reason instanceof Error?reason.message:'日历载入失败'}finally{loading.value=false}}
|
async function load(){loading.value=true;error.value='';try{await Promise.all([loadSubscriptions(),loadEvents()])}catch(reason){error.value=reason instanceof Error?reason.message:'日历载入失败'}finally{loading.value=false}}
|
||||||
async function moveMonth(offset:number){month.value=new Date(month.value.getFullYear(),month.value.getMonth()+offset,1);await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
|
async function moveWeek(offset:number){const next=new Date(weekStart.value);next.setDate(next.getDate()+offset*7);selectedDay.value=next;await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
|
||||||
async function today(){const now=new Date();month.value=new Date(now.getFullYear(),now.getMonth(),1);await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
|
async function today(){const now=new Date();selectedDay.value=new Date(now.getFullYear(),now.getMonth(),now.getDate());await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
|
||||||
|
function selectDay(date:Date){selectedDay.value=new Date(date.getFullYear(),date.getMonth(),date.getDate())}
|
||||||
function toggleFilter(id:string){const next=new Set(hiddenSources.value);next.has(id)?next.delete(id):next.add(id);hiddenSources.value=next}
|
function toggleFilter(id:string){const next=new Set(hiddenSources.value);next.has(id)?next.delete(id):next.add(id);hiddenSources.value=next}
|
||||||
function openCreate(){editing.value=null;form.value={name:'',url:'',color:'#f15a29',enabled:true};formOpen.value=true}
|
function openCreate(){editing.value=null;form.value={name:'',url:'',color:'#f15a29',enabled:true};formOpen.value=true}
|
||||||
function openEdit(item:Subscription){editing.value=item;form.value={name:item.name,url:item.url,color:item.color||'#f15a29',enabled:item.enabled};formOpen.value=true}
|
function openEdit(item:Subscription){editing.value=item;form.value={name:item.name,url:item.url,color:item.color||'#f15a29',enabled:item.enabled};formOpen.value=true}
|
||||||
@@ -50,12 +59,13 @@ onMounted(()=>void load())
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="calendar-view" :class="{loading}">
|
<section class="calendar-view" :class="{loading}">
|
||||||
<header class="calendar-heading"><div><h1>日历订阅</h1><p>{{visibleEvents.length}} 个日程 · {{subscriptions.length}} 个来源</p></div><button class="soft-button calendar-manage" aria-label="管理日历源" @click="manageOpen=true"><Settings2/>日历源</button></header>
|
<header class="calendar-heading"><div><h1>日历订阅</h1><p>{{filteredEvents.length}} 个日程 · {{subscriptions.length}} 个来源</p></div><button class="soft-button calendar-manage" aria-label="管理日历源" @click="manageOpen=true"><Settings2/>日历源</button></header>
|
||||||
<p v-if="error" class="inline-error" role="alert">{{error}}</p>
|
<p v-if="error" class="inline-error" role="alert">{{error}}</p>
|
||||||
<div class="calendar-toolbar"><button aria-label="上个月" @click="moveMonth(-1)"><ChevronLeft/></button><button class="calendar-today" aria-label="回到今天" @click="today">今天</button><strong>{{monthLabel}}</strong><button aria-label="下个月" @click="moveMonth(1)"><ChevronRight/></button></div>
|
<div class="calendar-toolbar"><button aria-label="上一周" @click="moveWeek(-1)"><ChevronLeft/></button><button class="calendar-today" aria-label="回到今天" @click="today">今天</button><strong>{{weekLabel}}</strong><button aria-label="下一周" @click="moveWeek(1)"><ChevronRight/></button></div>
|
||||||
|
<div class="calendar-week-strip" role="group" aria-label="选择日期"><button v-for="day in weekDays" :key="key(day)" class="calendar-week-day" :class="{'is-selected':key(day)===selectedDayKey,'is-today':isToday(day)}" :data-day="key(day)" :aria-label="`${displayDay(key(day))}${dayEventCount(day)?`,${dayEventCount(day)}个日程`:',无日程'}`" :aria-pressed="key(day)===selectedDayKey" @click="selectDay(day)"><small>{{weekDayLabel(day)}}</small><b>{{day.getDate()}}</b><i v-if="dayEventCount(day)" aria-hidden="true">{{dayEventCount(day)}}</i></button></div>
|
||||||
<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="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="groupedEvents.length" class="calendar-agenda"><section v-for="group in groupedEvents" :key="group.day"><h2>{{displayDay(group.day)}}</h2><button v-for="event in group.items" :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-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>
|
<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 :open="Boolean(selected)" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" @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)" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" @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="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>
|
<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>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user