studynote/src/stores/appStore.ts
MikiVL 9c534a920d feat(M5+): 导入导出功能、性能优化、单元测试
导出:Markdown / Word(.docx) / PDF(打印) / 纯文本,编辑器标题栏下拉菜单
导入:.md / .txt / .docx(mammoth) / .pdf(pdfjs-dist),侧边栏底部按钮
Store:createNote 支持 init 参数,filteredNotes 增加缓存层
测试:vitest 23 个单元测试(utils + filterNotes 逻辑)
构建:vite manualChunks 分包优化

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:16:11 +08:00

200 lines
6.8 KiB
TypeScript

import { create } from 'zustand'
import { db, type Note, type Folder } from '../db'
import { generateId, extractTextFromJSON } from '../lib/utils'
interface AppState {
notes: Note[]
folders: Folder[]
activeNoteId: string | null
activeFolderId: string | null | 'all' | 'starred'
searchQuery: string
theme: 'light' | 'dark'
focusMode: boolean
aiPanelOpen: boolean
activeTag: string | null
sortBy: 'updatedAt' | 'createdAt' | 'title'
sortOrder: 'asc' | 'desc'
_notesVersion: number
_filteredCache: { key: string; result: Note[] } | null
// actions
loadAll: () => Promise<void>
createNote: (folderId?: string | null, init?: { title?: string; content?: string }) => Promise<string>
updateNote: (id: string, patch: Partial<Note>, opts?: { silent?: boolean }) => Promise<void>
deleteNote: (id: string) => Promise<void>
toggleStar: (id: string) => Promise<void>
createFolder: (name: string, parentId?: string | null) => Promise<string>
updateFolder: (id: string, patch: Partial<Folder>) => Promise<void>
deleteFolder: (id: string) => Promise<void>
setActiveNote: (id: string | null) => void
setActiveFolder: (id: string | null | 'all' | 'starred') => void
setSearch: (q: string) => void
toggleTheme: () => void
toggleFocusMode: () => void
toggleAiPanel: () => void
setActiveTag: (tag: string | null) => void
setSortBy: (by: 'updatedAt' | 'createdAt' | 'title') => void
setSortOrder: (order: 'asc' | 'desc') => void
filteredNotes: () => Note[]
}
export const useAppStore = create<AppState>((set, get) => ({
notes: [],
folders: [],
activeNoteId: '__welcome__' as string | null,
activeFolderId: 'all',
searchQuery: '',
theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light',
focusMode: false,
aiPanelOpen: false,
activeTag: null,
sortBy: 'updatedAt',
sortOrder: 'desc',
_notesVersion: 0,
_filteredCache: null,
loadAll: async () => {
const [notes, folders] = await Promise.all([
db.notes.orderBy('updatedAt').reverse().toArray(),
db.folders.orderBy('order').toArray(),
])
set({ notes, folders, _notesVersion: get()._notesVersion + 1, _filteredCache: null })
// Keep welcome screen as default; only auto-select if already on a real note
const cur = get().activeNoteId
if (cur && cur !== '__welcome__' && !notes.find(n => n.id === cur)) {
set({ activeNoteId: '__welcome__' })
}
},
createNote: async (folderId = null, init?: { title?: string; content?: string }) => {
const id = generateId()
const now = Date.now()
const note: Note = {
id,
title: init?.title ?? '无标题笔记',
content: init?.content ?? JSON.stringify({ type: 'doc', content: [{ type: 'paragraph' }] }),
folderId: folderId ?? get().activeFolderId as string | null,
tags: [],
starred: false,
createdAt: now,
updatedAt: now,
wordCount: 0,
}
await db.notes.add(note)
set(s => ({ notes: [note, ...s.notes], activeNoteId: id, _notesVersion: s._notesVersion + 1, _filteredCache: null }))
return id
},
updateNote: async (id, patch, opts) => {
const silent = opts?.silent ?? false
const now = Date.now()
const dbPatch = silent ? patch : { ...patch, updatedAt: now }
await db.notes.update(id, dbPatch)
set(s => ({
notes: s.notes.map(n =>
n.id === id ? { ...n, ...dbPatch } : n
).sort((a, b) => b.updatedAt - a.updatedAt),
_notesVersion: s._notesVersion + 1,
_filteredCache: null,
}))
},
deleteNote: async (id) => {
await db.notes.delete(id)
set(s => {
const notes = s.notes.filter(n => n.id !== id)
const activeNoteId = s.activeNoteId === id ? '__welcome__' : s.activeNoteId
return { notes, activeNoteId, _notesVersion: s._notesVersion + 1, _filteredCache: null }
})
},
toggleStar: async (id) => {
const note = get().notes.find(n => n.id === id)
if (!note) return
await get().updateNote(id, { starred: !note.starred }, { silent: true })
},
createFolder: async (name, parentId = null) => {
const id = generateId()
const order = get().folders.filter(f => f.parentId === parentId).length
const folder: Folder = { id, name, parentId, order, createdAt: Date.now() }
await db.folders.add(folder)
set(s => ({ folders: [...s.folders, folder] }))
return id
},
updateFolder: async (id, patch) => {
await db.folders.update(id, patch)
set(s => ({ folders: s.folders.map(f => f.id === id ? { ...f, ...patch } : f) }))
},
deleteFolder: async (id) => {
const childIds = get().folders.filter(f => f.parentId === id).map(f => f.id)
for (const cid of childIds) await get().deleteFolder(cid)
await db.folders.delete(id)
const notesToMove = get().notes.filter(n => n.folderId === id)
for (const n of notesToMove) await get().updateNote(n.id, { folderId: null })
set(s => ({ folders: s.folders.filter(f => f.id !== id) }))
},
setActiveNote: (id) => set({ activeNoteId: id }),
setActiveFolder: (id) => set({ activeFolderId: id }),
setSearch: (q) => set({ searchQuery: q }),
toggleTheme: () => {
const next = get().theme === 'light' ? 'dark' : 'light'
localStorage.setItem('theme', next)
document.documentElement.setAttribute('data-theme', next)
set({ theme: next })
},
toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })),
toggleAiPanel: () => set(s => ({ aiPanelOpen: !s.aiPanelOpen })),
setActiveTag: (tag) => set({ activeTag: tag }),
setSortBy: (by) => set({ sortBy: by }),
setSortOrder: (order) => set({ sortOrder: order }),
filteredNotes: () => {
const { notes, activeFolderId, searchQuery, activeTag, sortBy, sortOrder, _notesVersion, _filteredCache } = get()
const cacheKey = `${_notesVersion}|${activeFolderId}|${searchQuery}|${activeTag}|${sortBy}|${sortOrder}`
if (_filteredCache?.key === cacheKey) return _filteredCache.result
let result = notes
if (activeFolderId === 'starred') {
result = result.filter(n => n.starred)
} else if (activeFolderId !== 'all' && activeFolderId !== null) {
result = result.filter(n => n.folderId === activeFolderId)
}
if (activeTag) {
result = result.filter(n => n.tags.includes(activeTag))
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
result = result.filter(n =>
n.title.toLowerCase().includes(q) ||
n.tags.some(t => t.toLowerCase().includes(q)) ||
extractTextFromJSON(n.content).toLowerCase().includes(q)
)
}
result = [...result].sort((a, b) => {
let cmp = 0
if (sortBy === 'title') {
cmp = a.title.localeCompare(b.title, 'zh-CN')
} else {
cmp = a[sortBy] - b[sortBy]
}
return sortOrder === 'asc' ? cmp : -cmp
})
set({ _filteredCache: { key: cacheKey, result } })
return result
},
}))