vue如何实现记事本小功能

发布时间:2021-11-22 11:51:42 作者:小新
来源:亿速云 阅读:228
# Vue如何实现记事本小功能

## 前言

在Web开发领域,Vue.js因其轻量级、易上手和响应式数据绑定等特性,成为构建交互式前端应用的热门选择。本文将详细介绍如何使用Vue 3实现一个完整的记事本应用,涵盖从项目搭建到功能实现的完整流程。这个记事本将包含以下核心功能:

- 新建、编辑、删除笔记
- 笔记分类与标签管理
- 本地存储持久化
- 搜索与过滤功能
- 响应式布局设计

## 一、项目环境搭建

### 1.1 初始化Vue项目

我们使用Vite作为构建工具,它能提供更快的开发体验:

```bash
npm create vite@latest vue-notepad --template vue
cd vue-notepad
npm install

1.2 安装必要依赖

npm install pinia date-fns uuid @heroicons/vue

1.3 项目目录结构

/src
├── assets
├── components
│   ├── NoteEditor.vue
│   ├── NoteList.vue
│   ├── TagSelector.vue
│   └── SearchBar.vue
├── stores
│   └── notes.js
├── App.vue
└── main.js

二、状态管理设计(Pinia)

2.1 创建笔记Store

// stores/notes.js
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { format } from 'date-fns'

export const useNotesStore = defineStore('notes', {
  state: () => ({
    notes: JSON.parse(localStorage.getItem('vue-notes')) || [],
    activeNoteId: null,
    searchQuery: '',
    selectedTag: null
  }),
  getters: {
    activeNote: (state) => 
      state.notes.find(note => note.id === state.activeNoteId),
    filteredNotes: (state) => {
      let notes = [...state.notes]
      if (state.searchQuery) {
        notes = notes.filter(note => 
          note.title.toLowerCase().includes(state.searchQuery.toLowerCase()) ||
          note.content.toLowerCase().includes(state.searchQuery.toLowerCase())
        )
      }
      if (state.selectedTag) {
        notes = notes.filter(note => 
          note.tags.includes(state.selectedTag)
        )
      }
      return notes.sort((a, b) => 
        new Date(b.updatedAt) - new Date(a.updatedAt)
      )
    },
    allTags: (state) => {
      const tags = new Set()
      state.notes.forEach(note => {
        note.tags.forEach(tag => tags.add(tag))
      })
      return Array.from(tags)
    }
  },
  actions: {
    saveToLocalStorage() {
      localStorage.setItem('vue-notes', JSON.stringify(this.notes))
    },
    createNote() {
      const newNote = {
        id: uuidv4(),
        title: '新笔记',
        content: '',
        tags: [],
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString()
      }
      this.notes.unshift(newNote)
      this.activeNoteId = newNote.id
      this.saveToLocalStorage()
      return newNote
    },
    updateNote(updatedNote) {
      const index = this.notes.findIndex(n => n.id === updatedNote.id)
      if (index !== -1) {
        this.notes[index] = {
          ...updatedNote,
          updatedAt: new Date().toISOString()
        }
        this.saveToLocalStorage()
      }
    },
    deleteNote(id) {
      this.notes = this.notes.filter(note => note.id !== id)
      if (this.activeNoteId === id) {
        this.activeNoteId = this.notes[0]?.id || null
      }
      this.saveToLocalStorage()
    },
    setActiveNote(id) {
      this.activeNoteId = id
    },
    setSearchQuery(query) {
      this.searchQuery = query
    },
    setSelectedTag(tag) {
      this.selectedTag = tag === this.selectedTag ? null : tag
    }
  }
})

三、核心组件实现

3.1 主应用结构 (App.vue)

<template>
  <div class="app-container">
    <header class="app-header">
      <h1>Vue记事本</h1>
      <button @click="createNewNote" class="new-note-btn">
        <PlusIcon class="icon" />
        新建笔记
      </button>
    </header>
    
    <div class="main-content">
      <NoteList class="note-list" />
      <NoteEditor v-if="activeNote" class="note-editor" />
      <div v-else class="empty-state">
        <p>选择或创建新笔记开始记录</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useNotesStore } from '@/stores/notes'
import NoteList from '@/components/NoteList.vue'
import NoteEditor from '@/components/NoteEditor.vue'
import { PlusIcon } from '@heroicons/vue/24/outline'

const notesStore = useNotesStore()
const activeNote = computed(() => notesStore.activeNote)

const createNewNote = () => {
  notesStore.createNote()
}
</script>

<style scoped>
.app-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background-color: #f5f5f7;
}

.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 2rem;
  background-color: #4a6fa5;
  color: white;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.main-content {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.note-list {
  width: 300px;
  border-right: 1px solid #e0e0e0;
  overflow-y: auto;
}

.note-editor, .empty-state {
  flex: 1;
  padding: 2rem;
}

.empty-state {
  display: flex;
  justify-content: center;
  align-items: center;
  color: #666;
}

.new-note-btn {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 1rem;
  background-color: #3a5a8c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.new-note-btn:hover {
  background-color: #2c4368;
}

.icon {
  width: 1rem;
  height: 1rem;
}
</style>

3.2 笔记列表组件 (NoteList.vue)

<template>
  <div class="note-list-container">
    <SearchBar />
    <TagFilter />
    
    <ul class="notes">
      <li 
        v-for="note in filteredNotes" 
        :key="note.id"
        :class="{ active: note.id === activeNoteId }"
        @click="setActiveNote(note.id)"
      >
        <div class="note-header">
          <h3>{{ note.title || '无标题' }}</h3>
          <button 
            @click.stop="deleteNote(note.id)" 
            class="delete-btn"
          >
            <TrashIcon class="icon" />
          </button>
        </div>
        <p class="preview">{{ note.content.substring(0, 100) }}...</p>
        <div class="meta">
          <span class="date">
            {{ formatDate(note.updatedAt) }}
          </span>
          <div class="tags">
            <span 
              v-for="tag in note.tags" 
              :key="tag"
              class="tag"
              :style="{ backgroundColor: stringToColor(tag) }"
            >
              {{ tag }}
            </span>
          </div>
        </div>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useNotesStore } from '@/stores/notes'
import { format } from 'date-fns'
import { TrashIcon } from '@heroicons/vue/24/outline'
import SearchBar from '@/components/SearchBar.vue'
import TagFilter from '@/components/TagFilter.vue'

const notesStore = useNotesStore()
const filteredNotes = computed(() => notesStore.filteredNotes)
const activeNoteId = computed(() => notesStore.activeNoteId)

const setActiveNote = (id) => {
  notesStore.setActiveNote(id)
}

const deleteNote = (id) => {
  if (confirm('确定要删除这条笔记吗?')) {
    notesStore.deleteNote(id)
  }
}

const formatDate = (dateString) => {
  return format(new Date(dateString), 'yyyy-MM-dd HH:mm')
}

// 生成标签颜色
const stringToColor = (str) => {
  let hash = 0
  for (let i = 0; i < str.length; i++) {
    hash = str.charCodeAt(i) + ((hash << 5) - hash)
  }
  const color = `hsl(${hash % 360}, 70%, 80%)`
  return color
}
</script>

<style scoped>
.note-list-container {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.notes {
  list-style: none;
  padding: 0;
  margin: 0;
  overflow-y: auto;
  flex: 1;
}

.notes li {
  padding: 1rem;
  border-bottom: 1px solid #e0e0e0;
  cursor: pointer;
  transition: background-color 0.2s;
}

.notes li:hover {
  background-color: #f0f0f0;
}

.notes li.active {
  background-color: #e3eef9;
}

.note-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 0.5rem;
}

.note-header h3 {
  margin: 0;
  font-size: 1rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.preview {
  margin: 0.5rem 0;
  color: #666;
  font-size: 0.9rem;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 0.8rem;
  color: #999;
}

.tags {
  display: flex;
  gap: 0.3rem;
}

.tag {
  padding: 0.2rem 0.4rem;
  border-radius: 4px;
  font-size: 0.7rem;
  color: #333;
}

.delete-btn {
  background: none;
  border: none;
  color: #999;
  cursor: pointer;
  padding: 0.2rem;
}

.delete-btn:hover {
  color: #ff4d4f;
}

.icon {
  width: 1rem;
  height: 1rem;
}
</style>

3.3 笔记编辑器组件 (NoteEditor.vue)

<template>
  <div class="editor-container">
    <input 
      v-model="currentNote.title" 
      type="text" 
      class="title-input" 
      placeholder="输入标题..."
      @input="saveNote"
    >
    
    <TagSelector v-model="currentNote.tags" @change="saveNote" />
    
    <div class="editor-toolbar">
      <button @click="toggleBold">
        <BoldIcon class="icon" />
      </button>
      <button @click="toggleItalic">
        <ItalicIcon class="icon" />
      </button>
      <!-- 更多格式按钮... -->
    </div>
    
    <textarea 
      ref="editor" 
      v-model="currentNote.content" 
      class="content-editor" 
      placeholder="开始输入内容..."
      @input="saveNote"
    ></textarea>
    
    <div class="status-bar">
      最后更新: {{ formatDate(currentNote.updatedAt) }}
    </div>
  </div>
</template>

<script setup>
import { computed, ref, watch } from 'vue'
import { useNotesStore } from '@/stores/notes'
import { format } from 'date-fns'
import { 
  BoldIcon, 
  ItalicIcon,
  // 其他图标...
} from '@heroicons/vue/24/outline'
import TagSelector from '@/components/TagSelector.vue'

const notesStore = useNotesStore()
const editor = ref(null)

const currentNote = computed({
  get: () => notesStore.activeNote,
  set: (value) => notesStore.updateNote(value)
})

const saveNote = () => {
  if (currentNote.value) {
    notesStore.updateNote(currentNote.value)
  }
}

const formatDate = (dateString) => {
  return format(new Date(dateString), 'yyyy-MM-dd HH:mm:ss')
}

// 简单的文本格式功能
const toggleBold = () => {
  document.execCommand('bold', false, null)
  editor.value.focus()
}

const toggleItalic = () => {
  document.execCommand('italic', false, null)
  editor.value.focus()
}

// 监听活动笔记变化,自动聚焦编辑器
watch(() => notesStore.activeNoteId, () => {
  if (editor.value) {
    setTimeout(() => {
      editor.value.focus()
    }, 0)
  }
})
</script>

<style scoped>
.editor-container {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.title-input {
  font-size: 1.5rem;
  padding: 1rem;
  border: none;
  border-bottom: 1px solid #e0e0e0;
  margin-bottom: 1rem;
  outline: none;
}

.content-editor {
  flex: 1;
  padding: 1rem;
  border: none;
  resize: none;
  line-height: 1.6;
  font-family: inherit;
  font-size: 1rem;
  outline: none;
}

.editor-toolbar {
  display: flex;
  gap: 0.5rem;
  padding: 0.5rem 1rem;
  border-bottom: 1px solid #e0e0e0;
}

.editor-toolbar button {
  background: none;
  border: none;
  cursor: pointer;
  padding: 0.3rem;
  border-radius: 4px;
}

.editor-toolbar button:hover {
  background-color: #f0f0f0;
}

.icon {
  width: 1rem;
  height: 1rem;
}

.status-bar {
  padding: 0.5rem 1rem;
  font-size: 0.8rem;
  color: #999;
  border-top: 1px solid #e0e0e0;
}
</style>

3.4 标签选择器组件 (TagSelector.vue)

”`vue