feat: spacing tokens, actionable failure copy, unified focus ring, 44px touch targets, press feedback
This commit is contained in:
@@ -271,8 +271,8 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a
|
|||||||
expect(layout.bodyOverflow).toBe(0)
|
expect(layout.bodyOverflow).toBe(0)
|
||||||
const isMobileContract = (await page.viewportSize())!.width <= 720
|
const isMobileContract = (await page.viewportSize())!.width <= 720
|
||||||
if (isMobileContract) {
|
if (isMobileContract) {
|
||||||
expect(layout.leftPadding).toBeCloseTo(29, 0)
|
expect(layout.leftPadding).toBeCloseTo(28, 0)
|
||||||
expect(layout.rightPadding).toBeCloseTo(29, 0)
|
expect(layout.rightPadding).toBeCloseTo(28, 0)
|
||||||
expect(layout.headingSize).toBe('24px')
|
expect(layout.headingSize).toBe('24px')
|
||||||
} else {
|
} else {
|
||||||
const pageWidth = await page.locator('.settings-sections').evaluate(element => element.getBoundingClientRect().width)
|
const pageWidth = await page.locator('.settings-sections').evaluate(element => element.getBoundingClientRect().width)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
}
|
}
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
media720: width <= 720,
|
media720: width <= 720,
|
||||||
contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 58,
|
contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 56,
|
||||||
environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81,
|
environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81,
|
||||||
})
|
})
|
||||||
await expect(page.locator('.today-heading')).toHaveCount(1)
|
await expect(page.locator('.today-heading')).toHaveCount(1)
|
||||||
@@ -270,9 +270,9 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
expect(metrics.directions.weather).toBe('column')
|
expect(metrics.directions.weather).toBe('column')
|
||||||
expect(metrics.directions.gold).toBe('column')
|
expect(metrics.directions.gold).toBe('column')
|
||||||
if (width <= 720) {
|
if (width <= 720) {
|
||||||
expect(metrics.content.width).toBeCloseTo(width - 58, 0)
|
expect(metrics.content.width).toBeCloseTo(width - 56, 0)
|
||||||
expect(metrics.content.left).toBeCloseTo(29, 0)
|
expect(metrics.content.left).toBeCloseTo(28, 0)
|
||||||
expect(metrics.styles.mainPaddingLeft).toBe('29px')
|
expect(metrics.styles.mainPaddingLeft).toBe('28px')
|
||||||
expect(metrics.styles.titleFontSize).toBe('24px')
|
expect(metrics.styles.titleFontSize).toBe('24px')
|
||||||
expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0)
|
expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n
|
|||||||
expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0)
|
expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0)
|
||||||
expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0)
|
expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0)
|
||||||
if (geometry.viewportWidth <= 720) {
|
if (geometry.viewportWidth <= 720) {
|
||||||
expect(geometry.header.left).toBeCloseTo(29, 0)
|
expect(geometry.header.left).toBeCloseTo(28, 0)
|
||||||
expect(geometry.list.right).toBeCloseTo(geometry.viewportWidth - 29, 0)
|
expect(geometry.list.right).toBeCloseTo(geometry.viewportWidth - 28, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fab = page.getByRole('button', { name: '添加任务' })
|
const fab = page.getByRole('button', { name: '添加任务' })
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
@@ -28,6 +28,7 @@ import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
|||||||
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
||||||
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
||||||
import { loadTaskOpenTotal } from './lib/task-open-total'
|
import { loadTaskOpenTotal } from './lib/task-open-total'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
type FolderItem = { id: string; name: string }
|
type FolderItem = { id: string; name: string }
|
||||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||||
@@ -470,7 +471,7 @@ async function api(path: string, options: RequestInit = {}) {
|
|||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = '请求失败'
|
let message = '请求失败,请检查网络后重试'
|
||||||
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
|
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
|
||||||
const requestError = new Error(message) as Error & { status: number }
|
const requestError = new Error(message) as Error & { status: number }
|
||||||
requestError.status = response.status
|
requestError.status = response.status
|
||||||
@@ -483,7 +484,7 @@ function toast(message: string) {
|
|||||||
notice.value = message
|
notice.value = message
|
||||||
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
|
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
|
||||||
}
|
}
|
||||||
function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' }
|
function fail(reason: unknown) { error.value = failureHint('请求失败', reason) }
|
||||||
|
|
||||||
async function syncBrowserTimezone(currentTimezone?: string) {
|
async function syncBrowserTimezone(currentTimezone?: string) {
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
@@ -1138,7 +1139,7 @@ async function saveSelectedTaskChanges() {
|
|||||||
toast('已保存')
|
toast('已保存')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
||||||
selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'
|
selectedRepeatError.value = failureHint('保存失败', reason)
|
||||||
fail(reason)
|
fail(reason)
|
||||||
} finally {
|
} finally {
|
||||||
savingSelectedTask.value = false
|
savingSelectedTask.value = false
|
||||||
@@ -1443,7 +1444,7 @@ async function confirmPurgeList() {
|
|||||||
focusPurgeListTrigger()
|
focusPurgeListTrigger()
|
||||||
toast('清单已永久删除')
|
toast('清单已永久删除')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败'
|
purgeListError.value = failureHint('永久删除失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
purgeListSubmitting.value = false
|
purgeListSubmitting.value = false
|
||||||
}
|
}
|
||||||
@@ -1512,7 +1513,7 @@ async function saveListEditor() {
|
|||||||
listEditorFolderId.value = current.folder_id ?? ''
|
listEditorFolderId.value = current.folder_id ?? ''
|
||||||
}
|
}
|
||||||
listEditorError.value = '名称已保存,但移动文件夹失败,请重试。'
|
listEditorError.value = '名称已保存,但移动文件夹失败,请重试。'
|
||||||
} else listEditorError.value = reason instanceof Error ? reason.message : '保存失败'
|
} else listEditorError.value = reason instanceof Error ? reason.message : '保存失败,请检查网络后重试'
|
||||||
} finally { listEditorBusy.value = false }
|
} finally { listEditorBusy.value = false }
|
||||||
}
|
}
|
||||||
async function archiveListFromEditor() {
|
async function archiveListFromEditor() {
|
||||||
@@ -1534,7 +1535,7 @@ async function archiveListFromEditor() {
|
|||||||
}
|
}
|
||||||
toast('清单已归档')
|
toast('清单已归档')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
listEditorError.value = reason instanceof Error ? reason.message : '归档失败'
|
listEditorError.value = failureHint('归档失败', reason)
|
||||||
} finally { listEditorBusy.value = false }
|
} finally { listEditorBusy.value = false }
|
||||||
}
|
}
|
||||||
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
|
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const app=readFileSync('src/App.vue','utf8');const utils=readFileSync('src/lib/m
|
|||||||
describe('calendar shell integration',()=>{
|
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('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('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(':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('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:var(--space-4)}.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('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%;')})
|
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%;')})
|
||||||
it('uses the approved minimal reading layout for event details',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(panel).toContain('class="calendar-event-detail__meta"');expect(panel).toContain('class="calendar-event-detail__content"');expect(panel).not.toContain('<dt>时间</dt>');expect(css).toContain('.calendar-event-detail__meta{');expect(css).toContain('.calendar-event-detail__content{')})
|
it('uses the approved minimal reading layout for event details',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(panel).toContain('class="calendar-event-detail__meta"');expect(panel).toContain('class="calendar-event-detail__content"');expect(panel).not.toContain('<dt>时间</dt>');expect(css).toContain('.calendar-event-detail__meta{');expect(css).toContain('.calendar-event-detail__content{')})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { csrfHeader } from './lib/csrf'
|
|||||||
import { formatApiErrorDetail } from './lib/mvp-utils'
|
import { formatApiErrorDetail } from './lib/mvp-utils'
|
||||||
import AppSheet from './components/AppSheet.vue'
|
import AppSheet from './components/AppSheet.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
type Subscription = { id:string; name:string; url:string; color:string; enabled:boolean; refreshed_at:string|null; last_error:string|null; stale:boolean }
|
type Subscription = { id:string; name:string; url:string; color:string; enabled:boolean; refreshed_at:string|null; last_error:string|null; stale:boolean }
|
||||||
type CalendarEvent = { id:string; title:string; starts_at:string; ends_at:string; all_day:boolean; source_id:string; source_name:string; color:string; description?:string|null; location?:string|null }
|
type CalendarEvent = { id:string; title:string; starts_at:string; ends_at:string; all_day:boolean; source_id:string; source_name:string; color:string; description?:string|null; location?:string|null }
|
||||||
@@ -43,17 +44,17 @@ function displayDay(day:string){const [y,m,d]=day.split('-').map(Number);return
|
|||||||
function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const date=new Date(eventStart(event));return Number.isNaN(date.getTime())?'时间待定':new Intl.DateTimeFormat('zh-CN',{hour:'2-digit',minute:'2-digit'}).format(date)}
|
function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const date=new Date(eventStart(event));return Number.isNaN(date.getTime())?'时间待定':new Intl.DateTimeFormat('zh-CN',{hour:'2-digit',minute:'2-digit'}).format(date)}
|
||||||
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=failureHint('日历载入失败', reason)}finally{loading.value=false}}
|
||||||
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 moveWeek(offset:number){const next=new Date(weekStart.value);next.setDate(next.getDate()+offset*7);selectedDay.value=next;await loadEvents().catch(reason=>error.value=failureHint('事件载入失败', reason))}
|
||||||
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:'事件载入失败')}
|
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 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}
|
||||||
async function save(){if(busyId.value||!form.value.name.trim()||!form.value.url.trim())return;busyId.value='form';error.value='';try{await request(editing.value?`/calendar-subscriptions/${editing.value.id}`:'/calendar-subscriptions',{method:editing.value?'PATCH':'POST',body:JSON.stringify({...form.value,name:form.value.name.trim(),url:form.value.url.trim()})});formOpen.value=false;await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',editing.value?'日历订阅已更新':'日历订阅已添加')}catch(reason){error.value=reason instanceof Error?reason.message:'保存失败'}finally{busyId.value=''}}
|
async function save(){if(busyId.value||!form.value.name.trim()||!form.value.url.trim())return;busyId.value='form';error.value='';try{await request(editing.value?`/calendar-subscriptions/${editing.value.id}`:'/calendar-subscriptions',{method:editing.value?'PATCH':'POST',body:JSON.stringify({...form.value,name:form.value.name.trim(),url:form.value.url.trim()})});formOpen.value=false;await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',editing.value?'日历订阅已更新':'日历订阅已添加')}catch(reason){error.value=failureHint('保存失败', reason)}finally{busyId.value=''}}
|
||||||
async function toggleEnabled(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'PATCH',body:JSON.stringify({enabled:!item.enabled})});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',item.enabled?'日历订阅已停用':'日历订阅已启用')}catch(reason){error.value=reason instanceof Error?reason.message:'更新失败'}finally{busyId.value=''}}
|
async function toggleEnabled(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'PATCH',body:JSON.stringify({enabled:!item.enabled})});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',item.enabled?'日历订阅已停用':'日历订阅已启用')}catch(reason){error.value=failureHint('更新失败', reason)}finally{busyId.value=''}}
|
||||||
async function refresh(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}/refresh`,{method:'POST'});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',`${item.name}已刷新`)}catch(reason){error.value=reason instanceof Error?reason.message:'刷新失败'}finally{busyId.value=''}}
|
async function refresh(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}/refresh`,{method:'POST'});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',`${item.name}已刷新`)}catch(reason){error.value=failureHint('刷新失败', reason)}finally{busyId.value=''}}
|
||||||
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=''}}
|
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=failureHint('删除失败', reason)}finally{busyId.value=''}}
|
||||||
watch(manageOpen,open=>{if(!open)formOpen.value=false})
|
watch(manageOpen,open=>{if(!open)formOpen.value=false})
|
||||||
watch(selected,event=>emit('detail',Boolean(event)))
|
watch(selected,event=>emit('detail',Boolean(event)))
|
||||||
watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'})
|
watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { csrfHeader } from './lib/csrf'
|
|||||||
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
|
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
|
||||||
import AppSheet from './components/AppSheet.vue'
|
import AppSheet from './components/AppSheet.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
type Countdown = {
|
type Countdown = {
|
||||||
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
||||||
@@ -98,7 +99,7 @@ async function safe(detailId:string|null, work:(context:OperationContext)=>Promi
|
|||||||
await work(context)
|
await work(context)
|
||||||
} catch(reason) {
|
} catch(reason) {
|
||||||
if (!currentContext(context)) return
|
if (!currentContext(context)) return
|
||||||
error.value=reason instanceof Error ? reason.message : '请求失败'
|
error.value=failureHint('请求失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (activeOperation === context) activeOperation=null
|
if (activeOperation === context) activeOperation=null
|
||||||
if (currentContext(context)) busy.value=false
|
if (currentContext(context)) busy.value=false
|
||||||
@@ -123,7 +124,7 @@ async function load(force = false, manageBusy = true) {
|
|||||||
items.value=data.items
|
items.value=data.items
|
||||||
archived.value=data.archived
|
archived.value=data.archived
|
||||||
} catch(reason) {
|
} catch(reason) {
|
||||||
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
|
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : failureHint('请求失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ describe('memo shell integration', () => {
|
|||||||
expect(app).toContain(':show="showFloatingAdd"')
|
expect(app).toContain(':show="showFloatingAdd"')
|
||||||
expect(css).toContain('.shell.memo-detail-open main{padding-right:372px}')
|
expect(css).toContain('.shell.memo-detail-open main{padding-right:372px}')
|
||||||
expect(css).toContain('.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden')
|
expect(css).toContain('.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden')
|
||||||
expect(css).toContain('@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}')
|
expect(css).toContain('@media(max-width:930px){.shell.memo-detail-open main{padding:var(--space-20) var(--space-16) 112px}')
|
||||||
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%')
|
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
|||||||
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
import AppSheet from './components/AppSheet.vue'
|
import AppSheet from './components/AppSheet.vue'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||||
const props = defineProps<{ request: RequestFn }>()
|
const props = defineProps<{ request: RequestFn }>()
|
||||||
@@ -55,7 +56,7 @@ async function load(reset = true) {
|
|||||||
committedGeneration = token
|
committedGeneration = token
|
||||||
}
|
}
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (token === generation && criteriaEqual(criteria, currentCriteria())) error.value = reason instanceof Error ? reason.message : '加载失败'
|
if (token === generation && criteriaEqual(criteria, currentCriteria())) error.value = failureHint('加载失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (token === generation && criteriaEqual(criteria, currentCriteria())) {
|
if (token === generation && criteriaEqual(criteria, currentCriteria())) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -76,7 +77,7 @@ async function loadMore() {
|
|||||||
items.value = [...items.value, ...data.items]
|
items.value = [...items.value, ...data.items]
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
page.value = nextPage
|
page.value = nextPage
|
||||||
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : '加载失败' }
|
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : failureHint('加载失败', reason) }
|
||||||
finally { if (token === generation) refreshing.value = false }
|
finally { if (token === generation) refreshing.value = false }
|
||||||
}
|
}
|
||||||
function closeDetail() {
|
function closeDetail() {
|
||||||
@@ -108,7 +109,7 @@ async function selectMemo(id: string, opener?: EventTarget | null) {
|
|||||||
const memo = await props.request(`/memos/${id}`) as MemoRecord
|
const memo = await props.request(`/memos/${id}`) as MemoRecord
|
||||||
if (token === detailGeneration) { selectedToken.value = token; selected.value = memo; emit('detail', true) }
|
if (token === detailGeneration) { selectedToken.value = token; selected.value = memo; emit('detail', true) }
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (token === detailGeneration) error.value = reason instanceof Error ? reason.message : '读取失败'
|
if (token === detailGeneration) error.value = failureHint('读取失败', reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function createMemo() {
|
async function createMemo() {
|
||||||
|
|||||||
+16
-15
@@ -10,6 +10,7 @@ import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldComm
|
|||||||
import AppSheet from './components/AppSheet.vue'
|
import AppSheet from './components/AppSheet.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
type View = 'habits' | 'today-habits' | 'settings'
|
type View = 'habits' | 'today-habits' | 'settings'
|
||||||
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
||||||
@@ -158,7 +159,7 @@ async function request<T = unknown>(path: string, options: RequestInit = {}): Pr
|
|||||||
}
|
}
|
||||||
async function safe(work: () => Promise<void>) {
|
async function safe(work: () => Promise<void>) {
|
||||||
busy.value = true; error.value = ''
|
busy.value = true; error.value = ''
|
||||||
try { await work() } catch (e) { error.value = e instanceof Error ? e.message : '请求失败' } finally { busy.value = false }
|
try { await work() } catch (e) { error.value = failureHint('请求失败', e) } finally { busy.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
|
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
|
||||||
@@ -203,7 +204,7 @@ async function finishHabitReorder(h: Habit, event: PointerEvent) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
habits.value = previous
|
habits.value = previous
|
||||||
writeHabitGridCache(dateKey(new Date()), previous)
|
writeHabitGridCache(dateKey(new Date()), previous)
|
||||||
error.value = e instanceof Error ? e.message : '请求失败'
|
error.value = e instanceof Error ? e.message : failureHint('请求失败', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function cancelHabitReorder() {
|
function cancelHabitReorder() {
|
||||||
@@ -309,7 +310,7 @@ async function mutateHabitValue(h: Habit, next: number | boolean, previous: numb
|
|||||||
if (!ownership.current()) return
|
if (!ownership.current()) return
|
||||||
setHabitCompletionExiting(h.id, false)
|
setHabitCompletionExiting(h.id, false)
|
||||||
setLocalHabitValue(h, rollback, day)
|
setLocalHabitValue(h, rollback, day)
|
||||||
error.value = reason instanceof Error ? reason.message : '请求失败'
|
error.value = reason instanceof Error ? reason.message : '请求失败,请检查网络后重试'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -424,7 +425,7 @@ async function saveHabit() {
|
|||||||
originalHabitForm.value = null
|
originalHabitForm.value = null
|
||||||
await loadHabits()
|
await loadHabits()
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
habitFormError.value = reason instanceof Error ? reason.message : '请求失败'
|
habitFormError.value = reason instanceof Error ? reason.message : failureHint('请求失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -520,7 +521,7 @@ async function loadHabitHistory(reset = false) {
|
|||||||
habitHistoryHasMore.value = !habit.start_date || nextTo >= habit.start_date
|
habitHistoryHasMore.value = !habit.start_date || nextTo >= habit.start_date
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (requestId !== habitHistoryRequest || selectedHabit.value?.id !== habit.id) return
|
if (requestId !== habitHistoryRequest || selectedHabit.value?.id !== habit.id) return
|
||||||
habitHistoryError.value = reason instanceof Error ? reason.message : '历史记录加载失败'
|
habitHistoryError.value = failureHint('历史记录加载失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === habitHistoryRequest && selectedHabit.value?.id === habit.id) {
|
if (requestId === habitHistoryRequest && selectedHabit.value?.id === habit.id) {
|
||||||
habitHistoryLoading.value = false
|
habitHistoryLoading.value = false
|
||||||
@@ -563,7 +564,7 @@ async function archiveHabit(h: Habit) {
|
|||||||
if (props.view === 'habits' && showArchivedHabits.value) await loadArchivedHabits()
|
if (props.view === 'habits' && showArchivedHabits.value) await loadArchivedHabits()
|
||||||
emit('notice', '习惯已归档')
|
emit('notice', '习惯已归档')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : '请求失败'
|
error.value = reason instanceof Error ? reason.message : failureHint('请求失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -585,7 +586,7 @@ async function restoreHabit(h: Habit) {
|
|||||||
emit('notice', result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试')
|
emit('notice', result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试')
|
||||||
void nextTick(() => habitArchiveToggle.value?.focus())
|
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : '恢复失败'
|
error.value = failureHint('恢复失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -603,7 +604,7 @@ async function deleteHabit(h: Habit) {
|
|||||||
emit('notice', '习惯已永久删除')
|
emit('notice', '习惯已永久删除')
|
||||||
void nextTick(() => habitArchiveToggle.value?.focus())
|
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : '永久删除失败'
|
error.value = failureHint('永久删除失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -617,7 +618,7 @@ async function loadArchivedHabits() {
|
|||||||
archiveState.value = 'success'
|
archiveState.value = 'success'
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
archiveState.value = 'error'
|
archiveState.value = 'error'
|
||||||
archiveError.value = reason instanceof Error ? reason.message : '归档习惯加载失败'
|
archiveError.value = failureHint('归档习惯加载失败', reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function toggleArchivedHabits() {
|
async function toggleArchivedHabits() {
|
||||||
@@ -645,7 +646,7 @@ async function loadHabits() {
|
|||||||
writeHabitGridCache(week, habits.value)
|
writeHabitGridCache(week, habits.value)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '请求失败'
|
error.value = e instanceof Error ? e.message : failureHint('请求失败', e)
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
if (!cached) busy.value = false
|
if (!cached) busy.value = false
|
||||||
@@ -667,14 +668,14 @@ async function loadSettings() {
|
|||||||
sessionsState.value = 'success'
|
sessionsState.value = 'success'
|
||||||
} else {
|
} else {
|
||||||
sessionsState.value = 'error'
|
sessionsState.value = 'error'
|
||||||
sessionsError.value = sessionResult.reason instanceof Error ? sessionResult.reason.message : '登录设备加载失败'
|
sessionsError.value = sessionResult.reason instanceof Error ? sessionResult.reason.message : '登录设备加载失败,请检查网络后重试'
|
||||||
}
|
}
|
||||||
if (auditResult.status === 'fulfilled') {
|
if (auditResult.status === 'fulfilled') {
|
||||||
audit.value = mergePage<any>(auditResult.value).items
|
audit.value = mergePage<any>(auditResult.value).items
|
||||||
auditState.value = 'success'
|
auditState.value = 'success'
|
||||||
} else {
|
} else {
|
||||||
auditState.value = 'error'
|
auditState.value = 'error'
|
||||||
auditError.value = auditResult.reason instanceof Error ? auditResult.reason.message : '活动记录加载失败'
|
auditError.value = auditResult.reason instanceof Error ? auditResult.reason.message : '活动记录加载失败,请检查网络后重试'
|
||||||
}
|
}
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -695,7 +696,7 @@ function downloadBlob(blob: Blob, name: string) {
|
|||||||
async function exportData() {
|
async function exportData() {
|
||||||
backupBusy.value = true; backupError.value = ''
|
backupBusy.value = true; backupError.value = ''
|
||||||
try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') }
|
try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') }
|
||||||
catch (reason) { backupError.value = reason instanceof Error ? reason.message : '完整备份导出失败' }
|
catch (reason) { backupError.value = failureHint('完整备份导出失败', reason) }
|
||||||
finally { backupBusy.value = false }
|
finally { backupBusy.value = false }
|
||||||
}
|
}
|
||||||
function openRestoreFilePicker() {
|
function openRestoreFilePicker() {
|
||||||
@@ -732,7 +733,7 @@ async function runPreflight() {
|
|||||||
acceptedPreflightSnapshot = snapshot
|
acceptedPreflightSnapshot = snapshot
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (generation !== preflightGeneration || controller.signal.aborted) return
|
if (generation !== preflightGeneration || controller.signal.aborted) return
|
||||||
backupError.value = reason instanceof Error ? reason.message : '备份预检失败'
|
backupError.value = failureHint('备份预检失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === preflightGeneration) {
|
if (generation === preflightGeneration) {
|
||||||
backupBusy.value = false
|
backupBusy.value = false
|
||||||
@@ -762,7 +763,7 @@ async function restore() {
|
|||||||
restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
||||||
if (restoreInput.value) restoreInput.value.value = ''
|
if (restoreInput.value) restoreInput.value.value = ''
|
||||||
emit('changed'); emit('notice', '数据已恢复')
|
emit('changed'); emit('notice', '数据已恢复')
|
||||||
} catch (reason) { backupError.value = reason instanceof Error ? reason.message : '恢复失败' }
|
} catch (reason) { backupError.value = reason instanceof Error ? reason.message : failureHint('恢复失败', reason) }
|
||||||
finally { backupBusy.value = false }
|
finally { backupBusy.value = false }
|
||||||
}
|
}
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ describe('Today environment integration', () => {
|
|||||||
expect(css).toContain('main.today-main{padding-left:max(44px,calc((100% - 1080px)/2));padding-right:max(44px,calc((100% - 1080px)/2))}')
|
expect(css).toContain('main.today-main{padding-left:max(44px,calc((100% - 1080px)/2));padding-right:max(44px,calc((100% - 1080px)/2))}')
|
||||||
expect(css).toContain('.today-environment{grid-template-columns:repeat(3,minmax(0,1fr));')
|
expect(css).toContain('.today-environment{grid-template-columns:repeat(3,minmax(0,1fr));')
|
||||||
expect(css).toContain('@media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}}')
|
expect(css).toContain('@media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}}')
|
||||||
expect(css).toContain('@media(max-width:720.98px){main.today-main{padding-left:29px!important;padding-right:29px!important;padding-bottom:calc(78px + var(--safe-area-bottom))}')
|
expect(css).toContain('@media(max-width:720.98px){main.today-main{padding-left:var(--space-28)!important;padding-right:var(--space-28)!important;padding-bottom:calc(78px + var(--safe-area-bottom))}')
|
||||||
expect(css).toContain('.today-context{margin:0 0 14px;border-bottom:0}')
|
expect(css).toContain('.today-context{margin:0 0 var(--space-14);border-bottom:0}')
|
||||||
expect(css).toContain('.today-heading{display:grid;')
|
expect(css).toContain('.today-heading{display:grid;')
|
||||||
expect(css).toContain('.today-heading .today-remaining{margin:6px 0 0}')
|
expect(css).toContain('.today-heading .today-remaining{margin:var(--space-6) 0 0}')
|
||||||
expect(css).not.toContain('.today-board{')
|
expect(css).not.toContain('.today-board{')
|
||||||
expect(css).not.toContain('.today-board__progress{')
|
expect(css).not.toContain('.today-board__progress{')
|
||||||
expect(css).not.toContain('.today-track{')
|
expect(css).not.toContain('.today-track{')
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function formatApiErrorDetail(detail: unknown): string {
|
|||||||
if (typeof value.message === 'string') return value.message
|
if (typeof value.message === 'string') return value.message
|
||||||
if (typeof value.code === 'string') return value.code
|
if (typeof value.code === 'string') return value.code
|
||||||
}
|
}
|
||||||
return '请求失败'
|
return '请求失败,请检查网络后重试'
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { ArchiveRestore, Bold, Code, Heading2, Italic, Link, List, ListChecks, ListOrdered, Quote, Trash2, X } from 'lucide-vue-next'
|
import { ArchiveRestore, Bold, Code, Heading2, Italic, Link, List, ListChecks, ListOrdered, Quote, Trash2, X } from 'lucide-vue-next'
|
||||||
import { applyMarkdownFormat, renderMarkdown, type MarkdownFormat } from '../lib/task-utils'
|
import { applyMarkdownFormat, renderMarkdown, type MarkdownFormat } from '../lib/task-utils'
|
||||||
|
import { failureHint } from '../failureHint'
|
||||||
|
|
||||||
export type MemoRecord = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
export type MemoRecord = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
||||||
export type MemoDraft = { id: null; title: string; content: string; version: null; created_at: null; updated_at: null; deleted_at: null }
|
export type MemoDraft = { id: null; title: string; content: string; version: null; created_at: null; updated_at: null; deleted_at: null }
|
||||||
@@ -67,7 +68,7 @@ async function save() {
|
|||||||
if (!isCurrentSelection()) return
|
if (!isCurrentSelection()) return
|
||||||
const status = (reason as { status?: number }).status
|
const status = (reason as { status?: number }).status
|
||||||
conflict.value = memoId !== null && status === 409
|
conflict.value = memoId !== null && status === 409
|
||||||
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : memoId === null ? '创建失败' : '保存失败'
|
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : memoId === null ? failureHint('创建失败', reason) : failureHint('保存失败', reason)
|
||||||
} finally {
|
} finally {
|
||||||
emit('saveFinished', selectionToken)
|
emit('saveFinished', selectionToken)
|
||||||
if (isCurrentSelection()) saving.value = false
|
if (isCurrentSelection()) saving.value = false
|
||||||
@@ -84,7 +85,7 @@ async function reload() {
|
|||||||
loadDraft(fresh)
|
loadDraft(fresh)
|
||||||
emit('saved', fresh, selectionToken)
|
emit('saved', fresh, selectionToken)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (isCurrentSelection()) error.value = reason instanceof Error ? reason.message : '重新载入失败'
|
if (isCurrentSelection()) error.value = failureHint('重新载入失败', reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function remove() {
|
async function remove() {
|
||||||
@@ -101,7 +102,7 @@ async function remove() {
|
|||||||
await props.request(`/memos/${memoId}`, { method: 'DELETE' })
|
await props.request(`/memos/${memoId}`, { method: 'DELETE' })
|
||||||
emit('deleted', memoId, selectionToken)
|
emit('deleted', memoId, selectionToken)
|
||||||
if (isCurrentOperation()) emit('notice', '备忘录已移到回收站')
|
if (isCurrentOperation()) emit('notice', '备忘录已移到回收站')
|
||||||
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '删除失败' }
|
} catch (reason) { if (isCurrentOperation()) error.value = failureHint('删除失败', reason) }
|
||||||
finally {
|
finally {
|
||||||
emit('lifecycleFinished', selectionToken)
|
emit('lifecycleFinished', selectionToken)
|
||||||
if (isCurrentOperation()) lifecycleBusy.value = false
|
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||||
@@ -120,7 +121,7 @@ async function restore() {
|
|||||||
const restored = await props.request(`/memos/${memoId}/restore`, { method: 'POST' }) as Memo
|
const restored = await props.request(`/memos/${memoId}/restore`, { method: 'POST' }) as Memo
|
||||||
emit('restored', restored, selectionToken)
|
emit('restored', restored, selectionToken)
|
||||||
if (isCurrentOperation()) emit('notice', '备忘录已恢复')
|
if (isCurrentOperation()) emit('notice', '备忘录已恢复')
|
||||||
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '恢复失败' }
|
} catch (reason) { if (isCurrentOperation()) error.value = failureHint('恢复失败', reason) }
|
||||||
finally {
|
finally {
|
||||||
emit('lifecycleFinished', selectionToken)
|
emit('lifecycleFinished', selectionToken)
|
||||||
if (isCurrentOperation()) lifecycleBusy.value = false
|
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||||
@@ -140,7 +141,7 @@ async function purge() {
|
|||||||
await props.request(`/memos/${memoId}/purge`, { method: 'DELETE' })
|
await props.request(`/memos/${memoId}/purge`, { method: 'DELETE' })
|
||||||
emit('purged', memoId, selectionToken)
|
emit('purged', memoId, selectionToken)
|
||||||
if (isCurrentOperation()) emit('notice', '备忘录已永久删除')
|
if (isCurrentOperation()) emit('notice', '备忘录已永久删除')
|
||||||
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '永久删除失败' }
|
} catch (reason) { if (isCurrentOperation()) error.value = failureHint('永久删除失败', reason) }
|
||||||
finally {
|
finally {
|
||||||
emit('lifecycleFinished', selectionToken)
|
emit('lifecycleFinished', selectionToken)
|
||||||
if (isCurrentOperation()) lifecycleBusy.value = false
|
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ describe('TodayEnvironmentStrip', () => {
|
|||||||
const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8')
|
const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8')
|
||||||
expect(css).toContain('.today-environment{grid-column:1/-1;position:relative;min-width:0;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr) minmax(0,1fr);align-items:stretch')
|
expect(css).toContain('.today-environment{grid-column:1/-1;position:relative;min-width:0;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr) minmax(0,1fr);align-items:stretch')
|
||||||
expect(css).toContain('color:var(--text-primary);font-size:23px;line-height:1.15;font-weight:800')
|
expect(css).toContain('color:var(--text-primary);font-size:23px;line-height:1.15;font-weight:800')
|
||||||
expect(css).toContain('margin-top:6px;color:var(--text-secondary);font-size:12px;font-weight:400')
|
expect(css).toContain('margin-top:var(--space-6);color:var(--text-secondary);font-size:12px;font-weight:400')
|
||||||
expect(css).toContain('.today-environment__eyeline{color:var(--text-secondary);font-size:11px')
|
expect(css).toContain('.today-environment__eyeline{color:var(--text-secondary);font-size:11px')
|
||||||
expect(css).toContain('.today-environment__item+.today-environment__item{border-left:1px solid var(--border-cream)}')
|
expect(css).toContain('.today-environment__item+.today-environment__item{border-left:1px solid var(--border-cream)}')
|
||||||
expect(css).not.toContain('.today-environment.has-attention:before')
|
expect(css).not.toContain('.today-environment.has-attention:before')
|
||||||
@@ -145,7 +145,7 @@ describe('TodayEnvironmentStrip', () => {
|
|||||||
expect(css).toContain('.today-environment__calendar{grid-column:1/-1;grid-row:1;display:flex;flex-direction:row;align-items:baseline;justify-content:space-between')
|
expect(css).toContain('.today-environment__calendar{grid-column:1/-1;grid-row:1;display:flex;flex-direction:row;align-items:baseline;justify-content:space-between')
|
||||||
expect(css).toContain('.today-environment__weather{grid-column:1;grid-row:2;border-top:1px solid var(--border-cream);border-left:0!important}')
|
expect(css).toContain('.today-environment__weather{grid-column:1;grid-row:2;border-top:1px solid var(--border-cream);border-left:0!important}')
|
||||||
expect(css).toContain('.today-environment__gold{grid-column:2;grid-row:2;border-top:1px solid var(--border-cream)}')
|
expect(css).toContain('.today-environment__gold{grid-column:2;grid-row:2;border-top:1px solid var(--border-cream)}')
|
||||||
expect(css).toContain('.today-environment__weather,.today-environment__gold{padding:13px 10px;display:flex;flex-direction:column;')
|
expect(css).toContain('.today-environment__weather,.today-environment__gold{padding:var(--space-12) var(--space-10);display:flex;flex-direction:column;')
|
||||||
expect(css).toContain('.today-environment__calendar-main--desktop,.today-environment__gold-date--desktop{display:none}')
|
expect(css).toContain('.today-environment__calendar-main--desktop,.today-environment__gold-date--desktop{display:none}')
|
||||||
expect(css).toContain('.today-environment__calendar-main--mobile,.today-environment__gold-date--mobile{display:inline}')
|
expect(css).toContain('.today-environment__calendar-main--mobile,.today-environment__gold-date--mobile{display:inline}')
|
||||||
const mobileBlock = css.slice(css.indexOf('@media(max-width:720px){.today-environment'), css.indexOf('/* Solid cream material system'))
|
const mobileBlock = css.slice(css.indexOf('@media(max-width:720px){.today-environment'), css.indexOf('/* Solid cream material system'))
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { failureHint } from './failureHint'
|
||||||
|
|
||||||
|
describe('failureHint', () => {
|
||||||
|
it('uses network hint without error', () => {
|
||||||
|
expect(failureHint('保存失败')).toBe('保存失败,请检查网络后重试')
|
||||||
|
})
|
||||||
|
it('classifies TypeError as network', () => {
|
||||||
|
const e = new TypeError('Failed to fetch')
|
||||||
|
expect(failureHint('请求失败', e)).toBe('请求失败,请检查网络后重试')
|
||||||
|
})
|
||||||
|
it('classifies 401 as auth', () => {
|
||||||
|
expect(failureHint('加载失败', { status: 401 })).toBe('加载失败,请重新登录后再试')
|
||||||
|
})
|
||||||
|
it('classifies 5xx as server', () => {
|
||||||
|
expect(failureHint('保存失败', { status: 502 })).toBe('保存失败,服务器出错,请稍后重试')
|
||||||
|
})
|
||||||
|
it('keeps Chinese server detail', () => {
|
||||||
|
expect(failureHint('保存失败', new Error('重复规则冲突'))).toBe('保存失败:重复规则冲突')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
type FailErr = unknown
|
||||||
|
|
||||||
|
/** 错误提示统一出口:动作短语 + 可操作后缀(网络/鉴权/校验/服务端四类) */
|
||||||
|
export function failureHint(phrase: string, err?: FailErr): string {
|
||||||
|
if (!err) return `${phrase},请检查网络后重试`
|
||||||
|
const e = err as { name?: string; message?: string; status?: number; response?: { status?: number } }
|
||||||
|
const name = String(e.name ?? "")
|
||||||
|
const msg = String(e.message ?? "")
|
||||||
|
const status = Number(e.status ?? e.response?.status ?? 0)
|
||||||
|
const blob = `${name} ${msg}`
|
||||||
|
if (status === 401 || status === 403 || /unauthorized|\b401\b|登录|token/i.test(blob)) return `${phrase},请重新登录后再试`
|
||||||
|
if (name === 'TypeError' || /failed to fetch|networkerror|network request failed|net::|fetch/i.test(blob)) return `${phrase},请检查网络后重试`
|
||||||
|
if (status >= 500) return `${phrase},服务器出错,请稍后重试`
|
||||||
|
if (status >= 400) return `${phrase},请检查填写内容后重试`
|
||||||
|
if (/\p{Script=Han}/u.test(msg)) return `${phrase}:${msg}`
|
||||||
|
return `${phrase},请重试`
|
||||||
|
}
|
||||||
@@ -894,5 +894,5 @@ export function formatApiErrorDetail(detail: unknown): string {
|
|||||||
if ('detail' in record) return formatApiErrorDetail(record.detail)
|
if ('detail' in record) return formatApiErrorDetail(record.detail)
|
||||||
return JSON.stringify(record)
|
return JSON.stringify(record)
|
||||||
}
|
}
|
||||||
return '请求失败'
|
return '请求失败,请检查网络后重试'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:var(--radius-control);background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:var(--accent-ink);font-weight:700}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;border:0;background:var(--surface-raised);padding:9px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:var(--surface-hover)}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row time{grid-column:2;grid-row:1;align-self:center;color:var(--text-secondary);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--text-secondary);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:var(--error-wash);border-radius:var(--radius-control);padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:700;letter-spacing:.06em;color:var(--text-secondary)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:var(--radius-control);background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:var(--radius-control);background:var(--white);padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;gap:12px}.memo-scope{display:flex;gap:4px;padding:var(--space-2);border:1px solid var(--border-cream);border-radius:var(--radius-control);background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;padding:0 var(--space-12)}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:var(--accent-ink);font-weight:700}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;border:0;background:var(--surface-raised);padding:var(--space-8) var(--space-14);text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:var(--surface-hover)}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row time{grid-column:2;grid-row:1;align-self:center;color:var(--text-secondary);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--text-secondary);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:var(--error-wash);border-radius:var(--radius-control);padding:var(--space-10) var(--space-12)}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:var(--space-10) var(--space-16);border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:700;letter-spacing:.06em;color:var(--text-secondary)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:var(--radius-control);background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:var(--space-18)}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:var(--radius-control);background:var(--white);padding:var(--space-12);outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||||
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:block}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:9px}.memo-row{height:74px;min-height:74px;max-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
@media(max-width:930px){.shell.memo-detail-open main{padding:var(--space-20) var(--space-16) 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:block}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:var(--space-8)}.memo-row{height:74px;min-height:74px;max-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:var(--space-16)}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||||
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
||||||
|
|||||||
+110
-84
File diff suppressed because one or more lines are too long
+29
-29
@@ -79,10 +79,10 @@ describe('unified task due display', () => {
|
|||||||
|
|
||||||
describe('approved cream solid button system', () => {
|
describe('approved cream solid button system', () => {
|
||||||
it('gives semantic action buttons a compact desktop size and a touch-safe mobile size', () => {
|
it('gives semantic action buttons a compact desktop size and a touch-safe mobile size', () => {
|
||||||
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 15px;border-radius:var(--radius-control);font-size:13px;font-weight:700;line-height:1;white-space:nowrap;transition:transform .14s ease,background-color .14s ease,border-color .14s ease,box-shadow .14s ease,color .14s ease}')
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 var(--space-14);border-radius:var(--radius-control);font-size:13px;font-weight:700;line-height:1;white-space:nowrap;transition:transform .14s ease,background-color .14s ease,border-color .14s ease,box-shadow .14s ease,color .14s ease}')
|
||||||
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:16px;height:16px;flex:0 0 auto}')
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:16px;height:16px;flex:0 0 auto}')
|
||||||
expect(css).toContain('@media(min-width:931px){main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button),.numeric-action .soft-button,.habit-history__message .soft-button,.backup-preflight .danger-button,.app-sheet__danger :is(.soft-button,.primary-small,.danger-button,.file-button,.secondary,.restore),.purge-list-dialog :is(.secondary,.danger-button){min-height:40px}}')
|
expect(css).toContain('@media(min-width:931px){main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button),.numeric-action .soft-button,.habit-history__message .soft-button,.backup-preflight .danger-button,.app-sheet__danger :is(.soft-button,.primary-small,.danger-button,.file-button,.secondary,.restore),.purge-list-dialog :is(.secondary,.danger-button){min-height:40px}}')
|
||||||
expect(css).toContain('@media(max-width:930px){:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:44px;padding-inline:16px;gap:7px}:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:17px;height:17px}}')
|
expect(css).toContain('@media(max-width:930px){:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:44px;padding-inline:var(--space-16);gap:7px}:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:17px;height:17px}}')
|
||||||
expect(css).toContain(':is(.primary,.primary-small){border:1px solid var(--accent);background:var(--accent);color:var(--white);box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
|
expect(css).toContain(':is(.primary,.primary-small){border:1px solid var(--accent);background:var(--accent);color:var(--white);box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
|
||||||
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button){border:1px solid var(--soft-button-border-2);background:var(--row-hover);color:var(--soft-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(83,62,42,.06)}')
|
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button){border:1px solid var(--soft-button-border-2);background:var(--row-hover);color:var(--soft-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(83,62,42,.06)}')
|
||||||
expect(css).toContain('.danger-button{border:1px solid var(--danger-button-border-3);background:var(--danger-btn-bg);color:var(--danger-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(105,48,34,.05)}')
|
expect(css).toContain('.danger-button{border:1px solid var(--danger-button-border-3);background:var(--danger-btn-bg);color:var(--danger-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(105,48,34,.05)}')
|
||||||
@@ -171,15 +171,15 @@ describe('approved Settings 01 paper ledger', () => {
|
|||||||
|
|
||||||
it('uses the selected continuous-paper geometry on desktop and mobile', () => {
|
it('uses the selected continuous-paper geometry on desktop and mobile', () => {
|
||||||
expect(css).toContain('main:has(>.mvp-view .settings-sections){background:var(--surface-raised)}')
|
expect(css).toContain('main:has(>.mvp-view .settings-sections){background:var(--surface-raised)}')
|
||||||
expect(css).toContain('.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:34px 0 64px}')
|
expect(css).toContain('.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:var(--space-32) 0 64px}')
|
||||||
expect(css).toContain('.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;')
|
expect(css).toContain('.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;')
|
||||||
expect(css).toContain('.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:25px}')
|
expect(css).toContain('.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:var(--space-24)}')
|
||||||
expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid var(--border-hairline)}')
|
expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid var(--border-hairline)}')
|
||||||
expect(css).toContain('.settings-row{min-height:62px;padding:6px 0;border-top:0;border-bottom:1px solid var(--border-hairline);')
|
expect(css).toContain('.settings-row{min-height:62px;padding:var(--space-6) 0;border-top:0;border-bottom:1px solid var(--border-hairline);')
|
||||||
expect(css).toContain('.settings-topbar{margin-bottom:0}')
|
expect(css).toContain('.settings-topbar{margin-bottom:0}')
|
||||||
expect(css).not.toContain('.topbar>.settings-refresh{grid-area:filter}')
|
expect(css).not.toContain('.topbar>.settings-refresh{grid-area:filter}')
|
||||||
expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:29px;padding-right:29px;padding-bottom:calc(102px + env(safe-area-inset-bottom))}')
|
expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:var(--space-28);padding-right:var(--space-28);padding-bottom:calc(102px + env(safe-area-inset-bottom))}')
|
||||||
expect(css).toContain('.settings-sections{width:100%;padding-top:23px;padding-bottom:0}')
|
expect(css).toContain('.settings-sections{width:100%;padding-top:var(--space-24);padding-bottom:0}')
|
||||||
expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}')
|
expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}')
|
||||||
expect(css).toContain('.settings-row{min-height:64px}')
|
expect(css).toContain('.settings-row{min-height:64px}')
|
||||||
})
|
})
|
||||||
@@ -391,12 +391,12 @@ describe('approved UI detail direction', () => {
|
|||||||
|
|
||||||
it('matches the approved paper-flow geometry and material', () => {
|
it('matches the approved paper-flow geometry and material', () => {
|
||||||
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
|
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
|
||||||
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}')
|
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:var(--space-24)}')
|
||||||
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--border-cream);background:var(--surface-raised);overflow:hidden;display:flex;flex-direction:column}')
|
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--border-cream);background:var(--surface-raised);overflow:hidden;display:flex;flex-direction:column}')
|
||||||
expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;')
|
expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 var(--space-18);')
|
||||||
expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:18px;display:grid;gap:18px;overflow-y:auto;')
|
expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:var(--space-18);display:grid;gap:18px;overflow-y:auto;')
|
||||||
expect(css).toContain('.detail-title textarea{min-width:0;min-height:62px;padding:0;border:0;background:transparent;box-shadow:none;resize:none;outline:none;font-size:20px;line-height:27px;')
|
expect(css).toContain('.detail-title textarea{min-width:0;min-height:62px;padding:0;border:0;background:transparent;box-shadow:none;resize:none;outline:none;font-size:20px;line-height:27px;')
|
||||||
expect(css).toContain('.task-detail-arrangement{display:grid;gap:13px;padding-bottom:18px;border-bottom:1px solid var(--border-section)}')
|
expect(css).toContain('.task-detail-arrangement{display:grid;gap:13px;padding-bottom:var(--space-18);border-bottom:1px solid var(--border-section)}')
|
||||||
expect(css).toContain('.task-detail-date-time{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}')
|
expect(css).toContain('.task-detail-date-time{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}')
|
||||||
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}')
|
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}')
|
||||||
expect(css).toContain('.detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;')
|
expect(css).toContain('.detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;')
|
||||||
@@ -420,7 +420,7 @@ describe('approved UI detail direction', () => {
|
|||||||
it('keeps custom recurrence controls at 44px without overflowing narrow screens', () => {
|
it('keeps custom recurrence controls at 44px without overflowing narrow screens', () => {
|
||||||
expect(css).toContain('.repeat-custom-fields input,.repeat-custom-fields select{min-height:44px;')
|
expect(css).toContain('.repeat-custom-fields input,.repeat-custom-fields select{min-height:44px;')
|
||||||
expect(css).toContain('.weekday-picker label{width:44px;height:44px;')
|
expect(css).toContain('.weekday-picker label{width:44px;height:44px;')
|
||||||
expect(css).toContain('@media(max-width:930px){.repeat-custom-fields{padding:10px;gap:8px}')
|
expect(css).toContain('@media(max-width:930px){.repeat-custom-fields{padding:var(--space-10);gap:8px}')
|
||||||
expect(css).toContain('.repeat-custom-fields>div,.repeat-custom-fields>label{min-width:0;flex-wrap:wrap;gap:6px}')
|
expect(css).toContain('.repeat-custom-fields>div,.repeat-custom-fields>label{min-width:0;flex-wrap:wrap;gap:6px}')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ describe('completion feedback motion', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('stacks the desktop calendar copy to align with weather and gold', () => {
|
it('stacks the desktop calendar copy to align with weather and gold', () => {
|
||||||
expect(css).toContain('@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:2px;font-size:11px}}')
|
expect(css).toContain('@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:var(--space-2);font-size:11px}}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the approved internal-standard login composition and material tokens', () => {
|
it('uses the approved internal-standard login composition and material tokens', () => {
|
||||||
@@ -495,12 +495,12 @@ describe('completion feedback motion', () => {
|
|||||||
expect(app).toContain('<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>')
|
expect(app).toContain('<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>')
|
||||||
expect(app).toContain('<form class="auth-card" aria-labelledby="auth-title" @submit.prevent="submitAuth">')
|
expect(app).toContain('<form class="auth-card" aria-labelledby="auth-title" @submit.prevent="submitAuth">')
|
||||||
expect(app).toContain('<button type="submit" class="primary">')
|
expect(app).toContain('<button type="submit" class="primary">')
|
||||||
expect(css).toMatch(/\.auth-card\{[^}]*width:min\(400px,100%\)[^}]*padding:36px[^}]*border:1px solid var\(--border-cream\)[^}]*border-radius:var\(--radius-card\)/)
|
expect(css).toMatch(/\.auth-card\{[^}]*width:min\(400px,100%\)[^}]*padding:var\(--space-36\)[^}]*border:1px solid var\(--border-cream\)[^}]*border-radius:var\(--radius-card\)/)
|
||||||
expect(css).toContain('.auth-brand .brand{gap:9px;font-size:28px;letter-spacing:-2px}')
|
expect(css).toContain('.auth-brand .brand{gap:9px;font-size:28px;letter-spacing:-2px}')
|
||||||
expect(css).toContain('.auth-brand .brand-logo{width:34px;height:34px}')
|
expect(css).toContain('.auth-brand .brand-logo{width:34px;height:34px}')
|
||||||
expect(css).toMatch(/\.auth-card input\{[^}]*min-height:46px[^}]*background:var\(--surface-raised\)/)
|
expect(css).toMatch(/\.auth-card input\{[^}]*min-height:46px[^}]*background:var\(--surface-raised\)/)
|
||||||
expect(css).toContain('.auth-card>.primary{min-height:46px}')
|
expect(css).toContain('.auth-card>.primary{min-height:46px}')
|
||||||
expect(css).toMatch(/@media\(max-width:930px\)\{\.auth-shell\{padding:18px\}\.auth-card\{padding:30px 24px\}/)
|
expect(css).toMatch(/@media\(max-width:930px\)\{\.auth-shell\{padding:var\(--space-18\)\}\.auth-card\{padding:var\(--space-28\) var\(--space-24\)\}/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('syncs browser IANA timezone before loading user task data', () => {
|
it('syncs browser IANA timezone before loading user task data', () => {
|
||||||
@@ -526,8 +526,8 @@ describe('completion feedback motion', () => {
|
|||||||
expect(css).toContain('.markdown-toolbar{display:flex;')
|
expect(css).toContain('.markdown-toolbar{display:flex;')
|
||||||
expect(css).toContain('overflow-x:auto')
|
expect(css).toContain('overflow-x:auto')
|
||||||
expect(css).toContain('.markdown-toolbar button{min-width:44px;min-height:44px;')
|
expect(css).toContain('.markdown-toolbar button{min-width:44px;min-height:44px;')
|
||||||
expect(css).toContain('.markdown-preview>.contains-task-list{list-style:none;padding-left:3px}')
|
expect(css).toContain('.markdown-preview>.contains-task-list{list-style:none;padding-left:var(--space-2)}')
|
||||||
expect(css).toContain('.markdown-preview .contains-task-list .contains-task-list{padding-left:24px}')
|
expect(css).toContain('.markdown-preview .contains-task-list .contains-task-list{padding-left:var(--space-24)}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps all task detail changes on the unified save button', () => {
|
it('keeps all task detail changes on the unified save button', () => {
|
||||||
@@ -545,7 +545,7 @@ describe('completion feedback motion', () => {
|
|||||||
|
|
||||||
it('keeps the desktop task composer compact and centered', () => {
|
it('keeps the desktop task composer compact and centered', () => {
|
||||||
expect(css).toContain('@media(min-width:931px){.task-compose-sheet:not(.habit-compose-sheet){width:min(560px,calc(100vw - 48px));height:min(520px,82dvh);max-height:none;padding:0;display:flex;flex-direction:column;gap:0;overflow:hidden}')
|
expect(css).toContain('@media(min-width:931px){.task-compose-sheet:not(.habit-compose-sheet){width:min(560px,calc(100vw - 48px));height:min(520px,82dvh);max-height:none;padding:0;display:flex;flex-direction:column;gap:0;overflow:hidden}')
|
||||||
expect(css).toContain('.app-sheet-mask:has(>.task-compose-sheet:not(.habit-compose-sheet)){place-items:center;padding:24px}')
|
expect(css).toContain('.app-sheet-mask:has(>.task-compose-sheet:not(.habit-compose-sheet)){place-items:center;padding:var(--space-24)}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
|
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
|
||||||
@@ -1072,7 +1072,7 @@ describe('task and habit row decoration', () => {
|
|||||||
const mobileLayout = css.slice(css.indexOf('@media(max-width:930px){.shell'))
|
const mobileLayout = css.slice(css.indexOf('@media(max-width:930px){.shell'))
|
||||||
expect(mobileLayout).toContain('.completed-filter-pill{min-width:138px;height:44px')
|
expect(mobileLayout).toContain('.completed-filter-pill{min-width:138px;height:44px')
|
||||||
expect(css).not.toContain('width:min(260px,100%)')
|
expect(css).not.toContain('width:min(260px,100%)')
|
||||||
expect(css).not.toContain('.today-completed-toolbar{margin-top:8px}')
|
expect(css).not.toContain('.today-completed-toolbar{margin-top:var(--space-8)}')
|
||||||
expect(css).not.toContain('.habit-toolbar{')
|
expect(css).not.toContain('.habit-toolbar{')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1368,7 +1368,7 @@ describe('task detail layout', () => {
|
|||||||
expect(app).toContain('v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input"')
|
expect(app).toContain('v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input"')
|
||||||
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
|
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
|
||||||
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}')
|
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}')
|
||||||
expect(css).toContain('.task-detail-due-input{padding-inline:9px!important}')
|
expect(css).toContain('.task-detail-due-input{padding-inline:var(--space-8)!important}')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1432,7 +1432,7 @@ describe('unified floating add interaction', () => {
|
|||||||
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
|
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
|
||||||
expect(dueRemovalBlock).not.toContain('saveRepeat(')
|
expect(dueRemovalBlock).not.toContain('saveRepeat(')
|
||||||
expect(saveBlock).toContain("selectedRepeatError.value = ''")
|
expect(saveBlock).toContain("selectedRepeatError.value = ''")
|
||||||
expect(saveBlock).toContain("selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'")
|
expect(saveBlock).toContain("selectedRepeatError.value = failureHint('保存失败', reason)")
|
||||||
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)'))
|
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)'))
|
||||||
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
|
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
|
||||||
expect(saveBlock).toContain('savingSelectedTask.value = true')
|
expect(saveBlock).toContain('savingSelectedTask.value = true')
|
||||||
@@ -1614,8 +1614,8 @@ describe('sidebar information hierarchy', () => {
|
|||||||
expect(archiveListBlock).toContain("confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复')")
|
expect(archiveListBlock).toContain("confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复')")
|
||||||
expect(archiveListBlock).not.toContain("askText(`归档清单")
|
expect(archiveListBlock).not.toContain("askText(`归档清单")
|
||||||
expect(css).toContain('.list-editor-sheet{width:min(460px,calc(100vw - 24px));')
|
expect(css).toContain('.list-editor-sheet{width:min(460px,calc(100vw - 24px));')
|
||||||
expect(css).toContain('@media(min-width:931px){.app-sheet-mask:has(.list-editor-sheet){place-items:center;padding:24px}.app-sheet-mask:has(.list-editor-sheet) .list-editor-sheet{margin:0}}')
|
expect(css).toContain('@media(min-width:931px){.app-sheet-mask:has(.list-editor-sheet){place-items:center;padding:var(--space-24)}.app-sheet-mask:has(.list-editor-sheet) .list-editor-sheet{margin:0}}')
|
||||||
expect(css).toContain('.list-editor-form{display:grid;gap:17px;padding:18px 20px}')
|
expect(css).toContain('.list-editor-form{display:grid;gap:17px;padding:var(--space-18) var(--space-20)}')
|
||||||
expect(css).toContain('.list-editor-danger{border-top:1px solid var(--border-cream);')
|
expect(css).toContain('.list-editor-danger{border-top:1px solid var(--border-cream);')
|
||||||
expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
|
expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
|
||||||
expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null')
|
expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null')
|
||||||
@@ -1631,8 +1631,8 @@ describe('quiet index sidebar parity', () => {
|
|||||||
expect(css).toContain('.sidebar{border-right:1px solid var(--border-cream);background:var(--surface-canvas);')
|
expect(css).toContain('.sidebar{border-right:1px solid var(--border-cream);background:var(--surface-canvas);')
|
||||||
expect(css).toContain('.brand-row{height:68px;')
|
expect(css).toContain('.brand-row{height:68px;')
|
||||||
expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)')
|
expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)')
|
||||||
expect(css).toContain('.primary-nav{display:grid;padding:10px 11px 8px;gap:2px}')
|
expect(css).toContain('.primary-nav{display:grid;padding:var(--space-10) var(--space-10) var(--space-8);gap:2px}')
|
||||||
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}')
|
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:var(--space-24)}')
|
||||||
expect(css).not.toContain('grid-template-columns:220px')
|
expect(css).not.toContain('grid-template-columns:220px')
|
||||||
expect(css).toContain('@media(max-width:930px){.shell')
|
expect(css).toContain('@media(max-width:930px){.shell')
|
||||||
expect(css).toContain('left:0;width:236px;max-width:86vw;')
|
expect(css).toContain('left:0;width:236px;max-width:86vw;')
|
||||||
@@ -1642,9 +1642,9 @@ describe('quiet index sidebar parity', () => {
|
|||||||
expect(css).toContain('.primary-nav button.active{background:var(--nav-active-bg);color:var(--accent-ink);')
|
expect(css).toContain('.primary-nav button.active{background:var(--nav-active-bg);color:var(--accent-ink);')
|
||||||
expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{')
|
expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{')
|
||||||
expect(css).toContain('width:3px;height:18px;')
|
expect(css).toContain('width:3px;height:18px;')
|
||||||
expect(css).toContain('.section-title{padding:14px 15px 5px 20px;')
|
expect(css).toContain('.section-title{padding:var(--space-14) var(--space-14) var(--space-4) var(--space-20);')
|
||||||
expect(css).toContain('.folders{flex:1;min-height:0;padding:0 10px;')
|
expect(css).toContain('.folders{flex:1;min-height:0;padding:0 var(--space-10);')
|
||||||
expect(css).toContain('.folder-row>button,.list-row-main{flex:1;min-width:0;min-height:44px;border:0;background:transparent;padding:0 8px;')
|
expect(css).toContain('.folder-row>button,.list-row-main{flex:1;min-width:0;min-height:44px;border:0;background:transparent;padding:0 var(--space-8);')
|
||||||
expect(css).toContain('.list-row.active{background:var(--list-row-bg);color:var(--accent-ink);box-shadow:inset 0 0 0 1px var(--list-row-shadow)}')
|
expect(css).toContain('.list-row.active{background:var(--list-row-bg);color:var(--accent-ink);box-shadow:inset 0 0 0 1px var(--list-row-shadow)}')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1790,7 +1790,7 @@ describe('sidebar layout', () => {
|
|||||||
expect(app).toContain('@close="closePurgeList"')
|
expect(app).toContain('@close="closePurgeList"')
|
||||||
expect(app).not.toContain('handlePurgeDialogKeydown')
|
expect(app).not.toContain('handlePurgeDialogKeydown')
|
||||||
expect(app).toContain('if (purgeListSubmitting.value) return')
|
expect(app).toContain('if (purgeListSubmitting.value) return')
|
||||||
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
|
expect(app).toContain("purgeListError.value = failureHint('永久删除失败', reason)")
|
||||||
expect(app).toContain(':disabled="purgeListSubmitting"')
|
expect(app).toContain(':disabled="purgeListSubmitting"')
|
||||||
expect(app).toContain('role="alert" class="purge-list-error"')
|
expect(app).toContain('role="alert" class="purge-list-error"')
|
||||||
expect(css).toContain('.purge-list-dialog button{min-height:44px;')
|
expect(css).toContain('.purge-list-dialog button{min-height:44px;')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.today-section-toggle{width:100%;min-height:44px;display:flex;align-items:center;padding:0;border:0;border-bottom:1px solid var(--border-hairline);background:transparent;color:var(--text-primary);text-align:left;border-radius:0}.today-section-toggle:hover{background:transparent}.today-section-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.today-section-title{display:flex;align-items:center;font-weight:700;font-size:13px;letter-spacing:.02em}.today-section-summary{margin-left:auto;color:var(--text-secondary);font-size:12px;font-weight:400}.today-section-chevron{width:14px;margin-left:3px;color:var(--text-secondary);font-size:13px;line-height:1;text-align:right}.today-collapsible-section,.today-habits-section{min-width:0}.today-habits-section.today-section-anchor{scroll-margin-top:18px}
|
.today-section-toggle{width:100%;min-height:44px;display:flex;align-items:center;padding:0;border:0;border-bottom:1px solid var(--border-hairline);background:transparent;color:var(--text-primary);text-align:left;border-radius:0}.today-section-toggle:hover{background:transparent}.today-section-toggle:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}.today-section-title{display:flex;align-items:center;font-weight:700;font-size:13px;letter-spacing:.02em}.today-section-summary{margin-left:auto;color:var(--text-secondary);font-size:12px;font-weight:400}.today-section-chevron{width:14px;margin-left:var(--space-2);color:var(--text-secondary);font-size:13px;line-height:1;text-align:right}.today-collapsible-section,.today-habits-section{min-width:0}.today-habits-section.today-section-anchor{scroll-margin-top:18px}
|
||||||
|
|
||||||
|
|
||||||
/* Restrained motion: paper unfold/put-away for the three Today sections (grid-rows 0fr<->1fr 180ms). */
|
/* Restrained motion: paper unfold/put-away for the three Today sections (grid-rows 0fr<->1fr 180ms). */
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ describe('approved five-detail polish', () => {
|
|||||||
expect(app).toContain('<span class="pager-status" aria-live="polite"><strong>{{page}} / {{totalPages}}</strong><span>共 {{ totalTasks }} 项</span></span>')
|
expect(app).toContain('<span class="pager-status" aria-live="polite"><strong>{{page}} / {{totalPages}}</strong><span>共 {{ totalTasks }} 项</span></span>')
|
||||||
expect(app).toContain('class="pager-button pager-button--next"')
|
expect(app).toContain('class="pager-button pager-button--next"')
|
||||||
expect(app).toContain('<span>下一页</span><ChevronRight aria-hidden="true"/>')
|
expect(app).toContain('<span>下一页</span><ChevronRight aria-hidden="true"/>')
|
||||||
expect(css).toContain('.pager{min-height:56px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:8px;margin:8px 0 14px;border-top:1px solid var(--border-hairline);background:transparent}')
|
expect(css).toContain('.pager{min-height:56px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:8px;margin:var(--space-8) 0 var(--space-14);border-top:1px solid var(--border-hairline);background:transparent}')
|
||||||
expect(css).toContain('.pager-button{min-width:0;min-height:44px;padding:0 8px;display:flex;align-items:center;justify-content:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;color:var(--accent-ink);font-size:13px;font-weight:600;box-shadow:none;transition:transform .14s ease,background-color .14s ease,color .14s ease}')
|
expect(css).toContain('.pager-button{min-width:0;min-height:44px;padding:0 var(--space-8);display:flex;align-items:center;justify-content:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;color:var(--accent-ink);font-size:13px;font-weight:600;box-shadow:none;transition:transform .14s ease,background-color .14s ease,color .14s ease}')
|
||||||
expect(css).toContain('.pager-status{min-width:72px;display:grid;justify-items:center;gap:1px;color:var(--text-secondary);font-size:11px;font-variant-numeric:tabular-nums}')
|
expect(css).toContain('.pager-status{min-width:72px;display:grid;justify-items:center;gap:1px;color:var(--text-secondary);font-size:11px;font-variant-numeric:tabular-nums}')
|
||||||
expect(css).toContain('@media(max-width:390px){.pager-button{padding:0 4px}.pager{grid-template-columns:minmax(0,1fr) 72px minmax(0,1fr);gap:2px}}')
|
expect(css).toContain('@media(max-width:390px){.pager-button{padding:0 var(--space-4)}.pager{grid-template-columns:minmax(0,1fr) 72px minmax(0,1fr);gap:2px}}')
|
||||||
expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }")
|
expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }")
|
||||||
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
|
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
|
||||||
expect(app).not.toContain('class="list-search-clear"')
|
expect(app).not.toContain('class="list-search-clear"')
|
||||||
@@ -109,9 +109,9 @@ describe('approved five-detail polish', () => {
|
|||||||
expect(app).toContain('class="trash-groups"')
|
expect(app).toContain('class="trash-groups"')
|
||||||
expect(css).toContain('.list-page-context{width:min(100%,630px);margin:0 auto 0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}')
|
expect(css).toContain('.list-page-context{width:min(100%,630px);margin:0 auto 0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}')
|
||||||
expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}')
|
expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}')
|
||||||
expect(css).toContain('.list-page-summary{margin:8px 0 18px;color:var(--text-secondary);font-size:13px}')
|
expect(css).toContain('.list-page-summary{margin:var(--space-8) 0 var(--space-18);color:var(--text-secondary);font-size:13px}')
|
||||||
expect(css).toContain('@media(min-width:1440px){main.list-main>.list-page-context')
|
expect(css).toContain('@media(min-width:1440px){main.list-main>.list-page-context')
|
||||||
expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}')
|
expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:var(--space-28);padding-right:var(--space-28)}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}')
|
||||||
expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}')
|
expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}')
|
||||||
expect(css).not.toContain('.list-search-reveal{')
|
expect(css).not.toContain('.list-search-reveal{')
|
||||||
expect(css).toContain('@media(max-width:720px){')
|
expect(css).toContain('@media(max-width:720px){')
|
||||||
@@ -139,10 +139,10 @@ describe('approved five-detail polish', () => {
|
|||||||
expect(css).toContain('.habit-section-heading{margin-top:0}')
|
expect(css).toContain('.habit-section-heading{margin-top:0}')
|
||||||
expect(css).toContain('main.list-main>.mvp-view{gap:0}')
|
expect(css).toContain('main.list-main>.mvp-view{gap:0}')
|
||||||
expect(css).toContain('main.list-main>.mvp-view>.habit-section-heading{width:100%}')
|
expect(css).toContain('main.list-main>.mvp-view>.habit-section-heading{width:100%}')
|
||||||
expect(css).toContain('main.list-main>.mvp-view>.habit-archive-section{margin-top:24px;padding-top:12px;border-top:1px solid var(--border-hairline)}')
|
expect(css).toContain('main.list-main>.mvp-view>.habit-archive-section{margin-top:var(--space-24);padding-top:var(--space-12);border-top:1px solid var(--border-hairline)}')
|
||||||
expect(css).toContain('.habit-archive-section{display:grid;gap:10px}')
|
expect(css).toContain('.habit-archive-section{display:grid;gap:10px}')
|
||||||
expect(css).toContain('.habit-archive-toggle{width:100%;min-height:44px;display:grid;grid-template-columns:28px minmax(0,1fr) 28px;align-items:center;gap:8px;padding:0 4px;border:0;background:transparent;box-shadow:none')
|
expect(css).toContain('.habit-archive-toggle{width:100%;min-height:44px;display:grid;grid-template-columns:28px minmax(0,1fr) 28px;align-items:center;gap:8px;padding:0 var(--space-4);border:0;background:transparent;box-shadow:none')
|
||||||
expect(css).toContain('.archived-habits{margin-top:2px;')
|
expect(css).toContain('.archived-habits{margin-top:var(--space-2);')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses one 58px plain-list contract for active task and habit rows', () => {
|
it('uses one 58px plain-list contract for active task and habit rows', () => {
|
||||||
@@ -159,7 +159,7 @@ describe('approved five-detail polish', () => {
|
|||||||
expect(css).toContain('.habit-row{grid-template-columns:44px minmax(0,1fr) auto;grid-template-rows:18px 4px;align-content:center;row-gap:5px}')
|
expect(css).toContain('.habit-row{grid-template-columns:44px minmax(0,1fr) auto;grid-template-rows:18px 4px;align-content:center;row-gap:5px}')
|
||||||
expect(css).toContain('.habit-row:has(>.habit-progress-bar)>.habit-main,.habit-row:has(>.habit-progress-bar)>.habit-progress-bar{transform:translateY(3px)}')
|
expect(css).toContain('.habit-row:has(>.habit-progress-bar)>.habit-main,.habit-row:has(>.habit-progress-bar)>.habit-progress-bar{transform:translateY(3px)}')
|
||||||
expect(css).toContain('.task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}')
|
expect(css).toContain('.task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}')
|
||||||
expect(css).toContain('.task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:12px;font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis}')
|
expect(css).toContain('.task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:var(--space-12);font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis}')
|
||||||
expect(css).toContain('.task-check{width:44px;')
|
expect(css).toContain('.task-check{width:44px;')
|
||||||
expect(css).toContain('.plain-list .habit-check{margin-left:0}')
|
expect(css).toContain('.plain-list .habit-check{margin-left:0}')
|
||||||
expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;')
|
expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;')
|
||||||
|
|||||||
Reference in New Issue
Block a user