完整项目实战 #

一、项目概述 #

1.1 项目介绍 #

我们将构建一个名为 QuickNote 的笔记应用,具有以下功能:

  • 创建、编辑、删除笔记
  • Markdown 支持
  • 分类管理
  • 搜索功能
  • 自动保存
  • 系统托盘
  • 自动更新

1.2 技术栈 #

text
前端:
- React 18
- TypeScript
- Tailwind CSS
- React Router
- Zustand (状态管理)

后端:
- Rust
- Tauri 2.x
- SQLite (数据存储)

工具:
- Vite
- pnpm

1.3 项目结构 #

text
quicknote/
├── src/
│   ├── components/
│   │   ├── Editor.tsx
│   │   ├── Sidebar.tsx
│   │   └── NoteList.tsx
│   ├── hooks/
│   │   └── useNotes.ts
│   ├── stores/
│   │   └── noteStore.ts
│   ├── types/
│   │   └── index.ts
│   ├── App.tsx
│   └── main.tsx
├── src-tauri/
│   ├── src/
│   │   ├── commands/
│   │   │   ├── mod.rs
│   │   │   └── notes.rs
│   │   ├── models/
│   │   │   └── note.rs
│   │   └── lib.rs
│   ├── Cargo.toml
│   └── tauri.conf.json
├── package.json
└── vite.config.ts

二、项目初始化 #

2.1 创建项目 #

bash
# 创建项目
npm create tauri-app@latest quicknote

# 进入项目
cd quicknote

# 安装依赖
pnpm install

# 安装额外依赖
pnpm add react-router-dom zustand @tauri-apps/plugin-fs @tauri-apps/plugin-dialog @tauri-apps/plugin-notification

2.2 配置 Tailwind CSS #

