feat: add standalone memos
ci / gitleaks (push) Successful in 10s
ci / docker (push) Successful in 3m38s

This commit is contained in:
2026-09-12 10:22:35 +08:00
parent e07a1de1f5
commit 31c2218edc
18 changed files with 1063 additions and 13 deletions
+16 -6
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder,
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search,
Settings, Trash2, X, Repeat2, RefreshCw,
Settings, Trash2, X, Repeat2, RefreshCw, StickyNote,
} from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
@@ -15,6 +15,7 @@ import { nextDialogFocusIndex } from './lib/list-purge'
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import MemoPanel from './MemoPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue'
@@ -26,7 +27,7 @@ type TaskList = { id: string; folder_id: string | null; name: string; is_inbox:
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
type RepeatOption = TaskRepeatOption
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
const initialized = ref<boolean | null>(null)
const authReady = ref(false)
@@ -152,6 +153,8 @@ let recurrenceLoadToken = 0
let todaySummaryLoadToken = 0
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
const memoTrash = ref(false)
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
let suppressTaskClickId = ''
@@ -199,6 +202,7 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
composeOrigin.value = origin
if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin)
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
}
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) {
@@ -316,6 +320,7 @@ const activeName = computed(() => {
if (activeView.value === 'upcoming') return '最近 7 天'
if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'countdowns') return '倒数日'
if (activeView.value === 'memos') return '备忘录'
if (activeView.value === 'settings') return '设置与数据'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
})
@@ -335,7 +340,7 @@ const visibleTasks = computed(() => {
const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value
if (['habits','settings','countdowns'].includes(activeView.value)) return []
if (['habits','settings','countdowns','memos'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
return query.value.trim() ? filterTasks(result, query.value) : result
@@ -374,7 +379,9 @@ async function api(path: string, options: RequestInit = {}) {
if (!response.ok) {
let message = '请求失败'
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
throw new Error(message)
const requestError = new Error(message) as Error & { status: number }
requestError.status = response.status
throw requestError
}
return response.status === 204 ? null : response.json()
}
@@ -596,6 +603,7 @@ async function loadTrash() {
})
}
async function switchView(view: View, listId?: string) {
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return
taskMutationNavigation.value += 1
activeView.value = view
if (!query.value) mobileSearchOpen.value = false
@@ -1336,6 +1344,7 @@ onUnmounted(() => {
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button>
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button>
</nav>
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
<div class="folders">
@@ -1413,6 +1422,7 @@ onUnmounted(() => {
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" />
</template>
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @notice="toast" />
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else>
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
@@ -1468,7 +1478,7 @@ onUnmounted(() => {
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
<div class="field markdown">
<div class="field-label"><span>备注</span><span class="markdown-mode-switch"><button type="button" :aria-pressed="!markdownPreview" :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button type="button" :aria-pressed="markdownPreview" :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div>
<div class="field-label"><span>任务备注</span><span class="markdown-mode-switch"><button type="button" :aria-pressed="!markdownPreview" :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button type="button" :aria-pressed="markdownPreview" :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div>
<div v-if="!markdownPreview" class="markdown-editor-shell">
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式">
<button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button>
@@ -1496,7 +1506,7 @@ onUnmounted(() => {
<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>
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" />
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="activeView!=='memos' || !memoTrash" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
<Transition name="task-compose">
<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">
+45
View File
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const app = readFileSync('src/App.vue', 'utf8')
const css = readFileSync('src/memo.css', 'utf8')
const panel = readFileSync('src/MemoPanel.vue', 'utf8')
const editor = readFileSync('src/components/MemoEditor.vue', 'utf8')
describe('memo shell integration', () => {
it('places Memos immediately after Countdowns in desktop navigation and keeps mobile tabs unchanged', () => {
const nav = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
expect(nav.indexOf("switchView('memos')")).toBeGreaterThan(nav.indexOf("switchView('countdowns')"))
expect(nav.match(/switchView\('memos'\)/g)).toHaveLength(1)
const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"')))
expect(bottom).not.toContain("switchView('memos')")
expect(bottom.match(/aria-current=/g)).toHaveLength(4)
})
it('routes the shared cat FAB to memo creation and hides it in memo trash', () => {
expect(app).toContain("activeView.value === 'memos'")
expect(app).toContain('memoPanel.value?.createMemo()')
expect(app).toContain(':show="activeView!==\'memos\' || !memoTrash"')
expect(panel).toContain("scope.value === 'trash'")
})
it('keeps memo UI componentized and task detail copy explicit', () => {
expect(app).toContain("import MemoPanel from './MemoPanel.vue'")
expect(panel).toContain("import MemoRow")
expect(panel).toContain("import MemoEditor")
expect(app).toContain('<span>任务详情</span>')
expect(app).toContain('<div class="field-label"><span>任务备注</span>')
})
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-list{display:grid;gap:0')
expect(css).toContain('.memo-row+.memo-row{border-top:1px solid var(--border-cream)}')
expect(css).toContain('@media(max-width:930px){.memo-panel{')
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('@media(prefers-reduced-motion:reduce){.memo-editor')
expect(editor).toContain("window.addEventListener('beforeunload'")
expect(editor).toContain("event.key !== 'Tab'")
})
})
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick } from 'vue'
import MemoPanel from './MemoPanel.vue'
const cleanups: Array<() => void> = []
const item = { id: 'm1', title: '第一条', excerpt: '摘要', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
type RequestMock = ReturnType<typeof vi.fn<(path: string, options?: RequestInit) => Promise<unknown>>>
async function mount(request: RequestMock) {
const host = document.createElement('div'); document.body.append(host)
let panel: InstanceType<typeof MemoPanel> | null = null
const app = createApp(() => h(MemoPanel, { ref: (value: unknown) => { panel = value as InstanceType<typeof MemoPanel> }, request }))
app.mount(host)
cleanups.push(() => { app.unmount(); host.remove() })
await Promise.resolve(); await Promise.resolve(); await nextTick()
return { host, vm: panel! }
}
afterEach(() => { vi.useRealTimers(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
describe('MemoPanel', () => {
it('loads active memos in server order and appends the next 50', async () => {
const request = vi.fn(async (path: string) => path.includes('page=2')
? { items: [{ ...item, id: 'm2', title: '第二页' }], total: 51 }
: { items: [item], total: 51 })
const { host } = await mount(request)
expect(request.mock.calls[0][0]).toContain('/memos?scope=active')
expect(request.mock.calls[0][0]).toContain('page_size=50')
expect(host.querySelectorAll('.memo-row')).toHaveLength(1)
host.querySelector<HTMLButtonElement>('.memo-load-more')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['第一条', '第二页'])
})
it('searches title and body through the server and has a search empty state', async () => {
vi.useFakeTimers()
const request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
const { host } = await mount(request)
const input = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
input.value = '咖啡'; input.dispatchEvent(new Event('input'))
await vi.advanceTimersByTimeAsync(260); await nextTick()
expect(request.mock.calls.at(-1)?.[0]).toContain('q=%E5%92%96%E5%95%A1')
expect(host.textContent).toContain('没有匹配的备忘录')
vi.useRealTimers()
})
it('only makes the memo list inert while the detail is a mobile sheet', async () => {
const originalWidth = window.innerWidth
const request = vi.fn(async (path: string) => path.startsWith('/memos/m1') ? { ...item, content: '正文' } : { items: [item], total: 1 })
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 })
const desktop = await mount(request)
desktop.host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await Promise.resolve(); await nextTick()
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(false)
expect(desktop.host.querySelector('.memo-editor')?.hasAttribute('aria-modal')).toBe(false)
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
window.dispatchEvent(new Event('resize')); await nextTick()
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(true)
expect(desktop.host.querySelector('.memo-editor')?.getAttribute('aria-modal')).toBe('true')
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
})
it('switches between active and trash and creates through the exposed FAB action', async () => {
const request = vi.fn(async (path: string, options?: RequestInit) => options?.method === 'POST'
? { ...item, id: 'new', title: '未命名备忘录', content: '', excerpt: '' }
: { items: [], total: 0 })
const { host, vm } = await mount(request)
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')
host.querySelector<HTMLButtonElement>('[data-scope="active"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
await vm.createMemo()
expect(request).toHaveBeenCalledWith('/memos', { method: 'POST', body: JSON.stringify({ title: '未命名备忘录', content: '' }) })
expect(host.querySelector('.memo-editor')).not.toBeNull()
})
})
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Archive, FileText, Search } from 'lucide-vue-next'
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
import MemoEditor, { type Memo } from './components/MemoEditor.vue'
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
const props = defineProps<{ request: RequestFn }>()
const emit = defineEmits<{ notice: [message: string]; scope: [scope: 'active' | 'trash'] }>()
const scope = ref<'active' | 'trash'>('active')
const query = ref('')
const items = ref<MemoListItem[]>([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const refreshing = ref(false)
const error = ref('')
const selected = ref<Memo | null>(null)
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
const mobileDetail = ref(window.innerWidth <= 930)
let timer: number | undefined
let generation = 0
const hasMore = computed(() => items.value.length < total.value)
const emptyCopy = computed(() => query.value.trim() ? '没有匹配的备忘录' : scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
async function load(reset = true) {
const token = ++generation
if (reset) { page.value = 1; loading.value = true } else refreshing.value = true
error.value = ''
try {
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(page.value), page_size: '50' })
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
if (token !== generation) return
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : '加载失败' }
finally { if (token === generation) { loading.value = false; refreshing.value = false } }
}
async function loadMore() { if (!hasMore.value || refreshing.value) return; page.value += 1; await load(false) }
async function setScope(next: 'active' | 'trash') {
if (next === scope.value) return
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
selected.value = null; scope.value = next; emit('scope', next); await load()
}
async function selectMemo(id: string) {
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 : '读取失败' }
}
async function createMemo() {
if (scope.value === 'trash') return
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return
try {
const created = await props.request('/memos', { method: 'POST', body: JSON.stringify({ title: '未命名备忘录', content: '' }) }) as Memo
items.value = [{ ...created, excerpt: '' }, ...items.value]; total.value += 1; selected.value = created
emit('notice', '备忘录已创建')
} catch (reason) { error.value = reason instanceof Error ? reason.message : '创建失败' }
}
function updateItem(memo: Memo) {
selected.value = memo
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) }
}
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 removeRestoredItem(memo: Memo) { removeItem(memo.id) }
function updateLayout() { mobileDetail.value = window.innerWidth <= 930 }
watch(query, () => { if (timer) clearTimeout(timer); timer = window.setTimeout(() => void load(), 250) })
onMounted(() => { window.addEventListener('resize', updateLayout); void load() })
onUnmounted(() => { window.removeEventListener('resize', updateLayout); if (timer) clearTimeout(timer) })
defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dirty: computed(() => Boolean(editor.value?.dirty)) })
</script>
<template>
<section class="memo-panel" :class="{'memo-panel--detail':selected}">
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
<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>
<label class="memo-search"><Search/><input v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…"></label>
</div>
<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-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角团子猫新建一条'}}</span></div>
<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"/>
</div>
<button v-if="hasMore" class="secondary memo-load-more" :disabled="refreshing" @click="loadMore">{{refreshing?'正在加载':'加载更多'}}</button>
</div>
<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)"/>
</section>
</template>
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { clampFabPosition, isFabDrag, snapFabPosition } from '../lib/mvp-utils'
const props = withDefaults(defineProps<{ label?: string }>(), { label: '添加' })
const props = withDefaults(defineProps<{ label?: string; show?: boolean }>(), { label: '添加', show: true })
const emit = defineEmits<{ activate: [origin: { x: number; y: number }] }>()
const dragging = ref(false)
const snapping = ref(false)
@@ -115,7 +115,7 @@ onUnmounted(() => {
</script>
<template>
<button class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
<button v-if="props.show" class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
<svg class="fab-cat" viewBox="0 0 56 56" aria-hidden="true">
<g class="fab-cat__tail"><path d="M40.5 36.8 C46.2 38.4 47 43.2 43 46"/></g>
<g class="fab-cat__character">
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick } from 'vue'
import MemoEditor, { type Memo } from './MemoEditor.vue'
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 }
async function mount(overrides: Record<string, unknown> = {}) {
const host = document.createElement('div'); document.body.append(host)
const events: Record<string, unknown[]> = { saved: [], close: [] }
const defaultRequest = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ ...memo, title: '新标题', version: 4 }))
const request = (overrides.request ?? defaultRequest) as (path: string, options?: RequestInit) => Promise<unknown>
const app = createApp(() => h(MemoEditor, { memo, request, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
return { host, request: request as ReturnType<typeof vi.fn>, events }
}
afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
describe('MemoEditor', () => {
it('requires a 1-200 character title and saves explicitly with its version', async () => {
const { host, request, events } = await mount()
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
title.value = ' 新标题 '; title.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await nextTick(); await nextTick()
expect(request).toHaveBeenCalledWith('/memos/m1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: '新标题', content: '正文', version: 3 }) }))
expect(events.saved).toHaveLength(1)
})
it('supports Cmd/Ctrl+S and prevents browser save', async () => {
const { host, request } = await mount()
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
title.value = '新标题'; title.dispatchEvent(new Event('input'))
const event = new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true, cancelable: true })
host.querySelector('.memo-editor')!.dispatchEvent(event); await nextTick(); await nextTick()
expect(event.defaultPrevented).toBe(true)
expect(request).toHaveBeenCalledOnce()
})
it('keeps the draft and offers reload after a 409 conflict', async () => {
const request = vi.fn().mockRejectedValue(Object.assign(new Error('备忘录版本冲突'), { status: 409 }))
const { host } = await mount({ request })
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
title.value = '我的草稿'; title.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
expect(title.value).toBe('我的草稿')
expect(host.textContent).toContain('版本冲突')
expect(host.querySelector('.memo-reload')).not.toBeNull()
})
it('keeps the editor open and reports destructive action failures', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => {
if (options?.method === 'DELETE') throw new Error('删除失败')
return memo
})
const { host, events } = await mount({ request })
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
expect(host.textContent).toContain('删除失败')
expect(events.close).toEqual([])
})
it('traps mobile focus, closes on Escape, and restores opener focus', async () => {
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
const { host, events } = await mount({ mobile: true })
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
expect(dialog.getAttribute('aria-modal')).toBe('true')
const last = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')].at(-1)!
last.focus()
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
dialog.dispatchEvent(tab)
expect(tab.defaultPrevented).toBe(true)
expect(document.activeElement).toBe(dialog.querySelector('button'))
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
await nextTick()
expect(events.close).toEqual([true])
expect(document.activeElement).toBe(opener)
opener.remove()
})
it('guards dirty close and allows clean close', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
const { host, events } = await mount()
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
expect(confirm).toHaveBeenCalled()
expect(events.close).toEqual([])
confirm.mockReturnValue(true)
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
expect(events.close).toEqual([true])
})
})
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
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 }
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
const props = defineProps<{ memo: Memo; request: RequestFn; mobile?: boolean }>()
const emit = defineEmits<{ saved: [memo: Memo]; close: []; deleted: [id: string]; restored: [memo: Memo]; purged: [id: string]; notice: [message: string] }>()
const title = ref('')
const content = ref('')
const saving = ref(false)
const error = ref('')
const conflict = ref(false)
const titleInput = ref<HTMLInputElement | null>(null)
const root = ref<HTMLElement | null>(null)
let initial = { title: '', content: '' }
let opener: HTMLElement | null = null
const dirty = computed(() => title.value !== initial.title || content.value !== initial.content)
function loadDraft(memo: Memo) {
title.value = memo.title; content.value = memo.content
initial = { title: memo.title, content: memo.content }
error.value = ''; conflict.value = false
}
watch(() => props.memo, loadDraft, { immediate: true })
function close() {
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
emit('close')
nextTick(() => opener?.focus())
}
function validate() {
const value = title.value.trim()
if (!value) return '请输入标题'
if (value.length > 200) return '标题最多 200 个字符'
return ''
}
async function save() {
error.value = validate(); if (error.value || saving.value || props.memo.deleted_at) return
saving.value = true; conflict.value = false
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
loadDraft(updated); emit('saved', updated); emit('notice', '备忘录已保存')
} catch (reason) {
const status = (reason as { status?: number }).status
conflict.value = status === 409
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : '保存失败'
} finally { saving.value = false }
}
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 : '重新载入失败' }
}
async function remove() {
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
error.value = ''
try {
await props.request(`/memos/${props.memo.id}`, { method: 'DELETE' })
emit('deleted', props.memo.id); emit('notice', '备忘录已移到回收站')
} catch (reason) { error.value = reason instanceof Error ? reason.message : '删除失败' }
}
async function restore() {
error.value = ''
try {
const restored = await props.request(`/memos/${props.memo.id}/restore`, { method: 'POST' }) as Memo
emit('restored', restored); emit('notice', '备忘录已恢复')
} catch (reason) { error.value = reason instanceof Error ? reason.message : '恢复失败' }
}
async function purge() {
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
error.value = ''
try {
await props.request(`/memos/${props.memo.id}/purge`, { method: 'DELETE' })
emit('purged', props.memo.id); emit('notice', '备忘录已永久删除')
} catch (reason) { error.value = reason instanceof Error ? reason.message : '永久删除失败' }
}
function keydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
if (event.key === 'Escape') { event.preventDefault(); close(); return }
if (!props.mobile || event.key !== 'Tab' || !root.value) return
const controls = [...root.value.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')]
if (!controls.length) return
const first = controls[0], last = controls.at(-1)!
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
onMounted(() => { opener = document.activeElement as HTMLElement; window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
onUnmounted(() => window.removeEventListener('beforeunload', beforeUnload))
defineExpose({ dirty, requestClose: close })
</script>
<template>
<aside ref="root" class="memo-editor" role="dialog" :aria-modal="mobile ? 'true' : undefined" aria-labelledby="memo-editor-title" @keydown="keydown">
<header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
<div class="memo-editor__fields">
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></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>
</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-else><button type="button" class="secondary" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" @click="purge"><Trash2/>永久删除</button></footer>
</aside>
</template>
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, h, nextTick, ref } from 'vue'
import MemoRow from './MemoRow.vue'
const cleanups: Array<() => void> = []
const memo = { id: 'm1', title: '旅行清单', excerpt: '护照\n 充电器 相机', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
describe('MemoRow', () => {
it('renders a compact selectable row with plain excerpt and semantic update time', async () => {
const host = document.createElement('div'); document.body.append(host)
const selected: string[] = []
const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string) => selected.push(id) }))
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
const button = host.querySelector('button')!
expect(button.classList.contains('active')).toBe(true)
expect(button.getAttribute('aria-current')).toBe('true')
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('护照 充电器 相机')
expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at)
button.click()
expect(selected).toEqual(['m1'])
})
it('updates the excerpt when the memo prop changes and preserves empty and whitespace formatting', async () => {
const host = document.createElement('div'); document.body.append(host)
const current = ref({ ...memo, excerpt: '' })
const app = createApp(() => h(MemoRow, { memo: current.value }))
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
const excerpt = () => host.querySelector('.memo-row__excerpt')?.textContent
expect(excerpt()).toBe('暂无正文')
current.value = { ...current.value, excerpt: '保存后\n 新摘要 正常' }
await nextTick()
expect(excerpt()).toBe('保存后 新摘要 正常')
current.value = { ...current.value, excerpt: ' \n\t ' }
await nextTick()
expect(excerpt()).toBe('暂无正文')
})
})
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
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 }
const props = defineProps<{ memo: MemoListItem; active?: boolean }>()
const emit = defineEmits<{ select: [id: string] }>()
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))
</script>
<template>
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id)">
<strong>{{ memo.title }}</strong>
<span class="memo-row__excerpt">{{ excerpt || '暂无正文' }}</span>
<time :datetime="memo.updated_at">{{ updated }}</time>
</button>
</template>
+2
View File
@@ -65,6 +65,8 @@ describe('MVP view utilities', () => {
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'tasks', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: 'list-2' })
storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' }))
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
})
+2 -2
View File
@@ -1,7 +1,7 @@
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
type StoredNavigation = { view: NavigationView; listId: string }
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'settings'])
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'settings'])
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
try {
+1
View File
@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
import './memo.css'
createApp(App).mount('#app')
if ('serviceWorker' in navigator && import.meta.env.PROD) {
+3
View File
@@ -0,0 +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}
@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(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}