附录 B:NoteFlow 完整源码

⏱ 预计阅读 15 分钟 更新于 2026/5/19
💡 进群学习加 wx: agentupdate
(申请发送: agentupdate)

跟完教程 12 章后,NoteFlow 项目的完整代码结构。供参考对照。

项目结构

noteflow/
├── public/
├── src/
│   ├── components/
│   │   ├── NoteList.jsx          — 笔记列表
│   │   ├── NoteList.test.jsx
│   │   ├── NoteList.css
│   │   ├── NoteEditor.jsx        — 笔记编辑器
│   │   ├── NoteEditor.test.jsx
│   │   ├── NoteEditor.css
│   │   ├── SearchBar.jsx         — 搜索栏
│   │   ├── SearchBar.test.jsx
│   │   ├── SearchBar.css
│   │   ├── TagSelector.jsx       — 分类标签选择
│   │   ├── TagSelector.test.jsx
│   │   └── TagSelector.css
│   ├── hooks/
│   │   ├── useNotes.js           — 笔记数据管理
│   │   ├── useNotes.test.js
│   │   ├── useSearch.js          — 搜索功能
│   │   └── useSearch.test.js
│   ├── utils/
│   │   ├── storage.js            — localStorage 封装
│   │   ├── storage.test.js
│   │   ├── search.js             — 搜索和高亮
│   │   ├── search.test.js
│   │   ├── migration.js          — 数据迁移
│   │   └── migration.test.js
│   ├── styles/
│   │   └── design-system.css     — 设计系统 CSS 变量
│   ├── App.jsx                   — 主应用
│   ├── App.test.jsx              — 集成测试
│   ├── App.css
│   └── main.jsx
├── docs/
│   ├── notes/
│   │   └── requirement-review.md — 需求文档(第 2 章产出)
│   ├── design/
│   │   └── design-system.md      — 设计系统(第 3 章产出)
│   └── superpowers/
│       └── plans/
│           ├── noteflow-p0.md    — P0 实施计划(第 4 章产出)
│           └── noteflow-p1.md    — P1 实施计划(第 7 章产出)
├── CLAUDE.md                     — 项目说明(第 1、10 章产出)
├── package.json
├── vite.config.js
└── vitest.config.js

核心代码

storage.js

const STORAGE_KEY = 'noteflow-notes';
const MAX_STORAGE_MB = 5;

export function saveNotes(notes) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(notes));
}

export function loadNotes() {
  const data = localStorage.getItem(STORAGE_KEY);
  return data ? JSON.parse(data) : [];
}

export function getStorageUsage() {
  const data = localStorage.getItem(STORAGE_KEY) || '';
  const usedBytes = new Blob([data]).size;
  const maxBytes = MAX_STORAGE_MB * 1024 * 1024;
  return (usedBytes / maxBytes) * 100;
}

useNotes.js

import { useState, useCallback } from 'react';
import { loadNotes, saveNotes } from '../utils/storage';
import { migrateNotes } from '../utils/migration';

export function useNotes() {
  const [notes, setNotes] = useState(() => migrateNotes(loadNotes()));
  const [storageWarning, setStorageWarning] = useState(null);

  const persist = useCallback((updatedNotes) => {
    try {
      saveNotes(updatedNotes);
      setStorageWarning(null);
    } catch (e) {
      setStorageWarning('存储空间不足,建议导出备份后清理');
    }
  }, []);

  const addNote = useCallback((note) => {
    setNotes(prev => {
      const updated = [...prev, {
        ...note,
        id: crypto.randomUUID(),
        createdAt: Date.now(),
        updatedAt: Date.now(),
        tags: note.tags || [],
      }];
      persist(updated);
      return updated;
    });
  }, [persist]);

  const updateNote = useCallback((id, changes) => {
    setNotes(prev => {
      const updated = prev.map(note =>
        note.id === id
          ? { ...note, ...changes, updatedAt: Date.now() }
          : note
      );
      persist(updated);
      return updated;
    });
  }, [persist]);

  const deleteNote = useCallback((id) => {
    setNotes(prev => {
      const updated = prev.filter(note => note.id !== id);
      persist(updated);
      return updated;
    });
  }, [persist]);

  return { notes, addNote, updateNote, deleteNote, storageWarning };
}

useSearch.js

import { useMemo } from 'react';
import { filterNotes } from '../utils/search';

export function useSearch(notes, query) {
  return useMemo(() => filterNotes(notes, query), [notes, query]);
}

search.js

export function filterNotes(notes, query) {
  if (!query.trim()) return notes;

  const lowerQuery = query.toLowerCase();
  return notes.filter(note =>
    note.title.toLowerCase().includes(lowerQuery) ||
    note.content.toLowerCase().includes(lowerQuery)
  );
}

export function highlightText(text, query) {
  if (!query.trim()) return [{ type: 'text', text }];

  const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
  const parts = text.split(regex);

  return parts.map((part, index) =>
    part.toLowerCase() === query.toLowerCase()
      ? { type: 'highlight', text: part, key: index }
      : { type: 'text', text: part, key: index }
  );
}

function escapeRegex(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

App.jsx

import { useState } from 'react';
import { useNotes } from './hooks/useNotes';
import { useSearch } from './hooks/useSearch';
import { NoteList } from './components/NoteList';
import { NoteEditor } from './components/NoteEditor';
import { SearchBar } from './components/SearchBar';
import { TagSelector } from './components/TagSelector';
import './styles/design-system.css';

export default function App() {
  const { notes, addNote, updateNote, deleteNote, storageWarning } = useNotes();
  const [selectedId, setSelectedId] = useState(null);
  const [searchQuery, setSearchQuery] = useState('');
  const [selectedTag, setSelectedTag] = useState(null);

  const filteredByTag = selectedTag
    ? notes.filter(note => note.tags?.includes(selectedTag))
    : notes;

  const searchResults = useSearch(filteredByTag, searchQuery);
  const selectedNote = notes.find(note => note.id === selectedId);

  return (
    <div className="app">
      {storageWarning && (
        <div className="storage-warning">{storageWarning}</div>
      )}
      <aside className="sidebar">
        <SearchBar query={searchQuery} onChange={setSearchQuery} />
        <TagSelector
          selected={selectedTag}
          onSelect={setSelectedTag}
        />
        <NoteList
          notes={searchResults}
          searchQuery={searchQuery}
          selectedId={selectedId}
          onSelect={setSelectedId}
          onAdd={() => {
            const note = addNote({ title: '未命名笔记', content: '' });
            setSelectedId(note.id);
          }}
          onDelete={(id) => {
            deleteNote(id);
            if (selectedId === id) setSelectedId(null);
          }}
        />
      </aside>
      <main className="editor">
        {selectedNote ? (
          <NoteEditor
            note={selectedNote}
            onChange={(changes) => updateNote(selectedId, changes)}
          />
        ) : (
          <div className="empty-state">
            <p>选择一条笔记开始编辑</p>
            <p className="empty-hint">或点击左侧 + 创建新笔记</p>
          </div>
        )}
      </main>
    </div>
  );
}

测试统计

模块 测试文件 测试用例数 覆盖率
storage storage.test.js 3 100%
search search.test.js 5 95%
migration migration.test.js 2 100%
useNotes useNotes.test.js 6 90%
useSearch useSearch.test.js 4 95%
NoteList NoteList.test.jsx 3 75%
NoteEditor NoteEditor.test.jsx 3 70%
SearchBar SearchBar.test.jsx 2 80%
TagSelector TagSelector.test.jsx 2 80%
App App.test.jsx 3 65%
总计 10 个文件 33 87%