bash
pnpm add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
javascript
// tailwind.config.js
export default {
    content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
    theme: {
        extend: {},
    },
    plugins: [],
};
css
/* index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

2.3 配置 Tauri #

json
// src-tauri/tauri.conf.json
{
    "$schema": "https://schema.tauri.app/config/2",
    "productName": "QuickNote",
    "version": "1.0.0",
    "identifier": "com.quicknote.app",
    "build": {
        "beforeDevCommand": "pnpm dev",
        "devUrl": "http://localhost:1420",
        "beforeBuildCommand": "pnpm build",
        "frontendDist": "../dist"
    },
    "app": {
        "withGlobalTauri": true,
        "windows": [
            {
                "title": "QuickNote",
                "width": 1000,
                "height": 700,
                "minWidth": 600,
                "minHeight": 400,
                "center": true
            }
        ],
        "trayIcon": {
            "iconPath": "icons/icon.png",
            "iconAsTemplate": true
        }
    },
    "bundle": {
        "active": true,
        "targets": "all",
        "icon": [
            "icons/32x32.png",
            "icons/128x128.png",
            "icons/128x128@2x.png",
            "icons/icon.icns",
            "icons/icon.ico"
        ]
    }
}

三、后端实现 #

3.1 数据模型 #

rust
// src-tauri/src/models/note.rs
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
    pub id: String,
    pub title: String,
    pub content: String,
    pub category: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Category {
    pub id: String,
    pub name: String,
    pub color: String,
}

3.2 数据库初始化 #

rust
// src-tauri/src/commands/notes.rs
use rusqlite::{Connection, Result as SqliteResult};
use std::sync::Mutex;
use tauri::State;

pub struct DbState {
    pub conn: Mutex<Connection>,
}

pub fn init_db(conn: &Connection) -> SqliteResult<()> {
    conn.execute(
        "CREATE TABLE IF NOT EXISTS notes (
            id TEXT PRIMARY KEY,
            title TEXT NOT NULL,
            content TEXT,
            category TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        )",
        [],
    )?;

    conn.execute(
        "CREATE TABLE IF NOT EXISTS categories (
            id TEXT PRIMARY KEY,
            name TEXT NOT NULL,
            color TEXT
        )",
        [],
    )?;

    Ok(())
}

3.3 命令实现 #

rust
// src-tauri/src/commands/notes.rs
use crate::models::{Note, Category};
use chrono::Utc;
use tauri::State;
use uuid::Uuid;

#[tauri::command]
pub fn get_all_notes(db: State<DbState>) -> Result<Vec<Note>, String> {
    let conn = db.conn.lock().unwrap();
    
    let mut stmt = conn
        .prepare("SELECT id, title, content, category, created_at, updated_at FROM notes ORDER BY updated_at DESC")
        .map_err(|e| e.to_string())?;
    
    let notes = stmt
        .query_map([], |row| {
            Ok(Note {
                id: row.get(0)?,
                title: row.get(1)?,
                content: row.get(2)?,
                category: row.get(3)?,
                created_at: row.get::<_, String>(4)?.parse().unwrap(),
                updated_at: row.get::<_, String>(5)?.parse().unwrap(),
            })
        })
        .map_err(|e| e.to_string())?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| e.to_string())?;
    
    Ok(notes)
}

#[tauri::command]
pub fn create_note(
    db: State<DbState>,
    title: String,
    content: String,
    category: String,
) -> Result<Note, String> {
    let conn = db.conn.lock().unwrap();
    
    let now = Utc::now();
    let note = Note {
        id: Uuid::new_v4().to_string(),
        title,
        content,
        category,
        created_at: now,
        updated_at: now,
    };
    
    conn.execute(
        "INSERT INTO notes (id, title, content, category, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        [
            &note.id,
            &note.title,
            &note.content,
            &note.category,
            &note.created_at.to_rfc3339(),
            &note.updated_at.to_rfc3339(),
        ],
    )
    .map_err(|e| e.to_string())?;
    
    Ok(note)
}

#[tauri::command]
pub fn update_note(
    db: State<DbState>,
    id: String,
    title: String,
    content: String,
    category: String,
) -> Result<Note, String> {
    let conn = db.conn.lock().unwrap();
    
    let now = Utc::now();
    
    conn.execute(
        "UPDATE notes SET title = ?1, content = ?2, category = ?3, updated_at = ?4 WHERE id = ?5",
        [
            &title,
            &content,
            &category,
            &now.to_rfc3339(),
            &id,
        ],
    )
    .map_err(|e| e.to_string())?;
    
    get_note_by_id(db, id)
}

#[tauri::command]
pub fn delete_note(db: State<DbState>, id: String) -> Result<(), String> {
    let conn = db.conn.lock().unwrap();
    
    conn.execute("DELETE FROM notes WHERE id = ?1", [&id])
        .map_err(|e| e.to_string())?;
    
    Ok(())
}

#[tauri::command]
pub fn search_notes(db: State<DbState>, query: String) -> Result<Vec<Note>, String> {
    let conn = db.conn.lock().unwrap();
    
    let pattern = format!("%{}%", query);
    
    let mut stmt = conn
        .prepare("SELECT id, title, content, category, created_at, updated_at FROM notes WHERE title LIKE ?1 OR content LIKE ?1 ORDER BY updated_at DESC")
        .map_err(|e| e.to_string())?;
    
    let notes = stmt
        .query_map([&pattern], |row| {
            Ok(Note {
                id: row.get(0)?,
                title: row.get(1)?,
                content: row.get(2)?,
                category: row.get(3)?,
                created_at: row.get::<_, String>(4)?.parse().unwrap(),
                updated_at: row.get::<_, String>(5)?.parse().unwrap(),
            })
        })
        .map_err(|e| e.to_string())?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| e.to_string())?;
    
    Ok(notes)
}

fn get_note_by_id(db: State<DbState>, id: String) -> Result<Note, String> {
    let conn = db.conn.lock().unwrap();
    
    conn.query_row(
        "SELECT id, title, content, category, created_at, updated_at FROM notes WHERE id = ?1",
        [&id],
        |row| {
            Ok(Note {
                id: row.get(0)?,
                title: row.get(1)?,
                content: row.get(2)?,
                category: row.get(3)?,
                created_at: row.get::<_, String>(4)?.parse().unwrap(),
                updated_at: row.get::<_, String>(5)?.parse().unwrap(),
            })
        },
    )
    .map_err(|e| e.to_string())
}

3.4 主入口 #

rust
// src-tauri/src/lib.rs
mod commands;
mod models;

use commands::notes::{init_db, DbState};
use rusqlite::Connection;
use tauri::Manager;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let conn = Connection::open_in_memory().expect("Failed to open database");
    init_db(&conn).expect("Failed to initialize database");
    
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_notification::init())
        .manage(DbState {
            conn: std::sync::Mutex::new(conn),
        })
        .invoke_handler(tauri::generate_handler![
            commands::notes::get_all_notes,
            commands::notes::create_note,
            commands::notes::update_note,
            commands::notes::delete_note,
            commands::notes::search_notes,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

四、前端实现 #

4.1 类型定义 #

typescript
// src/types/index.ts
export interface Note {
    id: string;
    title: string;
    content: string;
    category: string;
    createdAt: string;
    updatedAt: string;
}

export interface Category {
    id: string;
    name: string;
    color: string;
}

4.2 状态管理 #

typescript
// src/stores/noteStore.ts
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import type { Note } from '../types';

interface NoteState {
    notes: Note[];
    currentNote: Note | null;
    loading: boolean;
    error: string | null;
    
    loadNotes: () => Promise<void>;
    createNote: (title: string, content: string, category: string) => Promise<Note>;
    updateNote: (id: string, title: string, content: string, category: string) => Promise<Note>;
    deleteNote: (id: string) => Promise<void>;
    searchNotes: (query: string) => Promise<void>;
    setCurrentNote: (note: Note | null) => void;
}

export const useNoteStore = create<NoteState>((set, get) => ({
    notes: [],
    currentNote: null,
    loading: false,
    error: null,
    
    loadNotes: async () => {
        set({ loading: true, error: null });
        try {
            const notes = await invoke<Note[]>('get_all_notes');
            set({ notes, loading: false });
        } catch (error) {
            set({ error: String(error), loading: false });
        }
    },
    
    createNote: async (title, content, category) => {
        const note = await invoke<Note>('create_note', { title, content, category });
        set((state) => ({ notes: [note, ...state.notes] }));
        return note;
    },
    
    updateNote: async (id, title, content, category) => {
        const note = await invoke<Note>('update_note', { id, title, content, category });
        set((state) => ({
            notes: state.notes.map((n) => (n.id === id ? note : n)),
            currentNote: state.currentNote?.id === id ? note : state.currentNote,
        }));
        return note;
    },
    
    deleteNote: async (id) => {
        await invoke('delete_note', { id });
        set((state) => ({
            notes: state.notes.filter((n) => n.id !== id),
            currentNote: state.currentNote?.id === id ? null : state.currentNote,
        }));
    },
    
    searchNotes: async (query) => {
        set({ loading: true, error: null });
        try {
            const notes = await invoke<Note[]>('search_notes', { query });
            set({ notes, loading: false });
        } catch (error) {
            set({ error: String(error), loading: false });
        }
    },
    
    setCurrentNote: (note) => set({ currentNote: note }),
}));

4.3 编辑器组件 #

tsx
// src/components/Editor.tsx
import { useState, useEffect } from 'react';
import { useNoteStore } from '../stores/noteStore';

export function Editor() {
    const { currentNote, updateNote } = useNoteStore();
    const [title, setTitle] = useState('');
    const [content, setContent] = useState('');
    const [saving, setSaving] = useState(false);

    useEffect(() => {
        if (currentNote) {
            setTitle(currentNote.title);
            setContent(currentNote.content);
        }
    }, [currentNote]);

    const handleSave = async () => {
        if (!currentNote) return;
        
        setSaving(true);
        try {
            await updateNote(currentNote.id, title, content, currentNote.category);
        } finally {
            setSaving(false);
        }
    };

    if (!currentNote) {
        return (
            <div className="flex items-center justify-center h-full text-gray-500">
                选择或创建一个笔记
            </div>
        );
    }

    return (
        <div className="h-full flex flex-col">
            <div className="p-4 border-b flex justify-between items-center">
                <input
                    type="text"
                    value={title}
                    onChange={(e) => setTitle(e.target.value)}
                    className="text-xl font-semibold border-none outline-none flex-1"
                    placeholder="笔记标题"
                />
                <button
                    onClick={handleSave}
                    disabled={saving}
                    className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
                >
                    {saving ? '保存中...' : '保存'}
                </button>
            </div>
            
            <textarea
                value={content}
                onChange={(e) => setContent(e.target.value)}
                className="flex-1 p-4 outline-none resize-none"
                placeholder="开始编写..."
            />
        </div>
    );
}

4.4 笔记列表组件 #

tsx
// src/components/NoteList.tsx
import { useNoteStore } from '../stores/noteStore';

export function NoteList() {
    const { notes, currentNote, setCurrentNote, deleteNote } = useNoteStore();

    const handleDelete = async (id: string, e: React.MouseEvent) => {
        e.stopPropagation();
        if (confirm('确定要删除这个笔记吗?')) {
            await deleteNote(id);
        }
    };

    return (
        <div className="h-full overflow-y-auto">
            {notes.map((note) => (
                <div
                    key={note.id}
                    onClick={() => setCurrentNote(note)}
                    className={`p-4 border-b cursor-pointer hover:bg-gray-100 ${
                        currentNote?.id === note.id ? 'bg-blue-50' : ''
                    }`}
                >
                    <div className="flex justify-between items-start">
                        <div className="flex-1">
                            <h3 className="font-medium truncate">{note.title || '无标题'}</h3>
                            <p className="text-sm text-gray-500 truncate">
                                {note.content || '无内容'}
                            </p>
                            <p className="text-xs text-gray-400 mt-1">
                                {new Date(note.updatedAt).toLocaleString()}
                            </p>
                        </div>
                        <button
                            onClick={(e) => handleDelete(note.id, e)}
                            className="text-red-500 hover:text-red-700"
                        >
                            删除
                        </button>
                    </div>
                </div>
            ))}
        </div>
    );
}

4.5 主应用组件 #

tsx
// src/App.tsx
import { useEffect, useState } from 'react';
import { useNoteStore } from './stores/noteStore';
import { Editor } from './components/Editor';
import { NoteList } from './components/NoteList';

function App() {
    const { loadNotes, createNote, setCurrentNote, searchNotes } = useNoteStore();
    const [searchQuery, setSearchQuery] = useState('');

    useEffect(() => {
        loadNotes();
    }, []);

    const handleCreateNote = async () => {
        const note = await createNote('新笔记', '', '默认');
        setCurrentNote(note);
    };

    const handleSearch = (query: string) => {
        setSearchQuery(query);
        if (query) {
            searchNotes(query);
        } else {
            loadNotes();
        }
    };

    return (
        <div className="h-screen flex">
            {/* 侧边栏 */}
            <div className="w-80 border-r flex flex-col">
                <div className="p-4 border-b">
                    <div className="flex gap-2 mb-4">
                        <input
                            type="text"
                            value={searchQuery}
                            onChange={(e) => handleSearch(e.target.value)}
                            className="flex-1 px-3 py-2 border rounded"
                            placeholder="搜索笔记..."
                        />
                        <button
                            onClick={handleCreateNote}
                            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
                        >
                            新建
                        </button>
                    </div>
                </div>
                
                <NoteList />
            </div>
            
            {/* 编辑区 */}
            <div className="flex-1">
                <Editor />
            </div>
        </div>
    );
}

