feat: improve memo editing workflow
This commit is contained in:
+20
-8
@@ -12,6 +12,7 @@ import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionEx
|
|||||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||||
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
||||||
import { nextDialogFocusIndex } from './lib/list-purge'
|
import { nextDialogFocusIndex } from './lib/list-purge'
|
||||||
|
import { deriveMemoShellState } from './lib/app-shell-state'
|
||||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||||
import MvpPanel from './MvpPanel.vue'
|
import MvpPanel from './MvpPanel.vue'
|
||||||
import CountdownPanel from './CountdownPanel.vue'
|
import CountdownPanel from './CountdownPanel.vue'
|
||||||
@@ -155,6 +156,11 @@ const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
|
|||||||
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
|
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
|
||||||
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
|
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
|
||||||
const memoTrash = ref(false)
|
const memoTrash = ref(false)
|
||||||
|
const memoDetailOpen = ref(false)
|
||||||
|
const compactLayout = ref(window.innerWidth <= 930)
|
||||||
|
const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value }))
|
||||||
|
const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert)
|
||||||
|
const showFloatingAdd = computed(() => memoShellState.value.showFab)
|
||||||
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||||
let suppressTaskClickId = ''
|
let suppressTaskClickId = ''
|
||||||
|
|
||||||
@@ -618,6 +624,7 @@ async function switchView(view: View, listId?: string) {
|
|||||||
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
|
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
|
||||||
page.value = 1
|
page.value = 1
|
||||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
|
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
|
||||||
|
if (view !== 'memos') memoDetailOpen.value = false
|
||||||
if (view === 'trash') await loadTrash()
|
if (view === 'trash') await loadTrash()
|
||||||
else if (view === 'today') await loadTodayView()
|
else if (view === 'today') await loadTodayView()
|
||||||
else if (!isTaskView(view)) tasks.value = []
|
else if (!isTaskView(view)) tasks.value = []
|
||||||
@@ -1306,11 +1313,16 @@ function handleSearchBlur() {
|
|||||||
if (!query.value.trim()) mobileSearchOpen.value = false
|
if (!query.value.trim()) mobileSearchOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleViewportResize() {
|
||||||
|
compactLayout.value = window.innerWidth <= 930
|
||||||
|
handleArchivedListViewportChange()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
|
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
|
||||||
document.addEventListener('keydown', handleArchivedListEscape)
|
document.addEventListener('keydown', handleArchivedListEscape)
|
||||||
document.addEventListener('keydown', handleTaskSearchShortcut)
|
document.addEventListener('keydown', handleTaskSearchShortcut)
|
||||||
window.addEventListener('resize', handleArchivedListViewportChange)
|
window.addEventListener('resize', handleViewportResize)
|
||||||
document.addEventListener('scroll', handleArchivedListViewportChange, true)
|
document.addEventListener('scroll', handleArchivedListViewportChange, true)
|
||||||
void bootstrap()
|
void bootstrap()
|
||||||
})
|
})
|
||||||
@@ -1318,7 +1330,7 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
|
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
|
||||||
document.removeEventListener('keydown', handleArchivedListEscape)
|
document.removeEventListener('keydown', handleArchivedListEscape)
|
||||||
document.removeEventListener('keydown', handleTaskSearchShortcut)
|
document.removeEventListener('keydown', handleTaskSearchShortcut)
|
||||||
window.removeEventListener('resize', handleArchivedListViewportChange)
|
window.removeEventListener('resize', handleViewportResize)
|
||||||
document.removeEventListener('scroll', handleArchivedListViewportChange, true)
|
document.removeEventListener('scroll', handleArchivedListViewportChange, true)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -1334,9 +1346,9 @@ onUnmounted(() => {
|
|||||||
<small v-if="error" role="alert">{{ error }}</small>
|
<small v-if="error" role="alert">{{ error }}</small>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="shell" :class="{ 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask), 'mobile-sidebar-open': mobileSidebar }">
|
<div v-else class="shell" :class="{ 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask), 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar }">
|
||||||
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
|
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
|
||||||
<aside class="sidebar" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
|
<aside class="sidebar" :inert="memoBackgroundInert ? true : undefined" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
|
||||||
<div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
|
<div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
|
||||||
<nav class="primary-nav">
|
<nav class="primary-nav">
|
||||||
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
|
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
|
||||||
@@ -1407,7 +1419,7 @@ onUnmounted(() => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
||||||
<header class="topbar">
|
<header class="topbar" :inert="memoBackgroundInert ? true : undefined">
|
||||||
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
|
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
|
||||||
<div class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
<div class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
||||||
<div v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" class="topbar-actions">
|
<div v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" class="topbar-actions">
|
||||||
@@ -1422,7 +1434,7 @@ onUnmounted(() => {
|
|||||||
<template v-if="['habits','settings'].includes(activeView)">
|
<template v-if="['habits','settings'].includes(activeView)">
|
||||||
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" />
|
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" />
|
||||||
</template>
|
</template>
|
||||||
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @notice="toast" />
|
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
|
||||||
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
|
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
|
||||||
@@ -1505,8 +1517,8 @@ onUnmounted(() => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
|
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
|
||||||
<nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||||
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="activeView!=='memos' || !memoTrash" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
||||||
<Transition name="task-compose">
|
<Transition name="task-compose">
|
||||||
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
||||||
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ describe('memo shell integration', () => {
|
|||||||
expect(bottom.match(/aria-current=/g)).toHaveLength(4)
|
expect(bottom.match(/aria-current=/g)).toHaveLength(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes the shared cat FAB to memo creation and hides it in memo trash', () => {
|
it('routes the shared cat FAB to a local memo draft, hides it in trash, and defers POST until save', () => {
|
||||||
expect(app).toContain("activeView.value === 'memos'")
|
expect(app).toContain("activeView.value === 'memos'")
|
||||||
expect(app).toContain('memoPanel.value?.createMemo()')
|
expect(app).toContain('memoPanel.value?.createMemo()')
|
||||||
expect(app).toContain(':show="activeView!==\'memos\' || !memoTrash"')
|
expect(app).toContain(':show="showFloatingAdd"')
|
||||||
expect(panel).toContain("scope.value === 'trash'")
|
expect(panel).toContain("scope.value === 'trash'")
|
||||||
|
const createBlock = panel.slice(panel.indexOf('async function createMemo()'), panel.indexOf('function memoExcerpt'))
|
||||||
|
expect(createBlock).toContain('id: null')
|
||||||
|
expect(createBlock).not.toContain("props.request('/memos'")
|
||||||
|
expect(editor).toContain("memoId === null ? '/memos'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps memo UI componentized and task detail copy explicit', () => {
|
it('keeps memo UI componentized and task detail copy explicit', () => {
|
||||||
@@ -31,11 +35,24 @@ describe('memo shell integration', () => {
|
|||||||
expect(app).toContain('<div class="field-label"><span>任务备注</span>')
|
expect(app).toContain('<div class="field-label"><span>任务备注</span>')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('tracks editor state in the shell, reserves desktop space, hides the FAB, and marks mobile background regions inert', () => {
|
||||||
|
expect(app).toContain('const memoDetailOpen = ref(false)')
|
||||||
|
expect(app).toContain("'memo-detail-open': activeView==='memos' && memoDetailOpen")
|
||||||
|
expect(app).toContain('@detail="memoDetailOpen=$event"')
|
||||||
|
expect(app).toContain(':inert="memoBackgroundInert ? true : undefined"')
|
||||||
|
expect(app).toContain(':show="showFloatingAdd"')
|
||||||
|
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('@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}')
|
||||||
|
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%')
|
||||||
|
})
|
||||||
|
|
||||||
it('uses a 350px desktop detail, near-full mobile sheet, continuous rows and reduced motion', () => {
|
it('uses a 350px desktop detail, near-full mobile sheet, continuous rows and reduced motion', () => {
|
||||||
expect(css).toContain('.memo-editor{width:350px')
|
expect(css).toContain('.memo-editor{width:350px')
|
||||||
expect(css).toContain('.memo-list{display:grid;gap:0')
|
expect(css).toContain('.memo-list{display:grid;gap:0')
|
||||||
expect(css).toContain('.memo-row+.memo-row{border-top:1px solid var(--border-cream)}')
|
expect(css).toContain('.memo-row+.memo-row{border-top:1px solid var(--border-cream)}')
|
||||||
expect(css).toContain('@media(max-width:930px){.memo-panel{')
|
const mobileMedia = css.slice(css.indexOf('@media(max-width:930px){'), css.indexOf('@media(prefers-reduced-motion:reduce)'))
|
||||||
|
expect(mobileMedia).toContain('.memo-panel{')
|
||||||
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto')
|
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto')
|
||||||
expect(css).toContain('height:min(92dvh,820px)')
|
expect(css).toContain('height:min(92dvh,820px)')
|
||||||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor')
|
expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor')
|
||||||
|
|||||||
+745
-11
@@ -7,14 +7,24 @@ const item = { id: 'm1', title: '第一条', excerpt: '摘要', version: 1, crea
|
|||||||
|
|
||||||
type RequestMock = ReturnType<typeof vi.fn<(path: string, options?: RequestInit) => Promise<unknown>>>
|
type RequestMock = ReturnType<typeof vi.fn<(path: string, options?: RequestInit) => Promise<unknown>>>
|
||||||
|
|
||||||
async function mount(request: RequestMock) {
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||||
|
|
||||||
|
async function mount(request: RequestMock, onNotice?: (message: string) => void, onDetail?: (open: boolean) => void) {
|
||||||
const host = document.createElement('div'); document.body.append(host)
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
let panel: InstanceType<typeof MemoPanel> | null = null
|
let panel: InstanceType<typeof MemoPanel> | null = null
|
||||||
const app = createApp(() => h(MemoPanel, { ref: (value: unknown) => { panel = value as InstanceType<typeof MemoPanel> }, request }))
|
const app = createApp(() => h(MemoPanel, { ref: (value: unknown) => { panel = value as InstanceType<typeof MemoPanel> }, request, onNotice, onDetail }))
|
||||||
app.mount(host)
|
app.mount(host)
|
||||||
cleanups.push(() => { app.unmount(); host.remove() })
|
let mounted = true
|
||||||
|
const unmount = () => { if (!mounted) return; mounted = false; app.unmount(); host.remove() }
|
||||||
|
cleanups.push(unmount)
|
||||||
await Promise.resolve(); await Promise.resolve(); await nextTick()
|
await Promise.resolve(); await Promise.resolve(); await nextTick()
|
||||||
return { host, vm: panel! }
|
return { host, vm: panel!, unmount }
|
||||||
}
|
}
|
||||||
afterEach(() => { vi.useRealTimers(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
afterEach(() => { vi.useRealTimers(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
||||||
|
|
||||||
@@ -43,6 +53,116 @@ describe('MemoPanel', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits detail state and restores the actual triggering row after a clean close', async () => {
|
||||||
|
const details: boolean[] = []
|
||||||
|
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||||
|
? { ...item, content: '正文' }
|
||||||
|
: { items: [item], total: 1 })
|
||||||
|
const { host } = await mount(request, undefined, (open) => details.push(open))
|
||||||
|
const row = host.querySelector<HTMLButtonElement>('.memo-row')!
|
||||||
|
row.focus(); row.click(); await flush()
|
||||||
|
expect(details).toEqual([true])
|
||||||
|
expect(document.activeElement).toBe(host.querySelector('[aria-label="备忘录标题"]'))
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||||
|
expect(details).toEqual([true, false])
|
||||||
|
expect(document.activeElement).toBe(row)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the search field when the triggering row disconnects during the close patch', async () => {
|
||||||
|
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||||
|
? { ...item, content: '正文' }
|
||||||
|
: { items: [item], total: 1 })
|
||||||
|
const { host } = await mount(request)
|
||||||
|
const row = host.querySelector<HTMLButtonElement>('.memo-row')!
|
||||||
|
row.click(); await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||||
|
row.remove()
|
||||||
|
await flush()
|
||||||
|
expect(document.activeElement).toBe(host.querySelector('[aria-label="搜索备忘录"]'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['success', null],
|
||||||
|
['error', new Error('迟到保存失败')],
|
||||||
|
])('keeps detail closed after a pending save closes and settles with %s', async (_case, failure) => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const pending = deferred<unknown>()
|
||||||
|
const notices: string[] = []
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => options?.method === 'PATCH'
|
||||||
|
? pending.promise
|
||||||
|
: path === '/memos/m1' ? Promise.resolve({ ...item, content: '正文' }) : Promise.resolve({ items: [item], total: 1 }))
|
||||||
|
const { host } = await mount(request, (message) => notices.push(message))
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||||
|
if (failure) pending.reject(failure)
|
||||||
|
else pending.resolve({ ...item, title: '待保存', content: '正文', version: 2 })
|
||||||
|
await flush()
|
||||||
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
|
expect(host.textContent).not.toContain('迟到保存失败')
|
||||||
|
expect(notices).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps detail closed after a pending reload settles', async () => {
|
||||||
|
const reload = deferred<unknown>()
|
||||||
|
let detailCalls = 0
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && options?.method === 'PATCH') return Promise.reject(Object.assign(new Error('冲突'), { status: 409 }))
|
||||||
|
if (path === '/memos/m1') { detailCalls += 1; return detailCalls === 1 ? Promise.resolve({ ...item, content: '正文' }) : reload.promise }
|
||||||
|
return Promise.resolve({ items: [item], total: 1 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '冲突'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-reload')!.click()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||||
|
reload.resolve({ ...item, title: '迟到重载', content: '正文' }); await flush()
|
||||||
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['remove', null, '.danger-text', '备忘录已移到回收站'],
|
||||||
|
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
|
||||||
|
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
|
||||||
|
])('emits the current %s notice through the panel before closing detail', async (_name, deletedAt, selector, message) => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const scoped = { ...item, deleted_at: deletedAt }
|
||||||
|
const notices: string[] = []
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && !options) return Promise.resolve({ ...scoped, content: '正文' })
|
||||||
|
if (options) return Promise.resolve({ ...item, content: '正文', deleted_at: null })
|
||||||
|
return Promise.resolve({ items: [scoped], total: 1 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request, (message) => notices.push(message))
|
||||||
|
if (deletedAt) { host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush() }
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>(selector)!.click(); await flush()
|
||||||
|
expect(notices).toEqual([message])
|
||||||
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not move focus or close detail when dirty close is cancelled', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
const details: boolean[] = []
|
||||||
|
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||||
|
? { ...item, content: '正文' }
|
||||||
|
: { items: [item], total: 1 })
|
||||||
|
const { host } = await mount(request, undefined, (open) => details.push(open))
|
||||||
|
const row = host.querySelector<HTMLButtonElement>('.memo-row')!
|
||||||
|
row.click(); await flush()
|
||||||
|
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||||
|
content.value = '未保存'; content.dispatchEvent(new Event('input')); content.focus(); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||||
|
expect(host.querySelector('.memo-editor')).not.toBeNull()
|
||||||
|
expect(details).toEqual([true])
|
||||||
|
expect(document.activeElement).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
it('only makes the memo list inert while the detail is a mobile sheet', async () => {
|
it('only makes the memo list inert while the detail is a mobile sheet', async () => {
|
||||||
const originalWidth = window.innerWidth
|
const originalWidth = window.innerWidth
|
||||||
const request = vi.fn(async (path: string) => path.startsWith('/memos/m1') ? { ...item, content: '正文' } : { items: [item], total: 1 })
|
const request = vi.fn(async (path: string) => path.startsWith('/memos/m1') ? { ...item, content: '正文' } : { items: [item], total: 1 })
|
||||||
@@ -58,16 +178,630 @@ describe('MemoPanel', () => {
|
|||||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches between active and trash and creates through the exposed FAB action', async () => {
|
it('ignores stale detail success and failure after selecting another memo', async () => {
|
||||||
const request = vi.fn(async (path: string, options?: RequestInit) => options?.method === 'POST'
|
const first = deferred<unknown>(), second = deferred<unknown>()
|
||||||
? { ...item, id: 'new', title: '未命名备忘录', content: '', excerpt: '' }
|
const other = { ...item, id: 'm2', title: '第二条' }
|
||||||
: { items: [], total: 0 })
|
const request = vi.fn((path: string): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1') return first.promise
|
||||||
|
if (path === '/memos/m2') return second.promise
|
||||||
|
return Promise.resolve({ items: [item, other], total: 2 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
const rows = host.querySelectorAll<HTMLButtonElement>('.memo-row')
|
||||||
|
rows[0].click(); rows[1].click()
|
||||||
|
second.resolve({ ...other, content: 'B 正文' }); await flush()
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||||
|
first.resolve({ ...item, content: 'A 正文' }); await flush()
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||||
|
|
||||||
|
const staleFailure = deferred<unknown>(), latest = deferred<unknown>()
|
||||||
|
request.mockImplementation((path: string): Promise<unknown> => path === '/memos/m1' ? staleFailure.promise : path === '/memos/m2' ? latest.promise : Promise.resolve({ items: [item, other], total: 2 }))
|
||||||
|
rows[0].click(); rows[1].click()
|
||||||
|
latest.resolve({ ...other, content: 'B 新正文' }); await flush()
|
||||||
|
staleFailure.reject(new Error('旧请求失败')); await flush()
|
||||||
|
expect(host.textContent).not.toContain('旧请求失败')
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates an outstanding detail request when closing or switching scope', async () => {
|
||||||
|
const pending = deferred<unknown>()
|
||||||
|
const other = { ...item, id: 'm2', title: '第二条' }
|
||||||
|
let initialDetail = true
|
||||||
|
const request = vi.fn((path: string): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && initialDetail) { initialDetail = false; return Promise.resolve({ ...item, content: 'A' }) }
|
||||||
|
if (path === '/memos/m2') return pending.promise
|
||||||
|
return Promise.resolve({ items: [item, other], total: 2 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
const rows = host.querySelectorAll<HTMLButtonElement>('.memo-row')
|
||||||
|
rows[0].click(); await flush()
|
||||||
|
rows[1].click()
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||||
|
pending.resolve({ ...other, content: '不应打开' }); await flush()
|
||||||
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
|
|
||||||
|
const scopePending = deferred<unknown>()
|
||||||
|
request.mockImplementation((path: string): Promise<unknown> => path === '/memos/m1' ? scopePending.promise : Promise.resolve({ items: [item], total: 1 }))
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
||||||
|
host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush()
|
||||||
|
scopePending.resolve({ ...item, content: '也不应打开' }); await flush()
|
||||||
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates outstanding detail requests after delete and restore', async () => {
|
||||||
|
for (const lifecycle of ['delete', 'restore'] as const) {
|
||||||
|
const pending = deferred<unknown>()
|
||||||
|
const deletedAt = lifecycle === 'restore' ? '2026-09-13T02:00:00Z' : null
|
||||||
|
const scopedItem = { ...item, deleted_at: deletedAt }
|
||||||
|
let detailCalls = 0
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && !options) {
|
||||||
|
detailCalls += 1
|
||||||
|
return detailCalls === 1 ? Promise.resolve({ ...scopedItem, content: '正文' }) : pending.promise
|
||||||
|
}
|
||||||
|
if (options?.method === 'DELETE' || path.endsWith('/restore')) return Promise.resolve({ ...item, content: '正文', deleted_at: null })
|
||||||
|
return Promise.resolve({ items: [scopedItem], total: 1 })
|
||||||
|
})
|
||||||
|
const { host, unmount } = await mount(request)
|
||||||
|
if (lifecycle === 'restore') { host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush() }
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
||||||
|
if (lifecycle === 'delete') {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||||
|
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||||
|
} else host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
||||||
|
await flush()
|
||||||
|
pending.resolve({ ...scopedItem, title: '不应重新打开', content: '迟到详情' }); await flush()
|
||||||
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
|
vi.restoreAllMocks(); unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates outstanding detail requests after create and unmount', async () => {
|
||||||
|
const pending = deferred<unknown>()
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1') return pending.promise
|
||||||
|
if (options?.method === 'POST') return Promise.resolve({ ...item, id: 'new', title: '未命名备忘录', content: '' })
|
||||||
|
return Promise.resolve({ items: [item], total: 1 })
|
||||||
|
})
|
||||||
|
const mounted = await mount(request)
|
||||||
|
mounted.host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
||||||
|
await mounted.vm.createMemo(); await flush()
|
||||||
|
pending.resolve({ ...item, title: '不应覆盖新建', content: '迟到详情' }); await flush()
|
||||||
|
expect(mounted.host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('')
|
||||||
|
|
||||||
|
const afterUnmount = deferred<unknown>()
|
||||||
|
request.mockImplementation((path: string): Promise<unknown> => path === '/memos/m1' ? afterUnmount.promise : Promise.resolve({ items: [item], total: 1 }))
|
||||||
|
mounted.unmount()
|
||||||
|
const second = await mount(request)
|
||||||
|
second.host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
||||||
|
second.unmount()
|
||||||
|
afterUnmount.resolve({ ...item, content: '卸载后迟到' })
|
||||||
|
await flush()
|
||||||
|
expect(second.host.querySelector('.memo-editor')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries the same page after load-more fails', async () => {
|
||||||
|
let pageTwoAttempts = 0
|
||||||
|
const request = vi.fn((path: string): Promise<unknown> => {
|
||||||
|
if (!path.includes('page=2')) return Promise.resolve({ items: [item], total: 51 })
|
||||||
|
pageTwoAttempts += 1
|
||||||
|
if (pageTwoAttempts === 1) return Promise.reject(new Error('分页失败'))
|
||||||
|
return Promise.resolve({ items: [{ ...item, id: 'm2', title: '第二页' }], total: 51 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-load-more')!.click(); await flush()
|
||||||
|
expect(host.textContent).toContain('分页失败')
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-load-more')!.click(); await flush()
|
||||||
|
expect(request.mock.calls.map(([path]) => path).filter((path) => path.includes('page=2'))).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not commit an old reset when query changes before the next debounce', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const oldReset = deferred<unknown>()
|
||||||
|
const nextReset = deferred<unknown>()
|
||||||
|
const request = vi.fn((path: string): Promise<unknown> => {
|
||||||
|
if (path.includes('q=old')) return oldReset.promise
|
||||||
|
if (path.includes('q=next')) return nextReset.promise
|
||||||
|
return Promise.resolve({ items: [item], total: 51 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
|
||||||
|
search.value = 'old'; search.dispatchEvent(new Event('input'))
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
expect(host.querySelector<HTMLButtonElement>('.memo-load-more')?.textContent).toBe('正在加载…')
|
||||||
|
|
||||||
|
search.value = 'next'; search.dispatchEvent(new Event('input'))
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelector<HTMLButtonElement>('.memo-load-more')?.textContent).toBe('加载更多')
|
||||||
|
|
||||||
|
oldReset.resolve({ items: [{ ...item, id: 'old', title: '旧查询结果' }], total: 1 })
|
||||||
|
await flush()
|
||||||
|
expect(host.textContent).not.toContain('旧查询结果')
|
||||||
|
expect(host.querySelector('.memo-row strong')?.textContent).toBe('第一条')
|
||||||
|
expect(request.mock.calls.some(([path]) => path.includes('q=next'))).toBe(false)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
nextReset.resolve({ items: [{ ...item, id: 'next', title: '新查询结果' }], total: 1 })
|
||||||
|
await flush()
|
||||||
|
expect(host.querySelector('.memo-row strong')?.textContent).toBe('新查询结果')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not surface an old reset failure when query changes before the next debounce', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const oldReset = deferred<unknown>()
|
||||||
|
const request = vi.fn((path: string): Promise<unknown> => path.includes('q=old')
|
||||||
|
? oldReset.promise
|
||||||
|
: Promise.resolve({ items: [item], total: 51 }))
|
||||||
|
const { host } = await mount(request)
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
|
||||||
|
search.value = 'old'; search.dispatchEvent(new Event('input'))
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
search.value = 'next'; search.dispatchEvent(new Event('input'))
|
||||||
|
await nextTick()
|
||||||
|
oldReset.reject(new Error('旧查询失败'))
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(host.textContent).not.toContain('旧查询失败')
|
||||||
|
expect(host.querySelector<HTMLButtonElement>('.memo-load-more')?.textContent).toBe('加载更多')
|
||||||
|
expect(request.mock.calls.some(([path]) => path.includes('q=next'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks load-more during a reset and retries a failed reset from page one', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const reset = deferred<unknown>()
|
||||||
|
let resetAttempts = 0
|
||||||
|
const request = vi.fn((path: string): Promise<unknown> => {
|
||||||
|
if (path.includes('q=reset')) {
|
||||||
|
resetAttempts += 1
|
||||||
|
return resetAttempts === 1 ? reset.promise : Promise.resolve({ items: [], total: 0 })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ items: [item], total: 51 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
search.value = 'reset'; search.dispatchEvent(new Event('input'))
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
|
||||||
|
const loadMore = host.querySelector<HTMLButtonElement>('.memo-load-more')!
|
||||||
|
expect(loadMore.disabled).toBe(true)
|
||||||
|
loadMore.click(); await flush()
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
reset.reject(new Error('重置失败')); await flush()
|
||||||
|
expect(loadMore.disabled).toBe(true)
|
||||||
|
loadMore.click(); await flush()
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-error .link')!.click(); await flush()
|
||||||
|
expect(request.mock.calls.at(-1)?.[0]).toContain('q=reset')
|
||||||
|
expect(request.mock.calls.at(-1)?.[0]).toContain('page=1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses id DESC when saved memos have the same updated_at', async () => {
|
||||||
|
const higherId = { ...item, id: 'm2', title: '较高 ID' }
|
||||||
|
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||||
|
? { ...item, content: '正文' }
|
||||||
|
: { items: [item, higherId], total: 2 })
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '同时间更新'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
request.mockResolvedValueOnce({ ...item, title: '同时间更新', content: '正文', version: 2 })
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['较高 ID', '同时间更新'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a 120-character excerpt and truncates 121 characters to 119 plus ellipsis', async () => {
|
||||||
|
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||||
|
? { ...item, content: '正文' }
|
||||||
|
: { items: [item], total: 1 })
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||||
|
content.value = 'a'.repeat(120); content.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
request.mockResolvedValueOnce({ ...item, content: 'a'.repeat(120), version: 2 })
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('a'.repeat(120))
|
||||||
|
|
||||||
|
content.value = 'b'.repeat(121); content.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
request.mockResolvedValueOnce({ ...item, content: 'b'.repeat(121), version: 3 })
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe(`${'b'.repeat(119)}…`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates from the save response, reorders rows, and removes a search mismatch', async () => {
|
||||||
|
const older = { ...item, id: 'm2', title: '较旧', updated_at: '2026-09-10T02:00:00Z' }
|
||||||
|
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||||
|
? { ...item, content: '旧正文' }
|
||||||
|
: { items: [item, older], total: 2 })
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '最新'; title.dispatchEvent(new Event('input'))
|
||||||
|
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||||
|
content.value = 'x'.repeat(121); content.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
request.mockResolvedValueOnce({ ...item, title: '最新', content: 'x'.repeat(121), updated_at: '2026-09-14T02:00:00Z', version: 2 })
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['最新', '较旧'])
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('最新')
|
||||||
|
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe(`${'x'.repeat(119)}…`)
|
||||||
|
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
request.mockResolvedValueOnce({ items: [{ ...item, title: '最新', excerpt: '匹配', updated_at: '2026-09-14T02:00:00Z', version: 2 }], total: 1 })
|
||||||
|
search.value = '匹配'; search.dispatchEvent(new Event('input'))
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
title.value = '不相关'; title.dispatchEvent(new Event('input'))
|
||||||
|
content.value = ''; content.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
request.mockResolvedValueOnce({ ...item, title: '不相关', content: '', updated_at: '2026-09-15T02:00:00Z', version: 3 })
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
expect(host.querySelectorAll('.memo-row')).toHaveLength(0)
|
||||||
|
expect(host.textContent).toContain('没有匹配的备忘录')
|
||||||
|
expect(host.querySelector('.memo-load-more')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a late save to the committed list while a new query is still debouncing', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const save = deferred<unknown>()
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && options?.method === 'PATCH') return save.promise
|
||||||
|
if (path === '/memos/m1') return Promise.resolve({ ...item, content: '旧正文' })
|
||||||
|
return Promise.resolve({ items: [item], total: 2 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '保存后的标题'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
search.value = '另一个查询'; search.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
save.resolve({ ...item, title: '保存后的标题', content: '旧正文', version: 2, updated_at: '2026-09-14T02:00:00Z' })
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(host.querySelector('.memo-row strong')?.textContent).toBe('保存后的标题')
|
||||||
|
expect(host.querySelector('.memo-load-more')).not.toBeNull()
|
||||||
|
expect(request.mock.calls.some(([path]) => path.includes('q=%E5%8F%A6%E4%B8%80%E4%B8%AA%E6%9F%A5%E8%AF%A2'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a late save to the last committed list after the new query fails', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const save = deferred<unknown>()
|
||||||
|
const failedQuery = deferred<unknown>()
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && options?.method === 'PATCH') return save.promise
|
||||||
|
if (path === '/memos/m1') return Promise.resolve({ ...item, content: '旧正文' })
|
||||||
|
if (path.includes('q=failed')) return failedQuery.promise
|
||||||
|
return Promise.resolve({ items: [item], total: 2 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '迟到但已保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
search.value = 'failed'; search.dispatchEvent(new Event('input'))
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
failedQuery.reject(new Error('新查询失败')); await flush()
|
||||||
|
save.resolve({ ...item, title: '迟到但已保存', content: '旧正文', version: 2, updated_at: '2026-09-14T02:00:00Z' })
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('新查询失败')
|
||||||
|
expect(host.querySelector('.memo-row strong')?.textContent).toBe('迟到但已保存')
|
||||||
|
expect(host.querySelector('.memo-load-more')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a late save for memo A overwrite the editor after switching to memo B', async () => {
|
||||||
|
const save = deferred<unknown>()
|
||||||
|
const other = { ...item, id: 'm2', title: '第二条' }
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && options?.method === 'PATCH') return save.promise
|
||||||
|
if (path === '/memos/m1') return Promise.resolve({ ...item, content: 'A 正文' })
|
||||||
|
if (path === '/memos/m2') return Promise.resolve({ ...other, content: 'B 正文' })
|
||||||
|
return Promise.resolve({ items: [item, other], total: 2 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = 'A 已保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||||
|
|
||||||
|
save.resolve({ ...item, title: 'A 已保存', content: 'A 正文', version: 2 })
|
||||||
|
await flush()
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toContain('A 已保存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let an A save from an older detail instance overwrite a new A draft', async () => {
|
||||||
|
const save = deferred<unknown>()
|
||||||
|
const other = { ...item, id: 'm2', title: '第二条' }
|
||||||
|
let aDetailCalls = 0
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && options?.method === 'PATCH') return save.promise
|
||||||
|
if (path === '/memos/m1') {
|
||||||
|
aDetailCalls += 1
|
||||||
|
return Promise.resolve({ ...item, title: aDetailCalls === 1 ? '第一条' : 'A 再次打开', content: 'A 正文' })
|
||||||
|
}
|
||||||
|
if (path === '/memos/m2') return Promise.resolve({ ...other, content: 'B 正文' })
|
||||||
|
return Promise.resolve({ items: [item, other], total: 2 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
|
const firstTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
|
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
|
||||||
|
save.resolve({ ...item, title: 'A 旧保存', content: 'A 旧正文', version: 2, updated_at: '2026-09-14T02:00:00Z' })
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('A 新草稿')
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toContain('A 旧保存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['409 conflict', Object.assign(new Error('旧版本冲突'), { status: 409 })],
|
||||||
|
['ordinary error', new Error('旧保存失败')],
|
||||||
|
])('ignores a stale A save %s after A to B to A', async (_case, failure) => {
|
||||||
|
const oldSave = deferred<unknown>()
|
||||||
|
const other = { ...item, id: 'm2', title: '第二条' }
|
||||||
|
let aDetailCalls = 0
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && options?.method === 'PATCH') return oldSave.promise
|
||||||
|
if (path === '/memos/m1') {
|
||||||
|
aDetailCalls += 1
|
||||||
|
return Promise.resolve({ ...item, title: aDetailCalls === 1 ? '第一条' : 'A 再次打开', content: 'A 正文' })
|
||||||
|
}
|
||||||
|
if (path === '/memos/m2') return Promise.resolve({ ...other, content: 'B 正文' })
|
||||||
|
return Promise.resolve({ items: [item, other], total: 2 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
|
const firstTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
|
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
|
||||||
|
oldSave.reject(failure); await flush()
|
||||||
|
|
||||||
|
expect(reopenedTitle.value).toBe('A 新草稿')
|
||||||
|
expect(host.textContent).not.toContain('旧版本冲突')
|
||||||
|
expect(host.textContent).not.toContain('旧保存失败')
|
||||||
|
expect(host.textContent).not.toContain('版本冲突:草稿已保留')
|
||||||
|
expect(host.querySelector('.memo-reload')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let an old save finally unlock a newer A save', async () => {
|
||||||
|
const oldSave = deferred<unknown>()
|
||||||
|
const newSave = deferred<unknown>()
|
||||||
|
const other = { ...item, id: 'm2', title: '第二条' }
|
||||||
|
let aDetailCalls = 0
|
||||||
|
let patchCalls = 0
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && options?.method === 'PATCH') {
|
||||||
|
patchCalls += 1
|
||||||
|
return patchCalls === 1 ? oldSave.promise : newSave.promise
|
||||||
|
}
|
||||||
|
if (path === '/memos/m1') {
|
||||||
|
aDetailCalls += 1
|
||||||
|
return Promise.resolve({ ...item, title: aDetailCalls === 1 ? '第一条' : 'A 再次打开', content: 'A 正文' })
|
||||||
|
}
|
||||||
|
if (path === '/memos/m2') return Promise.resolve({ ...other, content: 'B 正文' })
|
||||||
|
return Promise.resolve({ items: [item, other], total: 2 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
|
const firstTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
|
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
const saveButton = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||||
|
expect(saveButton.disabled).toBe(false)
|
||||||
|
saveButton.click(); await nextTick()
|
||||||
|
expect(saveButton.textContent).toContain('正在保存')
|
||||||
|
|
||||||
|
oldSave.reject(Object.assign(new Error('旧版本冲突'), { status: 409 })); await flush()
|
||||||
|
|
||||||
|
expect(saveButton.disabled).toBe(true)
|
||||||
|
expect(saveButton.textContent).toContain('正在保存')
|
||||||
|
expect(reopenedTitle.value).toBe('A 新草稿')
|
||||||
|
expect(host.querySelector('.memo-reload')).toBeNull()
|
||||||
|
|
||||||
|
newSave.resolve({ ...item, title: 'A 新草稿', content: 'A 正文', version: 2 }); await flush()
|
||||||
|
expect(saveButton.textContent).toBe('保存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts a matching saved draft once using the committed search and server order', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const older = { ...item, id: 'm0', title: '咖啡旧记', updated_at: '2026-09-10T02:00:00Z' }
|
||||||
|
const newer = { ...item, id: 'm9', title: '咖啡新记', updated_at: '2026-09-15T02:00:00Z' }
|
||||||
|
const created = { ...item, id: 'm5', title: '咖啡创建', content: '', updated_at: '2026-09-14T02:00:00Z' }
|
||||||
|
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => options?.method === 'POST'
|
||||||
|
? created
|
||||||
|
: { items: [newer, older], total: 50 })
|
||||||
|
const { host, vm } = await mount(request)
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
search.value = '咖啡'; search.dispatchEvent(new Event('input'))
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
await vm.createMemo(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '咖啡创建'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['咖啡新记', '咖啡创建', '咖啡旧记'])
|
||||||
|
expect(host.querySelector('.memo-load-more')).not.toBeNull()
|
||||||
|
expect(title.value).toBe('咖啡创建')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not insert or count a saved draft that misses the committed search', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const created = { ...item, id: 'new', title: '茶记录', content: '', updated_at: '2026-09-14T03:00:00Z' }
|
||||||
|
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => options?.method === 'POST'
|
||||||
|
? created
|
||||||
|
: { items: [{ ...item, title: '咖啡记录' }], total: 1 })
|
||||||
|
const notice = vi.fn()
|
||||||
|
const { host, vm } = await mount(request, notice)
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
search.value = '咖啡'; search.dispatchEvent(new Event('input'))
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
await vm.createMemo(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '茶记录'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['咖啡记录'])
|
||||||
|
expect(host.querySelector('.memo-load-more')).toBeNull()
|
||||||
|
expect(title.value).toBe('茶记录')
|
||||||
|
expect(notice).toHaveBeenCalledWith('备忘录已创建')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('binds draft insertion to committed criteria when query changes during save', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const create = deferred<unknown>()
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (options?.method === 'POST') return create.promise
|
||||||
|
if (path.includes('q=next')) return Promise.resolve({ items: [{ ...item, id: 'next', title: 'next result' }], total: 1 })
|
||||||
|
return Promise.resolve({ items: [item], total: 1 })
|
||||||
|
})
|
||||||
|
const notice = vi.fn()
|
||||||
|
const { host, vm } = await mount(request, notice)
|
||||||
|
await vm.createMemo(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = 'next created'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
search.value = 'next'; search.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
create.resolve({ ...item, id: 'new', title: 'next created', content: '', updated_at: '2026-09-14T03:00:00Z' })
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toContain('next created')
|
||||||
|
expect(title.value).toBe('next created')
|
||||||
|
expect(notice).toHaveBeenCalledWith('备忘录已创建')
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['next result'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not double-count a saved draft when the server returns an id already in the list', async () => {
|
||||||
|
const duplicate = { ...item, title: '服务端重复 ID', content: '新正文', version: 2, updated_at: '2026-09-14T03:00:00Z' }
|
||||||
|
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => options?.method === 'POST'
|
||||||
|
? duplicate
|
||||||
|
: { items: [item], total: 1 })
|
||||||
|
const { host, vm } = await mount(request)
|
||||||
|
await vm.createMemo(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = duplicate.title; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
|
||||||
|
expect(host.querySelectorAll('.memo-row')).toHaveLength(1)
|
||||||
|
expect(host.querySelector('.memo-row strong')?.textContent).toBe('服务端重复 ID')
|
||||||
|
expect(host.querySelector('.memo-load-more')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not insert a saved draft into a newly committed query', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const create = deferred<unknown>()
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (options?.method === 'POST') return create.promise
|
||||||
|
if (path.includes('q=next')) return Promise.resolve({ items: [{ ...item, id: 'next', title: 'next result' }], total: 1 })
|
||||||
|
return Promise.resolve({ items: [item], total: 1 })
|
||||||
|
})
|
||||||
|
const { host, vm } = await mount(request)
|
||||||
|
await vm.createMemo(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = 'next created'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
search.value = 'next'; search.dispatchEvent(new Event('input'))
|
||||||
|
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||||
|
create.resolve({ ...item, id: 'new', title: 'next created', content: '', updated_at: '2026-09-14T03:00:00Z' }); await flush()
|
||||||
|
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['next result'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps memo B open when memo A deletion finishes late and removes only A from its committed list', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const remove = deferred<unknown>()
|
||||||
|
const other = { ...item, id: 'm2', title: '第二条' }
|
||||||
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && options?.method === 'DELETE') return remove.promise
|
||||||
|
if (path === '/memos/m1') return Promise.resolve({ ...item, content: 'A 正文' })
|
||||||
|
if (path === '/memos/m2') return Promise.resolve({ ...other, content: 'B 正文' })
|
||||||
|
return Promise.resolve({ items: [item, other], total: 2 })
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||||
|
remove.resolve(undefined); await flush()
|
||||||
|
|
||||||
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['第二条'])
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to search after deletion removes the opening row during nextTick', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const request = vi.fn(async (path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
|
if (path === '/memos/m1' && options?.method === 'DELETE') return undefined
|
||||||
|
if (path === '/memos/m1') return { ...item, content: '正文' }
|
||||||
|
return { items: [item], total: 1 }
|
||||||
|
})
|
||||||
|
const { host } = await mount(request)
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await flush()
|
||||||
|
expect(document.activeElement).toBe(host.querySelector('[aria-label="搜索备忘录"]'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens an empty local draft from the FAB without POST or list changes and ignores trash creation', async () => {
|
||||||
|
const request = vi.fn(async (): Promise<unknown> => ({ items: [item], total: 1 }))
|
||||||
|
const { host, vm } = await mount(request)
|
||||||
|
const callsBeforeCreate = request.mock.calls.length
|
||||||
|
await vm.createMemo(); await flush()
|
||||||
|
expect(request.mock.calls).toHaveLength(callsBeforeCreate)
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('')
|
||||||
|
expect(host.querySelectorAll('.memo-row')).toHaveLength(1)
|
||||||
|
|
||||||
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush()
|
||||||
|
const callsInTrash = request.mock.calls.length
|
||||||
|
await vm.createMemo(); await flush()
|
||||||
|
expect(request.mock.calls).toHaveLength(callsInTrash)
|
||||||
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches between active and trash and opens a local draft through the exposed FAB action', async () => {
|
||||||
|
const request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
|
||||||
const { host, vm } = await mount(request)
|
const { host, vm } = await mount(request)
|
||||||
host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
|
host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
|
||||||
expect(request.mock.calls.at(-1)?.[0]).toContain('scope=trash')
|
expect(request.mock.calls.at(-1)?.[0]).toContain('scope=trash')
|
||||||
|
await vm.createMemo(); await flush()
|
||||||
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
host.querySelector<HTMLButtonElement>('[data-scope="active"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
|
host.querySelector<HTMLButtonElement>('[data-scope="active"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
|
||||||
await vm.createMemo()
|
const callsBeforeCreate = request.mock.calls.length
|
||||||
expect(request).toHaveBeenCalledWith('/memos', { method: 'POST', body: JSON.stringify({ title: '未命名备忘录', content: '' }) })
|
await vm.createMemo(); await flush()
|
||||||
expect(host.querySelector('.memo-editor')).not.toBeNull()
|
expect(request.mock.calls).toHaveLength(callsBeforeCreate)
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+144
-27
@@ -2,11 +2,11 @@
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Archive, FileText, Search } from 'lucide-vue-next'
|
import { Archive, FileText, Search } from 'lucide-vue-next'
|
||||||
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
||||||
import MemoEditor, { type Memo } from './components/MemoEditor.vue'
|
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
||||||
|
|
||||||
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 }>()
|
||||||
const emit = defineEmits<{ notice: [message: string]; scope: [scope: 'active' | 'trash'] }>()
|
const emit = defineEmits<{ notice: [message: string]; scope: [scope: 'active' | 'trash']; detail: [open: boolean] }>()
|
||||||
const scope = ref<'active' | 'trash'>('active')
|
const scope = ref<'active' | 'trash'>('active')
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const items = ref<MemoListItem[]>([])
|
const items = ref<MemoListItem[]>([])
|
||||||
@@ -15,57 +15,174 @@ const page = ref(1)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const selected = ref<Memo | null>(null)
|
const selected = ref<MemoEditorValue | null>(null)
|
||||||
|
const selectedToken = ref(0)
|
||||||
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
||||||
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
const mobileDetail = ref(window.innerWidth <= 930)
|
const mobileDetail = ref(window.innerWidth <= 930)
|
||||||
|
let detailOpener: HTMLElement | null = null
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
let generation = 0
|
let generation = 0
|
||||||
const hasMore = computed(() => items.value.length < total.value)
|
let committedGeneration = 0
|
||||||
|
let detailGeneration = 0
|
||||||
|
type MemoCriteria = { scope: 'active' | 'trash'; query: string }
|
||||||
|
const saveContexts = new Map<number, { criteria: MemoCriteria; generation: number }>()
|
||||||
|
const lifecycleContexts = new Map<number, { criteria: MemoCriteria; generation: number }>()
|
||||||
|
function currentCriteria(): MemoCriteria { return { scope: scope.value, query: query.value.trim() } }
|
||||||
|
function criteriaEqual(left: MemoCriteria, right: MemoCriteria) { return left.scope === right.scope && left.query === right.query }
|
||||||
|
const committedCriteria = ref<MemoCriteria>(currentCriteria())
|
||||||
|
const criteriaMatch = computed(() => criteriaEqual(committedCriteria.value, currentCriteria()))
|
||||||
|
const hasMore = computed(() => criteriaMatch.value && items.value.length < total.value)
|
||||||
const emptyCopy = computed(() => query.value.trim() ? '没有匹配的备忘录' : scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
|
const emptyCopy = computed(() => query.value.trim() ? '没有匹配的备忘录' : scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
|
||||||
|
|
||||||
async function load(reset = true) {
|
async function load(reset = true) {
|
||||||
const token = ++generation
|
const token = ++generation
|
||||||
if (reset) { page.value = 1; loading.value = true } else refreshing.value = true
|
const criteria = currentCriteria()
|
||||||
|
const requestedPage = reset ? 1 : page.value
|
||||||
|
if (reset) loading.value = true
|
||||||
|
else refreshing.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(page.value), page_size: '50' })
|
const params = new URLSearchParams({ scope: criteria.scope, q: criteria.query, page: String(requestedPage), page_size: '50' })
|
||||||
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
|
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
|
||||||
if (token !== generation) return
|
if (token !== generation || !criteriaEqual(criteria, currentCriteria())) return
|
||||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
|
page.value = requestedPage
|
||||||
|
if (reset) {
|
||||||
|
committedCriteria.value = criteria
|
||||||
|
committedGeneration = token
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
if (token === generation && criteriaEqual(criteria, currentCriteria())) error.value = reason instanceof Error ? reason.message : '加载失败'
|
||||||
|
} finally {
|
||||||
|
if (token === generation && criteriaEqual(criteria, currentCriteria())) {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadMore() {
|
||||||
|
if (loading.value || !hasMore.value || refreshing.value) return
|
||||||
|
const nextPage = page.value + 1
|
||||||
|
const token = ++generation
|
||||||
|
refreshing.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(nextPage), page_size: '50' })
|
||||||
|
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
|
||||||
|
if (token !== generation) return
|
||||||
|
items.value = [...items.value, ...data.items]
|
||||||
|
total.value = data.total
|
||||||
|
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 : '加载失败' }
|
||||||
finally { if (token === generation) { loading.value = false; refreshing.value = false } }
|
finally { if (token === generation) refreshing.value = false }
|
||||||
|
}
|
||||||
|
function closeDetail() {
|
||||||
|
detailGeneration += 1
|
||||||
|
selectedToken.value = detailGeneration
|
||||||
|
selected.value = null
|
||||||
|
emit('detail', false)
|
||||||
|
const opener = detailOpener
|
||||||
|
detailOpener = null
|
||||||
|
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
||||||
}
|
}
|
||||||
async function loadMore() { if (!hasMore.value || refreshing.value) return; page.value += 1; await load(false) }
|
|
||||||
async function setScope(next: 'active' | 'trash') {
|
async function setScope(next: 'active' | 'trash') {
|
||||||
if (next === scope.value) return
|
if (next === scope.value) return
|
||||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
||||||
selected.value = null; scope.value = next; emit('scope', next); await load()
|
closeDetail(); scope.value = next; emit('scope', next); await load()
|
||||||
}
|
}
|
||||||
async function selectMemo(id: string) {
|
async function selectMemo(id: string, opener?: EventTarget | null) {
|
||||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
||||||
try { selected.value = await props.request(`/memos/${id}`) as Memo } catch (reason) { error.value = reason instanceof Error ? reason.message : '读取失败' }
|
if (opener instanceof HTMLElement) detailOpener = opener
|
||||||
|
const token = ++detailGeneration
|
||||||
|
try {
|
||||||
|
const memo = await props.request(`/memos/${id}`) as MemoRecord
|
||||||
|
if (token === detailGeneration) { selectedToken.value = token; selected.value = memo; emit('detail', true) }
|
||||||
|
} catch (reason) {
|
||||||
|
if (token === detailGeneration) error.value = reason instanceof Error ? reason.message : '读取失败'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function createMemo() {
|
async function createMemo() {
|
||||||
if (scope.value === 'trash') return
|
if (scope.value === 'trash') return
|
||||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return
|
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return
|
||||||
try {
|
const token = ++detailGeneration
|
||||||
const created = await props.request('/memos', { method: 'POST', body: JSON.stringify({ title: '未命名备忘录', content: '' }) }) as Memo
|
selectedToken.value = token
|
||||||
items.value = [{ ...created, excerpt: '' }, ...items.value]; total.value += 1; selected.value = created
|
selected.value = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
|
||||||
emit('notice', '备忘录已创建')
|
detailOpener = searchInput.value
|
||||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '创建失败' }
|
emit('detail', true)
|
||||||
}
|
}
|
||||||
function updateItem(memo: Memo) {
|
function memoExcerpt(content: string) {
|
||||||
selected.value = memo
|
const collapsed = content.replace(/\s+/g, ' ').trim()
|
||||||
|
return collapsed.length <= 120 ? collapsed : `${collapsed.slice(0, 119)}…`
|
||||||
|
}
|
||||||
|
function matchesCriteria(memo: MemoRecord, criteria: MemoCriteria) {
|
||||||
|
if (criteria.scope !== 'active') return false
|
||||||
|
const needle = criteria.query.toLocaleLowerCase()
|
||||||
|
return !needle || memo.title.toLocaleLowerCase().includes(needle) || memo.content.toLocaleLowerCase().includes(needle)
|
||||||
|
}
|
||||||
|
function compareMemos(left: MemoListItem, right: MemoListItem) {
|
||||||
|
const updated = Date.parse(right.updated_at) - Date.parse(left.updated_at)
|
||||||
|
return updated || right.id.localeCompare(left.id)
|
||||||
|
}
|
||||||
|
function beginSave(_id: string | null, selectionToken: number) {
|
||||||
|
saveContexts.set(selectionToken, { criteria: { ...committedCriteria.value }, generation: committedGeneration })
|
||||||
|
}
|
||||||
|
function finishSave(selectionToken: number) { saveContexts.delete(selectionToken) }
|
||||||
|
function beginLifecycle(_id: string, selectionToken: number) {
|
||||||
|
lifecycleContexts.set(selectionToken, { criteria: { ...committedCriteria.value }, generation: committedGeneration })
|
||||||
|
}
|
||||||
|
function finishLifecycle(selectionToken: number) { lifecycleContexts.delete(selectionToken) }
|
||||||
|
function updateItem(memo: MemoRecord, selectionToken: number) {
|
||||||
|
if (selectionToken === selectedToken.value) selected.value = memo
|
||||||
|
const saveContext = saveContexts.get(selectionToken)
|
||||||
|
if (!saveContext || saveContext.generation !== committedGeneration || !criteriaEqual(saveContext.criteria, committedCriteria.value)) return
|
||||||
const index = items.value.findIndex((item) => item.id === memo.id)
|
const index = items.value.findIndex((item) => item.id === memo.id)
|
||||||
if (index >= 0) items.value[index] = { ...memo, excerpt: memo.content.replace(/\s+/g, ' ').trim().slice(0, 120) }
|
if (!matchesCriteria(memo, saveContext.criteria)) {
|
||||||
|
if (index >= 0) {
|
||||||
|
items.value.splice(index, 1)
|
||||||
|
total.value = Math.max(0, total.value - 1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const listItem = { ...memo, excerpt: memoExcerpt(memo.content) }
|
||||||
|
if (index < 0) {
|
||||||
|
items.value.push(listItem)
|
||||||
|
total.value += 1
|
||||||
|
} else items.value[index] = listItem
|
||||||
|
items.value.sort(compareMemos)
|
||||||
}
|
}
|
||||||
function removeItem(id: string) { items.value = items.value.filter((item) => item.id !== id); total.value = Math.max(0, total.value - 1); selected.value = null }
|
function removeItem(id: string, selectionToken: number) {
|
||||||
function removeRestoredItem(memo: Memo) { removeItem(memo.id) }
|
const context = lifecycleContexts.get(selectionToken)
|
||||||
|
lifecycleContexts.delete(selectionToken)
|
||||||
|
if (context && context.generation === committedGeneration && criteriaEqual(context.criteria, committedCriteria.value)) {
|
||||||
|
const index = items.value.findIndex((item) => item.id === id)
|
||||||
|
if (index >= 0) {
|
||||||
|
items.value.splice(index, 1)
|
||||||
|
total.value = Math.max(0, total.value - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectionToken !== selectedToken.value) return
|
||||||
|
detailGeneration += 1
|
||||||
|
selectedToken.value = detailGeneration
|
||||||
|
selected.value = null
|
||||||
|
emit('detail', false)
|
||||||
|
const opener = detailOpener
|
||||||
|
detailOpener = null
|
||||||
|
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
||||||
|
}
|
||||||
|
function removeRestoredItem(memo: MemoRecord, selectionToken: number) { removeItem(memo.id, selectionToken) }
|
||||||
function updateLayout() { mobileDetail.value = window.innerWidth <= 930 }
|
function updateLayout() { mobileDetail.value = window.innerWidth <= 930 }
|
||||||
watch(query, () => { if (timer) clearTimeout(timer); timer = window.setTimeout(() => void load(), 250) })
|
watch(query, () => {
|
||||||
|
generation += 1
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
error.value = ''
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = window.setTimeout(() => void load(), 250)
|
||||||
|
})
|
||||||
onMounted(() => { window.addEventListener('resize', updateLayout); void load() })
|
onMounted(() => { window.addEventListener('resize', updateLayout); void load() })
|
||||||
onUnmounted(() => { window.removeEventListener('resize', updateLayout); if (timer) clearTimeout(timer) })
|
onUnmounted(() => { detailGeneration += 1; generation += 1; window.removeEventListener('resize', updateLayout); if (timer) clearTimeout(timer) })
|
||||||
defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dirty: computed(() => Boolean(editor.value?.dirty)) })
|
defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dirty: computed(() => Boolean(editor.value?.dirty)) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -74,7 +191,7 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
|
|||||||
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
|
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
|
||||||
<div class="memo-toolbar">
|
<div class="memo-toolbar">
|
||||||
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
|
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
|
||||||
<label class="memo-search"><Search/><input v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…"></label>
|
<label class="memo-search"><Search/><input ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…"></label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
||||||
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录…</div>
|
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录…</div>
|
||||||
@@ -82,9 +199,9 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
|
|||||||
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
|
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
|
||||||
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
|
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="hasMore" class="secondary memo-load-more" :disabled="refreshing" @click="loadMore">{{refreshing?'正在加载…':'加载更多'}}</button>
|
<button v-if="items.length < total" class="secondary memo-load-more" :disabled="loading || refreshing || !criteriaMatch" @click="loadMore">{{loading || refreshing?'正在加载…':'加载更多'}}</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selected" class="memo-editor-scrim" @click="editor?.requestClose()"/>
|
<div v-if="selected" class="memo-editor-scrim" @click="editor?.requestClose()"/>
|
||||||
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" @saved="updateItem" @close="selected=null" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
|
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" :selection-token="selectedToken" @save-started="beginSave" @save-finished="finishSave" @lifecycle-started="beginLifecycle" @lifecycle-finished="finishLifecycle" @saved="updateItem" @close="closeDetail" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createApp, h, nextTick } from 'vue'
|
import { createApp, h, nextTick, reactive } from 'vue'
|
||||||
import MemoEditor, { type Memo } from './MemoEditor.vue'
|
import MemoEditor, { type Memo, type MemoDraft } from './MemoEditor.vue'
|
||||||
|
|
||||||
const cleanups: Array<() => void> = []
|
const cleanups: Array<() => void> = []
|
||||||
const memo: Memo = { id: 'm1', title: '旧标题', content: '正文', version: 3, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
const memo: Memo = { id: 'm1', title: '旧标题', content: '正文', version: 3, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
||||||
|
const draft: MemoDraft = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((yes) => { resolve = yes })
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||||
|
|
||||||
async function mount(overrides: Record<string, unknown> = {}) {
|
async function mount(overrides: Record<string, unknown> = {}) {
|
||||||
const host = document.createElement('div'); document.body.append(host)
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
@@ -17,6 +24,104 @@ async function mount(overrides: Record<string, unknown> = {}) {
|
|||||||
afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
||||||
|
|
||||||
describe('MemoEditor', () => {
|
describe('MemoEditor', () => {
|
||||||
|
it('creates a local draft only on first save and locks click plus shortcut re-entry', async () => {
|
||||||
|
const pending = deferred<Memo>()
|
||||||
|
const request = vi.fn(() => pending.promise)
|
||||||
|
const { host } = await mount({ memo: draft, request })
|
||||||
|
expect(request).not.toHaveBeenCalled()
|
||||||
|
expect(host.querySelector('.danger-text')).toBeNull()
|
||||||
|
expect(host.querySelector('.memo-reload')).toBeNull()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = ' 新建标题 '; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||||
|
save.click()
|
||||||
|
host.querySelector('.memo-editor')!.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true, cancelable: true }))
|
||||||
|
save.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(request).toHaveBeenCalledTimes(1)
|
||||||
|
expect(request).toHaveBeenCalledWith('/memos', { method: 'POST', body: JSON.stringify({ title: '新建标题', content: '' }) })
|
||||||
|
pending.resolve({ ...memo, id: 'new', title: '新建标题', content: '', version: 1 })
|
||||||
|
await flush()
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('新建标题')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps save entry points locked while a draft save is pending', async () => {
|
||||||
|
const pending = deferred<Memo>()
|
||||||
|
const request = vi.fn(() => pending.promise)
|
||||||
|
const { host } = await mount({ memo: draft, request })
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '保存中'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||||
|
save.click()
|
||||||
|
host.querySelector('.memo-editor')!.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true, cancelable: true }))
|
||||||
|
save.click(); await nextTick()
|
||||||
|
expect(request).toHaveBeenCalledOnce()
|
||||||
|
expect(save.disabled).toBe(true)
|
||||||
|
pending.resolve({ ...memo, id: 'new', title: '保存中', content: '', version: 1 })
|
||||||
|
await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes an untouched draft without confirmation or request but guards an edited draft', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
const request = vi.fn()
|
||||||
|
const clean = await mount({ memo: draft, request })
|
||||||
|
clean.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await nextTick()
|
||||||
|
expect(confirm).not.toHaveBeenCalled()
|
||||||
|
expect(request).not.toHaveBeenCalled()
|
||||||
|
expect(clean.events.close).toEqual([true])
|
||||||
|
|
||||||
|
const edited = await mount({ memo: draft, request })
|
||||||
|
const body = edited.host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||||
|
body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||||
|
expect(confirm).toHaveBeenCalledOnce()
|
||||||
|
expect(edited.events.close).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a failed draft payload and retries POST successfully', async () => {
|
||||||
|
const request = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('创建失败'))
|
||||||
|
.mockResolvedValueOnce({ ...memo, id: 'new', title: '重试标题', content: '正文', version: 1 })
|
||||||
|
const { host } = await mount({ memo: draft, request })
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||||
|
title.value = ' 重试标题 '; title.dispatchEvent(new Event('input'))
|
||||||
|
body.value = '正文'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||||
|
save.click(); await flush()
|
||||||
|
expect(host.textContent).toContain('创建失败')
|
||||||
|
expect(title.value).toBe(' 重试标题 ')
|
||||||
|
expect(body.value).toBe('正文')
|
||||||
|
expect(save.disabled).toBe(false)
|
||||||
|
save.click(); await flush()
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a draft without offering reload when POST returns 409 and allows retry', async () => {
|
||||||
|
const request = vi.fn()
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error('同名备忘录无法创建'), { status: 409 }))
|
||||||
|
.mockResolvedValueOnce({ ...memo, id: 'new', title: '重试草稿', content: '草稿正文', version: 1 })
|
||||||
|
const { host } = await mount({ memo: draft, request })
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||||
|
title.value = '重试草稿'; title.dispatchEvent(new Event('input'))
|
||||||
|
body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||||
|
|
||||||
|
save.click(); await flush()
|
||||||
|
|
||||||
|
expect(title.value).toBe('重试草稿')
|
||||||
|
expect(body.value).toBe('草稿正文')
|
||||||
|
expect(host.textContent).toContain('同名备忘录无法创建')
|
||||||
|
expect(host.textContent).not.toContain('版本冲突')
|
||||||
|
expect(host.querySelector('.memo-reload')).toBeNull()
|
||||||
|
expect(save.disabled).toBe(false)
|
||||||
|
|
||||||
|
save.click(); await flush()
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
expect(request).toHaveBeenLastCalledWith('/memos', { method: 'POST', body: JSON.stringify({ title: '重试草稿', content: '草稿正文' }) })
|
||||||
|
})
|
||||||
|
|
||||||
it('requires a 1-200 character title and saves explicitly with its version', async () => {
|
it('requires a 1-200 character title and saves explicitly with its version', async () => {
|
||||||
const { host, request, events } = await mount()
|
const { host, request, events } = await mount()
|
||||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
@@ -47,6 +152,24 @@ describe('MemoEditor', () => {
|
|||||||
expect(host.querySelector('.memo-reload')).not.toBeNull()
|
expect(host.querySelector('.memo-reload')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reports a current ordinary save failure and unlocks retry', async () => {
|
||||||
|
const request = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('网络保存失败'))
|
||||||
|
.mockResolvedValueOnce({ ...memo, title: '我的草稿', version: 4 })
|
||||||
|
const { host } = await mount({ request })
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '我的草稿'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||||
|
|
||||||
|
save.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||||
|
expect(host.textContent).toContain('网络保存失败')
|
||||||
|
expect(title.value).toBe('我的草稿')
|
||||||
|
expect(save.disabled).toBe(false)
|
||||||
|
|
||||||
|
save.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the editor open and reports destructive action failures', async () => {
|
it('keeps the editor open and reports destructive action failures', async () => {
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => {
|
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
@@ -60,7 +183,7 @@ describe('MemoEditor', () => {
|
|||||||
expect(events.close).toEqual([])
|
expect(events.close).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('traps mobile focus, closes on Escape, and restores opener focus', async () => {
|
it('traps mobile focus, closes on Escape, and leaves focus restoration to the panel owner', async () => {
|
||||||
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
|
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
|
||||||
const { host, events } = await mount({ mobile: true })
|
const { host, events } = await mount({ mobile: true })
|
||||||
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
|
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
|
||||||
@@ -74,10 +197,189 @@ describe('MemoEditor', () => {
|
|||||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(events.close).toEqual([true])
|
expect(events.close).toEqual([true])
|
||||||
expect(document.activeElement).toBe(opener)
|
expect(document.activeElement).not.toBe(opener)
|
||||||
opener.remove()
|
opener.remove()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('ignores a stale reload after its editor selection changes', async () => {
|
||||||
|
const current = { ...memo, id: 'm2', title: '第二条', content: 'B 正文' }
|
||||||
|
const props = reactive({ memo, selectionToken: 1 })
|
||||||
|
const reload = deferred<Memo>()
|
||||||
|
const conflictRequest = vi.fn().mockRejectedValueOnce(Object.assign(new Error('冲突'), { status: 409 })).mockImplementation(() => reload.promise)
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const saved = vi.fn()
|
||||||
|
const app = createApp({
|
||||||
|
data: () => props,
|
||||||
|
render() { return h(MemoEditor, { memo: this.memo, selectionToken: this.selectionToken, request: conflictRequest, onSaved: saved }) },
|
||||||
|
})
|
||||||
|
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||||
|
const secondTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
secondTitle.value = '冲突草稿'; secondTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-reload')!.click()
|
||||||
|
props.memo = current; props.selectionToken = 2; await nextTick()
|
||||||
|
reload.resolve({ ...memo, title: 'A 迟到结果', content: 'A 迟到正文' }); await flush()
|
||||||
|
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||||
|
expect(saved).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the current save success notice', async () => {
|
||||||
|
const notice = vi.fn()
|
||||||
|
const { host } = await mount({ onNotice: notice })
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '当前保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
expect(notice).toHaveBeenCalledOnce()
|
||||||
|
expect(notice).toHaveBeenCalledWith('备忘录已保存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('silences a successful save after another editor selection is current', async () => {
|
||||||
|
const pending = deferred<Memo>()
|
||||||
|
const notice = vi.fn()
|
||||||
|
const props = reactive({ memo, selectionToken: 1 })
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const app = createApp({
|
||||||
|
data: () => props,
|
||||||
|
render() { return h(MemoEditor, { memo: this.memo, selectionToken: this.selectionToken, request: () => pending.promise, onNotice: notice }) },
|
||||||
|
})
|
||||||
|
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '已提交的 A'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
props.memo = { ...memo, id: 'm2', title: '第二条' }; props.selectionToken = 2; await nextTick()
|
||||||
|
pending.resolve({ ...memo, title: '已提交的 A', version: 4 }); await flush()
|
||||||
|
|
||||||
|
expect(notice).not.toHaveBeenCalled()
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['remove', null, '.danger-text', '备忘录已移到回收站'],
|
||||||
|
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
|
||||||
|
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
|
||||||
|
])('silences stale %s success notices', async (_name, deletedAt, selector, message) => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const pending = deferred<Memo>()
|
||||||
|
const notice = vi.fn()
|
||||||
|
const props = reactive({ memo: { ...memo, deleted_at: deletedAt }, selectionToken: 1 })
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const app = createApp({
|
||||||
|
data: () => props,
|
||||||
|
render() { return h(MemoEditor, { memo: this.memo, selectionToken: this.selectionToken, request: () => pending.promise, onNotice: notice }) },
|
||||||
|
})
|
||||||
|
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>(selector)!.click()
|
||||||
|
props.memo = { ...memo, id: 'm2', title: '第二条' }; props.selectionToken = 2; await nextTick()
|
||||||
|
pending.resolve({ ...memo, deleted_at: null }); await flush()
|
||||||
|
expect(notice).not.toHaveBeenCalledWith(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['remove', null, '.danger-text', '备忘录已移到回收站'],
|
||||||
|
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
|
||||||
|
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
|
||||||
|
])('keeps the current %s success notice', async (_name, deletedAt, selector, message) => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const notice = vi.fn()
|
||||||
|
const request = vi.fn().mockResolvedValue({ ...memo, deleted_at: null })
|
||||||
|
const { host } = await mount({ memo: { ...memo, deleted_at: deletedAt }, request, onNotice: notice })
|
||||||
|
host.querySelector<HTMLButtonElement>(selector)!.click(); await flush()
|
||||||
|
expect(notice).toHaveBeenCalledOnce()
|
||||||
|
expect(notice).toHaveBeenCalledWith(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses one lifecycle busy lock for double clicks and cross-operation entry', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const pending = deferred<Memo>()
|
||||||
|
const request = vi.fn(() => pending.promise)
|
||||||
|
const { host } = await mount({ memo: { ...memo, deleted_at: '2026-09-14T00:00:00Z' }, request })
|
||||||
|
const restore = host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!
|
||||||
|
const purge = host.querySelector<HTMLButtonElement>('.danger-button')!
|
||||||
|
restore.click(); restore.click(); purge.click(); await nextTick()
|
||||||
|
expect(request).toHaveBeenCalledTimes(1)
|
||||||
|
expect(restore.disabled).toBe(true)
|
||||||
|
expect(purge.disabled).toBe(true)
|
||||||
|
pending.resolve({ ...memo, deleted_at: null }); await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let an old lifecycle finally unlock a newer selection operation', async () => {
|
||||||
|
const oldRestore = deferred<Memo>()
|
||||||
|
const newRestore = deferred<Memo>()
|
||||||
|
let calls = 0
|
||||||
|
const request = vi.fn(() => (++calls === 1 ? oldRestore.promise : newRestore.promise))
|
||||||
|
const props = reactive({ memo: { ...memo, deleted_at: '2026-09-14T00:00:00Z' }, selectionToken: 1 })
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const app = createApp({
|
||||||
|
data: () => props,
|
||||||
|
render() { return h(MemoEditor, { memo: this.memo, selectionToken: this.selectionToken, request }) },
|
||||||
|
})
|
||||||
|
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
||||||
|
props.memo = { ...memo, id: 'm2', title: '第二条', deleted_at: '2026-09-14T00:00:00Z' }
|
||||||
|
props.selectionToken = 2
|
||||||
|
await nextTick()
|
||||||
|
const currentRestore = host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!
|
||||||
|
expect(currentRestore.disabled).toBe(false)
|
||||||
|
currentRestore.click(); await nextTick()
|
||||||
|
oldRestore.resolve({ ...memo, deleted_at: null }); await flush()
|
||||||
|
expect(currentRestore.disabled).toBe(true)
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
newRestore.resolve({ ...props.memo, deleted_at: null }); await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never sends draft lifecycle or reload requests with a null id', async () => {
|
||||||
|
const request = vi.fn().mockRejectedValue(Object.assign(new Error('冲突'), { status: 409 }))
|
||||||
|
const active = await mount({ memo: draft, request })
|
||||||
|
const title = active.host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '草稿'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
active.host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
expect(active.host.querySelector('.memo-reload')).toBeNull()
|
||||||
|
expect(request.mock.calls.map(([path]) => path)).toEqual(['/memos'])
|
||||||
|
|
||||||
|
const malformedDeletedDraft = { ...draft, deleted_at: '2026-09-14T00:00:00Z' } as unknown as MemoDraft
|
||||||
|
const deleted = await mount({ memo: malformedDeletedDraft, request })
|
||||||
|
deleted.host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
deleted.host.querySelector<HTMLButtonElement>('.danger-button')!.click(); await flush()
|
||||||
|
expect(request.mock.calls.map(([path]) => path)).toEqual(['/memos'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps beforeunload protection during save and clears it after success', async () => {
|
||||||
|
const pending = deferred<Memo>()
|
||||||
|
const { host } = await mount({ request: () => pending.promise })
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '保存中'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
const during = new Event('beforeunload', { cancelable: true })
|
||||||
|
window.dispatchEvent(during)
|
||||||
|
expect(during.defaultPrevented).toBe(true)
|
||||||
|
pending.resolve({ ...memo, title: '保存中', version: 4 }); await flush(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||||
|
expect(host.querySelector<HTMLButtonElement>('.memo-save')!.disabled).toBe(true)
|
||||||
|
const after = new Event('beforeunload', { cancelable: true })
|
||||||
|
window.dispatchEvent(after)
|
||||||
|
expect(after.defaultPrevented).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits save settlement after failure so its parent can release context', async () => {
|
||||||
|
const settled = vi.fn()
|
||||||
|
const request = vi.fn().mockRejectedValue(new Error('失败'))
|
||||||
|
const { host } = await mount({ request, selectionToken: 7, onSaveFinished: settled })
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
|
expect(settled).toHaveBeenCalledWith(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits lifecycle settlement after failure so its parent can release context', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const settled = vi.fn()
|
||||||
|
const request = vi.fn().mockRejectedValue(new Error('删除失败'))
|
||||||
|
const { host } = await mount({ request, selectionToken: 9, onLifecycleFinished: settled })
|
||||||
|
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await flush()
|
||||||
|
expect(settled).toHaveBeenCalledWith(9)
|
||||||
|
})
|
||||||
|
|
||||||
it('guards dirty close and allows clean close', async () => {
|
it('guards dirty close and allows clean close', async () => {
|
||||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
const { host, events } = await mount()
|
const { host, events } = await mount()
|
||||||
|
|||||||
@@ -2,31 +2,38 @@
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { ArchiveRestore, Trash2, X } from 'lucide-vue-next'
|
import { ArchiveRestore, Trash2, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
export type Memo = { 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 Memo = MemoRecord
|
||||||
|
export type MemoEditorValue = MemoRecord | MemoDraft
|
||||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||||
const props = defineProps<{ memo: Memo; request: RequestFn; mobile?: boolean }>()
|
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number }>(), { selectionToken: 0 })
|
||||||
const emit = defineEmits<{ saved: [memo: Memo]; close: []; deleted: [id: string]; restored: [memo: Memo]; purged: [id: string]; notice: [message: string] }>()
|
const emit = defineEmits<{ saveStarted: [id: string | null, selectionToken: number]; saveFinished: [selectionToken: number]; lifecycleStarted: [id: string, selectionToken: number]; lifecycleFinished: [selectionToken: number]; saved: [memo: MemoRecord, selectionToken: number]; close: []; deleted: [id: string, selectionToken: number]; restored: [memo: MemoRecord, selectionToken: number]; purged: [id: string, selectionToken: number]; notice: [message: string] }>()
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const lifecycleBusy = ref(false)
|
||||||
|
let lifecycleGeneration = 0
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const conflict = ref(false)
|
const conflict = ref(false)
|
||||||
const titleInput = ref<HTMLInputElement | null>(null)
|
const titleInput = ref<HTMLInputElement | null>(null)
|
||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
let initial = { title: '', content: '' }
|
const initial = ref({ title: '', content: '' })
|
||||||
let opener: HTMLElement | null = null
|
const dirty = computed(() => title.value !== initial.value.title || content.value !== initial.value.content)
|
||||||
const dirty = computed(() => title.value !== initial.title || content.value !== initial.content)
|
|
||||||
|
|
||||||
function loadDraft(memo: Memo) {
|
function loadDraft(memo: MemoEditorValue) {
|
||||||
title.value = memo.title; content.value = memo.content
|
title.value = memo.title; content.value = memo.content
|
||||||
initial = { title: memo.title, content: memo.content }
|
initial.value = { title: memo.title, content: memo.content }
|
||||||
error.value = ''; conflict.value = false
|
saving.value = false; error.value = ''; conflict.value = false
|
||||||
}
|
}
|
||||||
watch(() => props.memo, loadDraft, { immediate: true })
|
watch(() => props.memo, loadDraft, { immediate: true })
|
||||||
|
watch(() => props.selectionToken, () => {
|
||||||
|
lifecycleGeneration += 1
|
||||||
|
lifecycleBusy.value = false
|
||||||
|
})
|
||||||
function close() {
|
function close() {
|
||||||
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
|
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||||
emit('close')
|
emit('close')
|
||||||
nextTick(() => opener?.focus())
|
|
||||||
}
|
}
|
||||||
function validate() {
|
function validate() {
|
||||||
const value = title.value.trim()
|
const value = title.value.trim()
|
||||||
@@ -37,40 +44,104 @@ function validate() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
error.value = validate(); if (error.value || saving.value || props.memo.deleted_at) return
|
error.value = validate(); if (error.value || saving.value || props.memo.deleted_at) return
|
||||||
saving.value = true; conflict.value = false
|
saving.value = true; conflict.value = false
|
||||||
|
const memoId = props.memo.id
|
||||||
|
const version = props.memo.version
|
||||||
|
const selectionToken = props.selectionToken
|
||||||
|
const payload = { title: title.value.trim(), content: content.value }
|
||||||
|
const isCurrentSelection = () => props.selectionToken === selectionToken
|
||||||
|
emit('saveStarted', memoId, selectionToken)
|
||||||
try {
|
try {
|
||||||
const updated = await props.request(`/memos/${props.memo.id}`, { method: 'PATCH', body: JSON.stringify({ title: title.value.trim(), content: content.value, version: props.memo.version }) }) as Memo
|
const updated = await props.request(memoId === null ? '/memos' : `/memos/${memoId}`, {
|
||||||
loadDraft(updated); emit('saved', updated); emit('notice', '备忘录已保存')
|
method: memoId === null ? 'POST' : 'PATCH',
|
||||||
|
body: JSON.stringify(memoId === null ? payload : { ...payload, version }),
|
||||||
|
}) as MemoRecord
|
||||||
|
if (isCurrentSelection()) {
|
||||||
|
loadDraft(updated)
|
||||||
|
emit('notice', memoId === null ? '备忘录已创建' : '备忘录已保存')
|
||||||
|
}
|
||||||
|
emit('saved', updated, selectionToken)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
if (!isCurrentSelection()) return
|
||||||
const status = (reason as { status?: number }).status
|
const status = (reason as { status?: number }).status
|
||||||
conflict.value = status === 409
|
conflict.value = memoId !== null && status === 409
|
||||||
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : '保存失败'
|
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : memoId === null ? '创建失败' : '保存失败'
|
||||||
} finally { saving.value = false }
|
} finally {
|
||||||
|
emit('saveFinished', selectionToken)
|
||||||
|
if (isCurrentSelection()) saving.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function reload() {
|
async function reload() {
|
||||||
try { const fresh = await props.request(`/memos/${props.memo.id}`) as Memo; loadDraft(fresh); emit('saved', fresh) } catch (reason) { error.value = reason instanceof Error ? reason.message : '重新载入失败' }
|
const memoId = props.memo.id
|
||||||
|
if (memoId === null) return
|
||||||
|
const selectionToken = props.selectionToken
|
||||||
|
const isCurrentSelection = () => props.memo.id === memoId && props.selectionToken === selectionToken
|
||||||
|
try {
|
||||||
|
const fresh = await props.request(`/memos/${memoId}`) as Memo
|
||||||
|
if (!isCurrentSelection()) return
|
||||||
|
loadDraft(fresh)
|
||||||
|
emit('saved', fresh, selectionToken)
|
||||||
|
} catch (reason) {
|
||||||
|
if (isCurrentSelection()) error.value = reason instanceof Error ? reason.message : '重新载入失败'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function remove() {
|
async function remove() {
|
||||||
|
const memoId = props.memo.id
|
||||||
|
if (memoId === null || lifecycleBusy.value) return
|
||||||
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
|
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
lifecycleBusy.value = true
|
||||||
|
const operationToken = ++lifecycleGeneration
|
||||||
|
const selectionToken = props.selectionToken
|
||||||
|
const isCurrentOperation = () => props.selectionToken === selectionToken && lifecycleGeneration === operationToken
|
||||||
|
emit('lifecycleStarted', memoId, selectionToken)
|
||||||
try {
|
try {
|
||||||
await props.request(`/memos/${props.memo.id}`, { method: 'DELETE' })
|
await props.request(`/memos/${memoId}`, { method: 'DELETE' })
|
||||||
emit('deleted', props.memo.id); emit('notice', '备忘录已移到回收站')
|
emit('deleted', memoId, selectionToken)
|
||||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '删除失败' }
|
if (isCurrentOperation()) emit('notice', '备忘录已移到回收站')
|
||||||
|
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '删除失败' }
|
||||||
|
finally {
|
||||||
|
emit('lifecycleFinished', selectionToken)
|
||||||
|
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function restore() {
|
async function restore() {
|
||||||
|
const memoId = props.memo.id
|
||||||
|
if (memoId === null || lifecycleBusy.value) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
lifecycleBusy.value = true
|
||||||
|
const operationToken = ++lifecycleGeneration
|
||||||
|
const selectionToken = props.selectionToken
|
||||||
|
const isCurrentOperation = () => props.selectionToken === selectionToken && lifecycleGeneration === operationToken
|
||||||
|
emit('lifecycleStarted', memoId, selectionToken)
|
||||||
try {
|
try {
|
||||||
const restored = await props.request(`/memos/${props.memo.id}/restore`, { method: 'POST' }) as Memo
|
const restored = await props.request(`/memos/${memoId}/restore`, { method: 'POST' }) as Memo
|
||||||
emit('restored', restored); emit('notice', '备忘录已恢复')
|
emit('restored', restored, selectionToken)
|
||||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '恢复失败' }
|
if (isCurrentOperation()) emit('notice', '备忘录已恢复')
|
||||||
|
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '恢复失败' }
|
||||||
|
finally {
|
||||||
|
emit('lifecycleFinished', selectionToken)
|
||||||
|
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function purge() {
|
async function purge() {
|
||||||
|
const memoId = props.memo.id
|
||||||
|
if (memoId === null || lifecycleBusy.value) return
|
||||||
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
|
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
lifecycleBusy.value = true
|
||||||
|
const operationToken = ++lifecycleGeneration
|
||||||
|
const selectionToken = props.selectionToken
|
||||||
|
const isCurrentOperation = () => props.selectionToken === selectionToken && lifecycleGeneration === operationToken
|
||||||
|
emit('lifecycleStarted', memoId, selectionToken)
|
||||||
try {
|
try {
|
||||||
await props.request(`/memos/${props.memo.id}/purge`, { method: 'DELETE' })
|
await props.request(`/memos/${memoId}/purge`, { method: 'DELETE' })
|
||||||
emit('purged', props.memo.id); emit('notice', '备忘录已永久删除')
|
emit('purged', memoId, selectionToken)
|
||||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '永久删除失败' }
|
if (isCurrentOperation()) emit('notice', '备忘录已永久删除')
|
||||||
|
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '永久删除失败' }
|
||||||
|
finally {
|
||||||
|
emit('lifecycleFinished', selectionToken)
|
||||||
|
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function keydown(event: KeyboardEvent) {
|
function keydown(event: KeyboardEvent) {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
|
||||||
@@ -83,7 +154,7 @@ function keydown(event: KeyboardEvent) {
|
|||||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||||
}
|
}
|
||||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
|
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
|
||||||
onMounted(() => { opener = document.activeElement as HTMLElement; window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
onMounted(() => { window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
||||||
onUnmounted(() => window.removeEventListener('beforeunload', beforeUnload))
|
onUnmounted(() => window.removeEventListener('beforeunload', beforeUnload))
|
||||||
defineExpose({ dirty, requestClose: close })
|
defineExpose({ dirty, requestClose: close })
|
||||||
</script>
|
</script>
|
||||||
@@ -96,7 +167,7 @@ defineExpose({ dirty, requestClose: close })
|
|||||||
<label>正文<textarea v-model="content" rows="16" aria-label="备忘录正文" placeholder="写点什么…" :disabled="Boolean(memo.deleted_at)"></textarea></label>
|
<label>正文<textarea v-model="content" rows="16" aria-label="备忘录正文" placeholder="写点什么…" :disabled="Boolean(memo.deleted_at)"></textarea></label>
|
||||||
<p v-if="error" role="alert" class="memo-editor__error">{{ error }} <button v-if="conflict" class="memo-reload" type="button" @click="reload">重新载入</button></p>
|
<p v-if="error" role="alert" class="memo-editor__error">{{ error }} <button v-if="conflict" class="memo-reload" type="button" @click="reload">重新载入</button></p>
|
||||||
</div>
|
</div>
|
||||||
<footer v-if="!memo.deleted_at"><button type="button" class="danger-text" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || !dirty" @click="save">{{ saving ? '正在保存…' : '保存' }}</button></footer>
|
<footer v-if="!memo.deleted_at"><button v-if="memo.id !== null" type="button" class="danger-text" :disabled="saving || lifecycleBusy" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || lifecycleBusy || !dirty" @click="save">{{ saving ? '正在保存…' : '保存' }}</button></footer>
|
||||||
<footer v-else><button type="button" class="secondary" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" @click="purge"><Trash2/>永久删除</button></footer>
|
<footer v-else><button type="button" class="secondary" :disabled="lifecycleBusy" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" :disabled="lifecycleBusy" @click="purge"><Trash2/>永久删除</button></footer>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
|||||||
describe('MemoRow', () => {
|
describe('MemoRow', () => {
|
||||||
it('renders a compact selectable row with plain excerpt and semantic update time', async () => {
|
it('renders a compact selectable row with plain excerpt and semantic update time', async () => {
|
||||||
const host = document.createElement('div'); document.body.append(host)
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
const selected: string[] = []
|
const selected: Array<{ id: string; opener: EventTarget | null }> = []
|
||||||
const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string) => selected.push(id) }))
|
const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string, opener: EventTarget | null) => selected.push({ id, opener }) }))
|
||||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||||
const button = host.querySelector('button')!
|
const button = host.querySelector('button')!
|
||||||
expect(button.classList.contains('active')).toBe(true)
|
expect(button.classList.contains('active')).toBe(true)
|
||||||
@@ -19,7 +19,8 @@ describe('MemoRow', () => {
|
|||||||
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('护照 充电器 相机')
|
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('护照 充电器 相机')
|
||||||
expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at)
|
expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at)
|
||||||
button.click()
|
button.click()
|
||||||
expect(selected).toEqual(['m1'])
|
expect(selected).toHaveLength(1)
|
||||||
|
expect(selected[0]).toEqual({ id: 'm1', opener: button })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates the excerpt when the memo prop changes and preserves empty and whitespace formatting', async () => {
|
it('updates the excerpt when the memo prop changes and preserves empty and whitespace formatting', async () => {
|
||||||
@@ -39,4 +40,16 @@ describe('MemoRow', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
expect(excerpt()).toBe('暂无正文')
|
expect(excerpt()).toBe('暂无正文')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('updates the displayed timestamp when the memo prop changes', async () => {
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const current = ref({ ...memo })
|
||||||
|
const app = createApp(() => h(MemoRow, { memo: current.value }))
|
||||||
|
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||||
|
const before = host.querySelector('time')?.textContent
|
||||||
|
current.value = { ...current.value, updated_at: '2026-10-20T10:30:00Z' }
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelector('time')?.textContent).not.toBe(before)
|
||||||
|
expect(host.querySelector('time')?.getAttribute('datetime')).toBe('2026-10-20T10:30:00Z')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { computed } from 'vue'
|
|||||||
|
|
||||||
export type MemoListItem = { id: string; title: string; excerpt: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
export type MemoListItem = { id: string; title: string; excerpt: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
||||||
const props = defineProps<{ memo: MemoListItem; active?: boolean }>()
|
const props = defineProps<{ memo: MemoListItem; active?: boolean }>()
|
||||||
const emit = defineEmits<{ select: [id: string] }>()
|
const emit = defineEmits<{ select: [id: string, opener: EventTarget | null] }>()
|
||||||
const excerpt = computed(() => props.memo.excerpt.replace(/\s+/g, ' ').trim())
|
const excerpt = computed(() => props.memo.excerpt.replace(/\s+/g, ' ').trim())
|
||||||
const updated = new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at))
|
const updated = computed(() => new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at)))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id)">
|
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id, $event.currentTarget)">
|
||||||
<strong>{{ memo.title }}</strong>
|
<strong>{{ memo.title }}</strong>
|
||||||
<span class="memo-row__excerpt">{{ excerpt || '暂无正文' }}</span>
|
<span class="memo-row__excerpt">{{ excerpt || '暂无正文' }}</span>
|
||||||
<time :datetime="memo.updated_at">{{ updated }}</time>
|
<time :datetime="memo.updated_at">{{ updated }}</time>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { deriveMemoShellState } from './app-shell-state'
|
||||||
|
|
||||||
|
describe('deriveMemoShellState', () => {
|
||||||
|
it('switches background inertness and FAB visibility across responsive memo states', () => {
|
||||||
|
expect(deriveMemoShellState({ view: 'memos', detailOpen: true, compact: false, trash: false })).toEqual({
|
||||||
|
backgroundInert: false,
|
||||||
|
showFab: false,
|
||||||
|
})
|
||||||
|
expect(deriveMemoShellState({ view: 'memos', detailOpen: true, compact: true, trash: false })).toEqual({
|
||||||
|
backgroundInert: true,
|
||||||
|
showFab: false,
|
||||||
|
})
|
||||||
|
expect(deriveMemoShellState({ view: 'memos', detailOpen: false, compact: true, trash: false })).toEqual({
|
||||||
|
backgroundInert: false,
|
||||||
|
showFab: true,
|
||||||
|
})
|
||||||
|
expect(deriveMemoShellState({ view: 'memos', detailOpen: false, compact: true, trash: true })).toEqual({
|
||||||
|
backgroundInert: false,
|
||||||
|
showFab: false,
|
||||||
|
})
|
||||||
|
expect(deriveMemoShellState({ view: 'tasks', detailOpen: true, compact: true, trash: true })).toEqual({
|
||||||
|
backgroundInert: false,
|
||||||
|
showFab: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type MemoShellStateInput = {
|
||||||
|
view: string
|
||||||
|
detailOpen: boolean
|
||||||
|
compact: boolean
|
||||||
|
trash: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveMemoShellState(input: MemoShellStateInput) {
|
||||||
|
const isMemoView = input.view === 'memos'
|
||||||
|
return {
|
||||||
|
backgroundInert: isMemoView && input.detailOpen && input.compact,
|
||||||
|
showFab: !isMemoView || (!input.trash && !input.detailOpen),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.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%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);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:#fff0ec;border-radius:10px;padding:10px 12px}.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:750;letter-spacing:.06em;color:var(--muted)}.memo-editor>header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow:auto;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:11px;background:#fff;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-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;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.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%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);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:#fff0ec;border-radius:10px;padding:10px 12px}.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:750;letter-spacing:.06em;color:var(--muted)}.memo-editor>header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;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:11px;background:#fff;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-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){.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-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%;height:min(92dvh,820px);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:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-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(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}}
|
||||||
|
|||||||
@@ -372,7 +372,8 @@ describe('archived task-list disclosure', () => {
|
|||||||
expect(app).toContain('<Teleport to="body">')
|
expect(app).toContain('<Teleport to="body">')
|
||||||
expect(app).toContain('class="archived-action-mask" @click.self="closeArchivedListAction()"')
|
expect(app).toContain('class="archived-action-mask" @click.self="closeArchivedListAction()"')
|
||||||
expect(app).toContain("document.addEventListener('scroll', handleArchivedListViewportChange, true)")
|
expect(app).toContain("document.addEventListener('scroll', handleArchivedListViewportChange, true)")
|
||||||
expect(app).toContain("window.addEventListener('resize', handleArchivedListViewportChange)")
|
expect(app).toContain("window.addEventListener('resize', handleViewportResize)")
|
||||||
|
expect(app).toContain('handleArchivedListViewportChange()')
|
||||||
expect(css).toContain('@media(max-width:930px){.archived-action-mask{display:block;position:fixed;')
|
expect(css).toContain('@media(max-width:930px){.archived-action-mask{display:block;position:fixed;')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user