完整项目实战 #
一、项目概述 #
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)",
[
¬e.id,
¬e.title,
¬e.content,
¬e.category,
¬e.created_at.to_rfc3339(),
¬e.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