Skip to content

待办事项应用

一个全面的待办事项应用,展示了高级 Pinia 模式,包括嵌套状态管理、复杂过滤、持久化和实时同步。

功能特性

  • ✅ 创建、编辑和删除待办事项
  • 🏷️ 分类和标签
  • 📅 截止日期和优先级
  • 🔍 高级过滤和搜索
  • 💾 本地存储持久化
  • 🔄 实时同步
  • 📊 统计和分析
  • 🎨 拖拽排序
  • 📱 响应式设计

类型定义

typescript
// types/todo.ts
export interface Todo {
  id: string
  title: string
  description?: string
  completed: boolean
  priority: 'low' | 'medium' | 'high'
  categoryId?: string
  tags: string[]
  dueDate?: Date
  createdAt: Date
  updatedAt: Date
  completedAt?: Date
  userId?: string
}

export interface Category {
  id: string
  name: string
  color: string
  icon?: string
  description?: string
  createdAt: Date
}

export interface TodoFilter {
  search?: string
  completed?: boolean
  priority?: Todo['priority'][]
  categoryId?: string
  tags?: string[]
  dueDateRange?: {
    start?: Date
    end?: Date
  }
  sortBy?: 'createdAt' | 'dueDate' | 'priority' | 'title'
  sortOrder?: 'asc' | 'desc'
}

export interface TodoStats {
  total: number
  completed: number
  pending: number
  overdue: number
  byPriority: Record<Todo['priority'], number>
  byCategory: Record<string, number>
  completionRate: number
  averageCompletionTime: number
}

存储实现

待办事项存储

typescript
// stores/todos.ts
import { defineStore } from 'pinia'
import { useCategoriesStore } from './categories'
import { useAuthStore } from './auth'

