# Vue如何实现记事本小功能
## 前言
在Web开发领域,Vue.js因其轻量级、易上手和响应式数据绑定等特性,成为构建交互式前端应用的热门选择。本文将详细介绍如何使用Vue 3实现一个完整的记事本应用,涵盖从项目搭建到功能实现的完整流程。这个记事本将包含以下核心功能:
- 新建、编辑、删除笔记
- 笔记分类与标签管理
- 本地存储持久化
- 搜索与过滤功能
- 响应式布局设计
## 一、项目环境搭建
### 1.1 初始化Vue项目
我们使用Vite作为构建工具,它能提供更快的开发体验:
```bash
npm create vite@latest vue-notepad --template vue
cd vue-notepad
npm install
npm install pinia date-fns uuid @heroicons/vue
/src
├── assets
├── components
│ ├── NoteEditor.vue
│ ├── NoteList.vue
│ ├── TagSelector.vue
│ └── SearchBar.vue
├── stores
│ └── notes.js
├── App.vue
└── main.js
// 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
}
}
})
<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>
<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>
<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>
”`vue
<input
v-model="newTag"
type="text"
placeholder="添加标签..."
@keydown.enter="addTag"
@keydown.backspace="handleBackspace"
class="tag-input"
>
<div v-if="filteredAvailableTags.length > 0" class="tag-suggestions">
<div
v-for="tag in filteredAvailableTags"
:key="tag"
@click="addExistingTag(tag)"
class="suggestion"
>
{{ tag }}
</div>
</div>