export default App;

五、功能增强 #

5.1 自动保存 #

typescript
// 使用防抖自动保存
import { debounce } from 'lodash-es';

const debouncedSave = debounce(async (id: string, title: string, content: string, category: string) => {
    await updateNote(id, title, content, category);
}, 1000);

// 在 Editor 组件中使用
useEffect(() => {
    if (currentNote && (title !== currentNote.title || content !== currentNote.content)) {
        debouncedSave(currentNote.id, title, content, currentNote.category);
    }
}, [title, content]);

5.2 系统托盘 #

rust
// 在 lib.rs 中添加托盘
use tauri::{
    menu::{Menu, MenuItem},
    tray::{TrayIcon, TrayIconBuilder},
};

fn create_tray(app: &tauri::AppHandle) -> Result<TrayIcon, Box<dyn std::error::Error>> {
    let show = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
    let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
    
    let menu = Menu::with_items(app, &[&show, &quit])?;
    
    let tray = TrayIconBuilder::new()
        .icon(app.default_window_icon().unwrap().clone())
        .menu(&menu)
        .on_menu_event(|app, event| {
            match event.id.as_ref() {
                "show" => {
                    if let Some(window) = app.get_webview_window("main") {
                        let _ = window.show();
                        let _ = window.set_focus();
                    }
                }
                "quit" => {
                    app.exit(0);
                }
                _ => {}
            }
        })
        .build(app)?;
    
    Ok(tray)
}

六、总结 #

6.1 项目要点 #

要点 说明
项目结构 前后端分离
数据存储 SQLite 数据库
状态管理 Zustand
组件设计 模块化组件
功能增强 自动保存、托盘

6.2 扩展方向 #

  • Markdown 渲染
  • 标签系统
  • 导出功能
  • 云同步
  • 多语言支持

通过这个项目,你已经掌握了 Tauri 应用的完整开发流程,可以开始构建自己的桌面应用了!

最后更新:2026-03-28