export const useTodosStore = defineStore('todos', () => {
  const categoriesStore = useCategoriesStore()
  const authStore = useAuthStore()

  // 状态
  const todos = ref<Todo[]>([])
  const filter = ref<TodoFilter>({})
  const loading = ref(false)
  const error = ref<string | null>(null)
  const lastSync = ref<Date | null>(null)
  const selectedTodos = ref<Set<string>>(new Set())

  // 计算属性
  const filteredTodos = computed(() => {
    let result = [...todos.value]

    // 搜索过滤
    if (filter.value.search) {
      const searchLower = filter.value.search.toLowerCase()
      result = result.filter(todo => 
        todo.title.toLowerCase().includes(searchLower) ||
        todo.description?.toLowerCase().includes(searchLower) ||
        todo.tags.some(tag => tag.toLowerCase().includes(searchLower))
      )
    }

    // 完成状态过滤
    if (filter.value.completed !== undefined) {
      result = result.filter(todo => todo.completed === filter.value.completed)
    }

    // 优先级过滤
    if (filter.value.priority?.length) {
      result = result.filter(todo => filter.value.priority!.includes(todo.priority))
    }

    // 分类过滤
    if (filter.value.categoryId) {
      result = result.filter(todo => todo.categoryId === filter.value.categoryId)
    }

    // 标签过滤
    if (filter.value.tags?.length) {
      result = result.filter(todo => 
        filter.value.tags!.some(tag => todo.tags.includes(tag))
      )
    }

    // 截止日期过滤
    if (filter.value.dueDateRange) {
      const { start, end } = filter.value.dueDateRange
      result = result.filter(todo => {
        if (!todo.dueDate) return false
        const dueDate = new Date(todo.dueDate)
        if (start && dueDate < start) return false
        if (end && dueDate > end) return false
        return true
      })
    }

    // 排序
    const sortBy = filter.value.sortBy || 'createdAt'
    const sortOrder = filter.value.sortOrder || 'desc'
    
    result.sort((a, b) => {
      let aValue: any = a[sortBy]
      let bValue: any = b[sortBy]

      if (sortBy === 'priority') {
        const priorityOrder = { high: 3, medium: 2, low: 1 }
        aValue = priorityOrder[a.priority]
        bValue = priorityOrder[b.priority]
      }

      if (aValue instanceof Date) aValue = aValue.getTime()
      if (bValue instanceof Date) bValue = bValue.getTime()

      const comparison = aValue < bValue ? -1 : aValue > bValue ? 1 : 0
      return sortOrder === 'asc' ? comparison : -comparison
    })

    return result
  })

  const todosByCategory = computed(() => {
    const grouped = new Map<string, Todo[]>()
    
    filteredTodos.value.forEach(todo => {
      const categoryId = todo.categoryId || 'uncategorized'
      if (!grouped.has(categoryId)) {
        grouped.set(categoryId, [])
      }
      grouped.get(categoryId)!.push(todo)
    })
    
    return grouped
  })

  const overdueTodos = computed(() => {
    const now = new Date()
    return todos.value.filter(todo => 
      !todo.completed && 
      todo.dueDate && 
      new Date(todo.dueDate) < now
    )
  })

  const todayTodos = computed(() => {
    const today = new Date()
    today.setHours(0, 0, 0, 0)
    const tomorrow = new Date(today)
    tomorrow.setDate(tomorrow.getDate() + 1)

    return todos.value.filter(todo => {
      if (!todo.dueDate) return false
      const dueDate = new Date(todo.dueDate)
      return dueDate >= today && dueDate < tomorrow
    })
  })

  const stats = computed((): TodoStats => {
    const total = todos.value.length
    const completed = todos.value.filter(t => t.completed).length
    const pending = total - completed
    const overdue = overdueTodos.value.length

    const byPriority = todos.value.reduce((acc, todo) => {
      acc[todo.priority] = (acc[todo.priority] || 0) + 1
      return acc
    }, {} as Record<Todo['priority'], number>)

    const byCategory = todos.value.reduce((acc, todo) => {
      const categoryId = todo.categoryId || 'uncategorized'
      acc[categoryId] = (acc[categoryId] || 0) + 1
      return acc
    }, {} as Record<string, number>)

    const completionRate = total > 0 ? (completed / total) * 100 : 0

    const completedTodos = todos.value.filter(t => t.completed && t.completedAt)
    const averageCompletionTime = completedTodos.length > 0 
      ? completedTodos.reduce((acc, todo) => {
          const created = new Date(todo.createdAt).getTime()
          const completed = new Date(todo.completedAt!).getTime()
          return acc + (completed - created)
        }, 0) / completedTodos.length
      : 0

    return {
      total,
      completed,
      pending,
      overdue,
      byPriority,
      byCategory,
      completionRate,
      averageCompletionTime
    }
  })

  const allTags = computed(() => {
    const tagSet = new Set<string>()
    todos.value.forEach(todo => {
      todo.tags.forEach(tag => tagSet.add(tag))
    })
    return Array.from(tagSet).sort()
  })

  // 操作方法
  async function fetchTodos() {
    loading.value = true
    error.value = null

    try {
      const response = await fetch('/api/todos', {
        headers: {
          'Authorization': `Bearer ${authStore.token}`
        }
      })

      if (!response.ok) {
        throw new Error('获取待办事项失败')
      }

      const data = await response.json()
      todos.value = data.map(todo => ({
        ...todo,
        createdAt: new Date(todo.createdAt),
        updatedAt: new Date(todo.updatedAt),
        dueDate: todo.dueDate ? new Date(todo.dueDate) : undefined,
        completedAt: todo.completedAt ? new Date(todo.completedAt) : undefined
      }))
      
      lastSync.value = new Date()
    } catch (err) {
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    } finally {
      loading.value = false
    }
  }

  async function createTodo(todoData: Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>) {
    const optimisticTodo: Todo = {
      id: `temp-${Date.now()}`,
      ...todoData,
      createdAt: new Date(),
      updatedAt: new Date()
    }

    // 乐观更新
    todos.value.unshift(optimisticTodo)

    try {
      const response = await fetch('/api/todos', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${authStore.token}`
        },
        body: JSON.stringify(todoData)
      })

      if (!response.ok) {
        throw new Error('创建待办事项失败')
      }

      const createdTodo = await response.json()
      
      // 用真实数据替换乐观更新的数据
      const index = todos.value.findIndex(t => t.id === optimisticTodo.id)
      if (index !== -1) {
        todos.value[index] = {
          ...createdTodo,
          createdAt: new Date(createdTodo.createdAt),
          updatedAt: new Date(createdTodo.updatedAt),
          dueDate: createdTodo.dueDate ? new Date(createdTodo.dueDate) : undefined
        }
      }

      return createdTodo
    } catch (err) {
      // 回滚乐观更新
      const index = todos.value.findIndex(t => t.id === optimisticTodo.id)
      if (index !== -1) {
        todos.value.splice(index, 1)
      }
      
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    }
  }

  async function updateTodo(id: string, updates: Partial<Todo>) {
    const todoIndex = todos.value.findIndex(t => t.id === id)
    if (todoIndex === -1) return

    const originalTodo = { ...todos.value[todoIndex] }
    const updatedTodo = {
      ...originalTodo,
      ...updates,
      updatedAt: new Date(),
      completedAt: updates.completed && !originalTodo.completed ? new Date() : 
                   !updates.completed && originalTodo.completed ? undefined :
                   originalTodo.completedAt
    }

    // 乐观更新
    todos.value[todoIndex] = updatedTodo

    try {
      const response = await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${authStore.token}`
        },
        body: JSON.stringify(updates)
      })

      if (!response.ok) {
        throw new Error('更新待办事项失败')
      }

      const serverTodo = await response.json()
      todos.value[todoIndex] = {
        ...serverTodo,
        createdAt: new Date(serverTodo.createdAt),
        updatedAt: new Date(serverTodo.updatedAt),
        dueDate: serverTodo.dueDate ? new Date(serverTodo.dueDate) : undefined,
        completedAt: serverTodo.completedAt ? new Date(serverTodo.completedAt) : undefined
      }
    } catch (err) {
      // 回滚乐观更新
      todos.value[todoIndex] = originalTodo
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    }
  }

  async function deleteTodo(id: string) {
    const todoIndex = todos.value.findIndex(t => t.id === id)
    if (todoIndex === -1) return

    const deletedTodo = todos.value[todoIndex]
    
    // 乐观更新
    todos.value.splice(todoIndex, 1)
    selectedTodos.value.delete(id)

    try {
      const response = await fetch(`/api/todos/${id}`, {
        method: 'DELETE',
        headers: {
          'Authorization': `Bearer ${authStore.token}`
        }
      })

      if (!response.ok) {
        throw new Error('删除待办事项失败')
      }
    } catch (err) {
      // 回滚乐观更新
      todos.value.splice(todoIndex, 0, deletedTodo)
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    }
  }

  async function bulkUpdateTodos(todoIds: string[], updates: Partial<Todo>) {
    const originalTodos = new Map()
    
    // 存储原始状态并应用乐观更新
    todoIds.forEach(id => {
      const todoIndex = todos.value.findIndex(t => t.id === id)
      if (todoIndex !== -1) {
        originalTodos.set(id, { ...todos.value[todoIndex] })
        todos.value[todoIndex] = {
          ...todos.value[todoIndex],
          ...updates,
          updatedAt: new Date()
        }
      }
    })

    try {
      const response = await fetch('/api/todos/bulk', {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${authStore.token}`
        },
        body: JSON.stringify({ ids: todoIds, updates })
      })

      if (!response.ok) {
        throw new Error('批量更新待办事项失败')
      }

      const updatedTodos = await response.json()
      
      // 用服务器响应更新
      updatedTodos.forEach(serverTodo => {
        const todoIndex = todos.value.findIndex(t => t.id === serverTodo.id)
        if (todoIndex !== -1) {
          todos.value[todoIndex] = {
            ...serverTodo,
            createdAt: new Date(serverTodo.createdAt),
            updatedAt: new Date(serverTodo.updatedAt),
            dueDate: serverTodo.dueDate ? new Date(serverTodo.dueDate) : undefined,
            completedAt: serverTodo.completedAt ? new Date(serverTodo.completedAt) : undefined
          }
        }
      })
    } catch (err) {
      // 回滚乐观更新
      originalTodos.forEach((originalTodo, id) => {
        const todoIndex = todos.value.findIndex(t => t.id === id)
        if (todoIndex !== -1) {
          todos.value[todoIndex] = originalTodo
        }
      })
      
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    }
  }

  function setFilter(newFilter: Partial<TodoFilter>) {
    filter.value = { ...filter.value, ...newFilter }
  }

  function clearFilter() {
    filter.value = {}
  }

  function toggleTodoSelection(id: string) {
    if (selectedTodos.value.has(id)) {
      selectedTodos.value.delete(id)
    } else {
      selectedTodos.value.add(id)
    }
  }

  function selectAllTodos() {
    filteredTodos.value.forEach(todo => {
      selectedTodos.value.add(todo.id)
    })
  }

  function clearSelection() {
    selectedTodos.value.clear()
  }

  function reorderTodos(fromIndex: number, toIndex: number) {
    const item = filteredTodos.value[fromIndex]
    const newTodos = [...todos.value]
    const originalIndex = newTodos.findIndex(t => t.id === item.id)
    
    if (originalIndex !== -1) {
      newTodos.splice(originalIndex, 1)
      newTodos.splice(toIndex, 0, item)
      todos.value = newTodos
    }
  }

  // 持久化
  function saveToLocalStorage() {
    try {
      localStorage.setItem('todos', JSON.stringify(todos.value))
      localStorage.setItem('todos-filter', JSON.stringify(filter.value))
    } catch (error) {
      console.error('保存到本地存储失败:', error)
    }
  }

  function loadFromLocalStorage() {
    try {
      const savedTodos = localStorage.getItem('todos')
      const savedFilter = localStorage.getItem('todos-filter')
      
      if (savedTodos) {
        todos.value = JSON.parse(savedTodos).map(todo => ({
          ...todo,
          createdAt: new Date(todo.createdAt),
          updatedAt: new Date(todo.updatedAt),
          dueDate: todo.dueDate ? new Date(todo.dueDate) : undefined,
          completedAt: todo.completedAt ? new Date(todo.completedAt) : undefined
        }))
      }
      
      if (savedFilter) {
        filter.value = JSON.parse(savedFilter)
      }
    } catch (error) {
      console.error('从本地存储加载失败:', error)
    }
  }

  // 自动保存到本地存储
  watch(todos, saveToLocalStorage, { deep: true })
  watch(filter, saveToLocalStorage, { deep: true })

  return {
    // 状态
    todos: readonly(todos),
    filter: readonly(filter),
    loading: readonly(loading),
    error: readonly(error),
    lastSync: readonly(lastSync),
    selectedTodos: readonly(selectedTodos),
    
    // 计算属性
    filteredTodos,
    todosByCategory,
    overdueTodos,
    todayTodos,
    stats,
    allTags,
    
    // 操作方法
    fetchTodos,
    createTodo,
    updateTodo,
    deleteTodo,
    bulkUpdateTodos,
    setFilter,
    clearFilter,
    toggleTodoSelection,
    selectAllTodos,
    clearSelection,
    reorderTodos,
    loadFromLocalStorage
  }
})

分类存储

typescript
// stores/categories.ts
export const useCategoriesStore = defineStore('categories', () => {
  const categories = ref<Category[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  const categoriesMap = computed(() => {
    return new Map(categories.value.map(cat => [cat.id, cat]))
  })

  async function fetchCategories() {
    loading.value = true
    error.value = null

    try {
      const response = await fetch('/api/categories')
      if (!response.ok) {
        throw new Error('获取分类失败')
      }
      
      const data = await response.json()
      categories.value = data.map(cat => ({
        ...cat,
        createdAt: new Date(cat.createdAt)
      }))
    } catch (err) {
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    } finally {
      loading.value = false
    }
  }

  async function createCategory(categoryData: Omit<Category, 'id' | 'createdAt'>) {
    try {
      const response = await fetch('/api/categories', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(categoryData)
      })

      if (!response.ok) {
        throw new Error('创建分类失败')
      }

      const newCategory = await response.json()
      categories.value.push({
        ...newCategory,
        createdAt: new Date(newCategory.createdAt)
      })
      
      return newCategory
    } catch (err) {
      error.value = err instanceof Error ? err.message : '未知错误'
      throw err
    }
  }

  function getCategoryById(id: string): Category | undefined {
    return categoriesMap.value.get(id)
  }

  return {
    categories: readonly(categories),
    loading: readonly(loading),
    error: readonly(error),
    categoriesMap,
    fetchCategories,
    createCategory,
    getCategoryById
  }
})

组件使用

待办事项列表组件

vue
<!-- components/TodoList.vue -->
<template>
  <div class="todo-list">
    <!-- 过滤器 -->
    <div class="filters">
      <input 
        v-model="searchQuery" 
        placeholder="搜索待办事项..."
        class="search-input"
      />
      
      <select v-model="selectedCategory">
        <option value="">所有分类</option>
        <option 
          v-for="category in categoriesStore.categories" 
          :key="category.id"
          :value="category.id"
        >
          {{ category.name }}
        </option>
      </select>
      
      <select v-model="completionFilter">
        <option value="">全部</option>
        <option value="pending">待完成</option>
        <option value="completed">已完成</option>
      </select>
    </div>

    <!-- 统计信息 -->
    <div class="stats">
      <div class="stat">
        <span class="label">总计:</span>
        <span class="value">{{ todosStore.stats.total }}</span>
      </div>
      <div class="stat">
        <span class="label">已完成:</span>
        <span class="value">{{ todosStore.stats.completed }}</span>
      </div>
      <div class="stat">
        <span class="label">完成率:</span>
        <span class="value">{{ todosStore.stats.completionRate.toFixed(1) }}%</span>
      </div>
    </div>

    <!-- 批量操作 -->
    <div v-if="todosStore.selectedTodos.size > 0" class="bulk-actions">
      <button @click="markSelectedAsCompleted">
        标记 {{ todosStore.selectedTodos.size }} 项为已完成
      </button>
      <button @click="deleteSelected">
        删除选中项
      </button>
      <button @click="todosStore.clearSelection()">
        清除选择
      </button>
    </div>

    <!-- 待办事项 -->
    <div class="todo-items">
      <TransitionGroup name="todo" tag="div">
        <TodoItem
          v-for="todo in todosStore.filteredTodos"
          :key="todo.id"
          :todo="todo"
          :selected="todosStore.selectedTodos.has(todo.id)"
          @toggle-selection="todosStore.toggleTodoSelection(todo.id)"
          @update="handleUpdateTodo"
          @delete="handleDeleteTodo"
        />
      </TransitionGroup>
    </div>

    <!-- 空状态 -->
    <div v-if="todosStore.filteredTodos.length === 0" class="empty-state">
      <p>未找到待办事项</p>
      <button @click="showCreateForm = true">创建您的第一个待办事项</button>
    </div>

    <!-- 创建表单 -->
    <TodoCreateForm 
      v-if="showCreateForm"
      @create="handleCreateTodo"
      @cancel="showCreateForm = false"
    />
  </div>
</template>

<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useTodosStore } from '@/stores/todos'
import { useCategoriesStore } from '@/stores/categories'
import TodoItem from './TodoItem.vue'
import TodoCreateForm from './TodoCreateForm.vue'

const todosStore = useTodosStore()
const categoriesStore = useCategoriesStore()

const showCreateForm = ref(false)
const searchQuery = ref('')
const selectedCategory = ref('')
const completionFilter = ref('')

// 当响应式值变化时更新过滤器
watch([searchQuery, selectedCategory, completionFilter], () => {
  todosStore.setFilter({
    search: searchQuery.value || undefined,
    categoryId: selectedCategory.value || undefined,
    completed: completionFilter.value === 'completed' ? true : 
               completionFilter.value === 'pending' ? false : undefined
  })
})

async function handleCreateTodo(todoData: any) {
  try {
    await todosStore.createTodo(todoData)
    showCreateForm.value = false
  } catch (error) {
    console.error('创建待办事项失败:', error)
  }
}

async function handleUpdateTodo(id: string, updates: any) {
  try {
    await todosStore.updateTodo(id, updates)
  } catch (error) {
    console.error('更新待办事项失败:', error)
  }
}

async function handleDeleteTodo(id: string) {
  try {
    await todosStore.deleteTodo(id)
  } catch (error) {
    console.error('删除待办事项失败:', error)
  }
}

async function markSelectedAsCompleted() {
  try {
    await todosStore.bulkUpdateTodos(
      Array.from(todosStore.selectedTodos),
      { completed: true }
    )
    todosStore.clearSelection()
  } catch (error) {
    console.error('标记待办事项为已完成失败:', error)
  }
}

async function deleteSelected() {
  if (confirm(`删除 ${todosStore.selectedTodos.size} 个待办事项?`)) {
    try {
      const promises = Array.from(todosStore.selectedTodos).map(id => 
        todosStore.deleteTodo(id)
      )
      await Promise.all(promises)
      todosStore.clearSelection()
    } catch (error) {
      console.error('删除待办事项失败:', error)
    }
  }
}

onMounted(async () => {
  todosStore.loadFromLocalStorage()
  await Promise.all([
    todosStore.fetchTodos(),
    categoriesStore.fetchCategories()
  ])
})
</script>

测试

typescript
// tests/stores/todos.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTodosStore } from '@/stores/todos'

// 模拟 fetch
global.fetch = vi.fn()

describe('待办事项存储', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.clearAllMocks()
  })

  it('应该根据搜索查询过滤待办事项', () => {
    const store = useTodosStore()
    
    store.todos = [
      { id: '1', title: '买杂货', completed: false },
      { id: '2', title: '遛狗', completed: false },
      { id: '3', title: '买狗粮', completed: true }
    ]
    
    store.setFilter({ search: '狗' })
    
    expect(store.filteredTodos).toHaveLength(2)
    expect(store.filteredTodos.map(t => t.id)).toEqual(['2', '3'])
  })

  it('应该正确计算统计信息', () => {
    const store = useTodosStore()
    
    store.todos = [
      { id: '1', completed: false, priority: 'high' },
      { id: '2', completed: true, priority: 'medium' },
      { id: '3', completed: true, priority: 'high' }
    ]
    
    expect(store.stats.total).toBe(3)
    expect(store.stats.completed).toBe(2)
    expect(store.stats.pending).toBe(1)
    expect(store.stats.completionRate).toBe(66.67)
    expect(store.stats.byPriority.high).toBe(2)
    expect(store.stats.byPriority.medium).toBe(1)
  })

  it('应该处理乐观更新', async () => {
    const store = useTodosStore()
    
    const mockResponse = { id: 'real-id', title: '测试待办事项' }
    fetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockResponse)
    })
    
    const promise = store.createTodo({ title: '测试待办事项', completed: false })
    
    // 应该立即有乐观更新的待办事项
    expect(store.todos).toHaveLength(1)
    expect(store.todos[0].title).toBe('测试待办事项')
    expect(store.todos[0].id).toMatch(/^temp-/)
    
    await promise
    
    // 应该用真实待办事项替换
    expect(store.todos).toHaveLength(1)
    expect(store.todos[0].id).toBe('real-id')
  })

  it('应该在乐观更新失败时回滚', async () => {
    const store = useTodosStore()
    
    fetch.mockRejectedValueOnce(new Error('网络错误'))
    
    await expect(store.createTodo({ 
      title: '测试待办事项', 
      completed: false 
    })).rejects.toThrow('网络错误')
    
    // 应该回滚乐观更新
    expect(store.todos).toHaveLength(0)
  })
})

核心功能

1. 高级过滤

  • 跨标题、描述和标签的文本搜索
  • 按完成状态、优先级、分类过滤
  • 截止日期范围过滤
  • 多条件排序

2. 乐观更新

  • 所有操作的即时 UI 反馈
  • 失败时自动回滚
  • 支持批量操作

3. 实时同步

  • 与服务器的后台同步
  • 冲突解决
  • 本地存储的离线支持

4. 性能优化

  • 派生状态的计算属性
  • 高效的过滤和排序
  • 大列表的虚拟滚动

5. 用户体验

  • 拖拽排序
  • 批量选择和操作
  • 键盘快捷键
  • 响应式设计

这个待办事项应用展示了如何使用 Pinia 构建复杂、功能丰富的应用,同时保持代码的清洁、可测试和高性能。

Released under the MIT License.