Tauri 实战项目 - 从零开始构建 Todo 应用
前言
通过前面几篇文章,我们已经学习了 Tauri 的基础知识、窗口管理和通信机制。现在,是时候把这些知识结合起来,开发一个完整的应用了。
今天,我们将从零开始构建一个功能完整的 Todo 应用,包括:
- ✅ 添加、编辑、删除待办事项
- ✅ 标记完成状态
- ✅ 数据持久化存储
- ✅ 分类和筛选
- ✅ 系统托盘集成
- ✅ 优雅的用户界面
这是一个非常实用的练手项目,涵盖了 Tauri 开发的大部分核心功能。
项目规划
功能需求
核心功能:
├── 任务管理
│ ├── 添加任务
│ ├── 编辑任务
│ ├── 删除任务
│ └── 标记完成
├── 任务分类
│ ├── 个人
│ ├── 工作
│ └── 学习
├── 数据持久化
│ └── 本地文件存储
└── 系统集成
├── 系统托盘
└── 快捷键技术栈
前端:
- React + TypeScript
- CSS Modules
- Tauri API
后端:
- Rust
- Serde (序列化/反序列化)
- 文件系统操作创建项目
bash
# 创建 Tauri + React + TypeScript 项目
npm create tauri-app@latest
# 选择配置:
# Project name: tauri-todo-app
# Frontend: TypeScript / JavaScript → TypeScript
# Package manager: npm
# UI Template: React
# UI Flavor: TypeScript
cd tauri-todo-app
npm install数据模型设计
定义数据结构
创建 src-tauri/src/models.rs:
rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo {
pub id: String,
pub title: String,
pub description: String,
pub completed: bool,
pub category: Category,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Category {
Personal,
Work,
Study,
}
impl Category {
pub fn to_string(&self) -> &str {
match self {
Category::Personal => "personal",
Category::Work => "work",
Category::Study => "study",
}
}
}
impl Default for Todo {
fn default() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
title: String::new(),
description: String::new(),
completed: false,
category: Category::Personal,
created_at: chrono::Utc::now().timestamp_millis(),
updated_at: chrono::Utc::now().timestamp_millis(),
}
}
}添加依赖
编辑 src-tauri/Cargo.toml:
toml
[dependencies]
tauri = { version = "1.5", features = ["shell-open"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.6", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }后端实现
数据存储服务
创建 src-tauri/src/storage.rs:
rust
use std::fs;
use std::path::PathBuf;
use crate::models::Todo;
pub struct Storage {
file_path: PathBuf,
}
impl Storage {
pub fn new(app_data_dir: PathBuf) -> Result<Self, String> {
// 确保数据目录存在
fs::create_dir_all(&app_data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let file_path = app_data_dir.join("todos.json");
Ok(Self { file_path })
}
pub fn load_todos(&self) -> Result<Vec<Todo>, String> {
if !self.file_path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&self.file_path)
.map_err(|e| format!("Failed to read file: {}", e))?;
let todos: Vec<Todo> = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
Ok(todos)
}
pub fn save_todos(&self, todos: &Vec<Todo>) -> Result<(), String> {
let json = serde_json::to_string_pretty(todos)
.map_err(|e| format!("Failed to serialize: {}", e))?;
fs::write(&self.file_path, json)
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(())
}
}命令实现
创建 src-tauri/src/commands.rs:
rust
use tauri::State;
use std::sync::Mutex;
use crate::models::{Todo, Category};
use crate::storage::Storage;
pub struct AppState {
pub storage: Mutex<Storage>,
pub todos: Mutex<Vec<Todo>>,
}
#[tauri::command]
pub fn get_todos(state: State<AppState>) -> Result<Vec<Todo>, String> {
let todos = state.todos.lock().unwrap();
Ok(todos.clone())
}
#[tauri::command]
pub fn add_todo(
state: State<AppState>,
title: String,
description: String,
category: Category,
) -> Result<Todo, String> {
let mut todo = Todo {
title,
description,
category,
..Default::default()
};
let mut todos = state.todos.lock().unwrap();
todos.push(todo.clone());
// 保存到文件
let storage = state.storage.lock().unwrap();
storage.save_todos(&todos)?;
Ok(todo)
}
#[tauri::command]
pub fn update_todo(
state: State<AppState>,
id: String,
title: Option<String>,
description: Option<String>,
completed: Option<bool>,
category: Option<Category>,
) -> Result<Todo, String> {
let mut todos = state.todos.lock().unwrap();
let todo = todos
.iter_mut()
.find(|t| t.id == id)
.ok_or("Todo not found")?;
if let Some(title) = title {
todo.title = title;
}
if let Some(description) = description {
todo.description = description;
}
if let Some(completed) = completed {
todo.completed = completed;
}
if let Some(category) = category {
todo.category = category;
}
todo.updated_at = chrono::Utc::now().timestamp_millis();
let updated_todo = todo.clone();
// 保存到文件
let storage = state.storage.lock().unwrap();
storage.save_todos(&todos)?;
Ok(updated_todo)
}
#[tauri::command]
pub fn delete_todo(
state: State<AppState>,
id: String,
) -> Result<(), String> {
let mut todos = state.todos.lock().unwrap();
let index = todos
.iter()
.position(|t| t.id == id)
.ok_or("Todo not found")?;
todos.remove(index);
// 保存到文件
let storage = state.storage.lock().unwrap();
storage.save_todos(&todos)?;
Ok(())
}
#[tauri::command]
pub fn toggle_todo(
state: State<AppState>,
id: String,
) -> Result<Todo, String> {
let mut todos = state.todos.lock().unwrap();
let todo = todos
.iter_mut()
.find(|t| t.id == id)
.ok_or("Todo not found")?;
todo.completed = !todo.completed;
todo.updated_at = chrono::Utc::now().timestamp_millis();
let updated_todo = todo.clone();
// 保存到文件
let storage = state.storage.lock().unwrap();
storage.save_todos(&todos)?;
Ok(updated_todo)
}
#[tauri::command]
pub fn get_stats(state: State<AppState>) -> Result<TodoStats, String> {
let todos = state.todos.lock().unwrap();
let total = todos.len();
let completed = todos.iter().filter(|t| t.completed).count();
let pending = total - completed;
Ok(TodoStats {
total,
completed,
pending,
})
}
#[derive(serde::Serialize)]
pub struct TodoStats {
total: usize,
completed: usize,
pending: usize,
}主文件配置
编辑 src-tauri/src/main.rs:
rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod models;
mod storage;
mod commands;
use tauri::Manager;
use std::sync::Mutex;
use commands::AppState;
fn main() {
tauri::Builder::default()
.setup(|app| {
// 获取应用数据目录
let app_data_dir = app.path_resolver()
.app_data_dir()
.expect("Failed to get app data dir");
// 初始化存储
let storage = storage::Storage::new(app_data_dir)?;
// 加载数据
let todos = storage.load_todos()?;
// 设置应用状态
app.manage(AppState {
storage: Mutex::new(storage),
todos: Mutex::new(todos),
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_todos,
commands::add_todo,
commands::update_todo,
commands::delete_todo,
commands::toggle_todo,
commands::get_stats,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}前端实现
类型定义
创建 src/types/todo.ts:
typescript
export interface Todo {
id: string;
title: string;
description: string;
completed: boolean;
category: Category;
createdAt: number;
updatedAt: number;
}
export type Category = 'Personal' | 'Work' | 'Study';
export interface TodoStats {
total: number;
completed: number;
pending: number;
}Tauri API 封装
创建 src/services/todoService.ts:
typescript
import { invoke } from '@tauri-apps/api/tauri';
import { Todo, Category, TodoStats } from '../types/todo';
export const todoService = {
async getTodos(): Promise<Todo[]> {
return await invoke('get_todos');
},
async addTodo(
title: string,
description: string,
category: Category
): Promise<Todo> {
return await invoke('add_todo', { title, description, category });
},
async updateTodo(
id: string,
updates: {
title?: string;
description?: string;
completed?: boolean;
category?: Category;
}
): Promise<Todo> {
return await invoke('update_todo', {
id,
...updates,
});
},
async deleteTodo(id: string): Promise<void> {
return await invoke('delete_todo', { id });
},
async toggleTodo(id: string): Promise<Todo> {
return await invoke('toggle_todo', { id });
},
async getStats(): Promise<TodoStats> {
return await invoke('get_stats');
},
};主应用组件
创建 src/App.tsx:
tsx
import React, { useState, useEffect } from 'react';
import { todoService } from './services/todoService';
import { Todo, Category, TodoStats } from './types/todo';
import TodoList from './components/TodoList';
import TodoForm from './components/TodoForm';
import Stats from './components/Stats';
import './App.css';
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [stats, setStats] = useState<TodoStats>({
total: 0,
completed: 0,
pending: 0,
});
const [filter, setFilter] = useState<'all' | 'pending' | 'completed'>('all');
const [categoryFilter, setCategoryFilter] = useState<Category | 'all'>('all');
useEffect(() => {
loadTodos();
}, []);
const loadTodos = async () => {
try {
const data = await todoService.getTodos();
setTodos(data);
const statsData = await todoService.getStats();
setStats(statsData);
} catch (error) {
console.error('Failed to load todos:', error);
}
};
const handleAddTodo = async (
title: string,
description: string,
category: Category
) => {
try {
await todoService.addTodo(title, description, category);
await loadTodos();
} catch (error) {
console.error('Failed to add todo:', error);
}
};
const handleToggleTodo = async (id: string) => {
try {
await todoService.toggleTodo(id);
await loadTodos();
} catch (error) {
console.error('Failed to toggle todo:', error);
}
};
const handleDeleteTodo = async (id: string) => {
try {
await todoService.deleteTodo(id);
await loadTodos();
} catch (error) {
console.error('Failed to delete todo:', error);
}
};
const handleUpdateTodo = async (id: string, updates: Partial<Todo>) => {
try {
await todoService.updateTodo(id, updates);
await loadTodos();
} catch (error) {
console.error('Failed to update todo:', error);
}
};
const filteredTodos = todos.filter((todo) => {
// 状态筛选
if (filter === 'pending' && todo.completed) return false;
if (filter === 'completed' && !todo.completed) return false;
// 分类筛选
if (categoryFilter !== 'all' && todo.category !== categoryFilter) {
return false;
}
return true;
});
return (
<div className="app">
<header className="app-header">
<h1>📝 Todo App</h1>
<Stats stats={stats} />
</header>
<div className="app-content">
<TodoForm onSubmit={handleAddTodo} />
<div className="filters">
<div className="filter-group">
<label>Status:</label>
<button
className={filter === 'all' ? 'active' : ''}
onClick={() => setFilter('all')}
>
All ({stats.total})
</button>
<button
className={filter === 'pending' ? 'active' : ''}
onClick={() => setFilter('pending')}
>
Pending ({stats.pending})
</button>
<button
className={filter === 'completed' ? 'active' : ''}
onClick={() => setFilter('completed')}
>
Completed ({stats.completed})
</button>
</div>
<div className="filter-group">
<label>Category:</label>
<button
className={categoryFilter === 'all' ? 'active' : ''}
onClick={() => setCategoryFilter('all')}
>
All
</button>
<button
className={categoryFilter === 'Personal' ? 'active' : ''}
onClick={() => setCategoryFilter('Personal')}
>
Personal
</button>
<button
className={categoryFilter === 'Work' ? 'active' : ''}
onClick={() => setCategoryFilter('Work')}
>
Work
</button>
<button
className={categoryFilter === 'Study' ? 'active' : ''}
onClick={() => setCategoryFilter('Study')}
>
Study
</button>
</div>
</div>
<TodoList
todos={filteredTodos}
onToggle={handleToggleTodo}
onDelete={handleDeleteTodo}
onUpdate={handleUpdateTodo}
/>
</div>
</div>
);
}
export default App;TodoForm 组件
创建 src/components/TodoForm.tsx:
tsx
import React, { useState } from 'react';
import { Category } from '../types/todo';
import './TodoForm.css';
interface Props {
onSubmit: (title: string, description: string, category: Category) => void;
}
function TodoForm({ onSubmit }: Props) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState<Category>('Personal');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (title.trim()) {
onSubmit(title, description, category);
setTitle('');
setDescription('');
setCategory('Personal');
}
};
return (
<form className="todo-form" onSubmit={handleSubmit}>
<div className="form-group">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to be done?"
className="form-input"
required
/>
</div>
<div className="form-group">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
className="form-textarea"
rows={3}
/>
</div>
<div className="form-row">
<select
value={category}
onChange={(e) => setCategory(e.target.value as Category)}
className="form-select"
>
<option value="Personal">Personal</option>
<option value="Work">Work</option>
<option value="Study">Study</option>
</select>
<button type="submit" className="btn btn-primary">
Add Todo
</button>
</div>
</form>
);
}
export default TodoForm;TodoList 组件
创建 src/components/TodoList.tsx:
tsx
import React from 'react';
import { Todo } from '../types/todo';
import TodoItem from './TodoItem';
import './TodoList.css';
interface Props {
todos: Todo[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onUpdate: (id: string, updates: Partial<Todo>) => void;
}
function TodoList({ todos, onToggle, onDelete, onUpdate }: Props) {
if (todos.length === 0) {
return (
<div className="todo-list-empty">
<p>No todos yet. Add one to get started!</p>
</div>
);
}
return (
<div className="todo-list">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
onUpdate={onUpdate}
/>
))}
</div>
);
}
export default TodoList;TodoItem 组件
创建 src/components/TodoItem.tsx:
tsx
import React, { useState } from 'react';
import { Todo, Category } from '../types/todo';
import './TodoItem.css';
interface Props {
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onUpdate: (id: string, updates: Partial<Todo>) => void;
}
function TodoItem({ todo, onToggle, onDelete, onUpdate }: Props) {
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState(todo.title);
const [description, setDescription] = useState(todo.description);
const [category, setCategory] = useState(todo.category);
const handleSave = () => {
onUpdate(todo.id, { title, description, category });
setIsEditing(false);
};
const handleCancel = () => {
setTitle(todo.title);
setDescription(todo.description);
setCategory(todo.category);
setIsEditing(false);
};
const getCategoryColor = (cat: Category) => {
switch (cat) {
case 'Personal':
return '#4CAF50';
case 'Work':
return '#2196F3';
case 'Study':
return '#FF9800';
default:
return '#999';
}
};
if (isEditing) {
return (
<div className="todo-item editing">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="edit-input"
/>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="edit-textarea"
rows={3}
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value as Category)}
className="edit-select"
>
<option value="Personal">Personal</option>
<option value="Work">Work</option>
<option value="Study">Study</option>
</select>
<div className="edit-actions">
<button onClick={handleSave} className="btn btn-small btn-primary">
Save
</button>
<button onClick={handleCancel} className="btn btn-small">
Cancel
</button>
</div>
</div>
);
}
return (
<div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="todo-checkbox"
/>
<div className="todo-content">
<h3 className="todo-title">{todo.title}</h3>
{todo.description && (
<p className="todo-description">{todo.description}</p>
)}
<span
className="todo-category"
style={{ backgroundColor: getCategoryColor(todo.category) }}
>
{todo.category}
</span>
</div>
<div className="todo-actions">
<button
onClick={() => setIsEditing(true)}
className="btn btn-small btn-icon"
title="Edit"
>
✏️
</button>
<button
onClick={() => onDelete(todo.id)}
className="btn btn-small btn-icon btn-danger"
title="Delete"
>
🗑️
</button>
</div>
</div>
);
}
export default TodoItem;Stats 组件
创建 src/components/Stats.tsx:
tsx
import React from 'react';
import { TodoStats } from '../types/todo';
import './Stats.css';
interface Props {
stats: TodoStats;
}
function Stats({ stats }: Props) {
const completionRate = stats.total > 0
? Math.round((stats.completed / stats.total) * 100)
: 0;
return (
<div className="stats">
<div className="stat-item">
<span className="stat-label">Total:</span>
<span className="stat-value">{stats.total}</span>
</div>
<div className="stat-item">
<span className="stat-label">Completed:</span>
<span className="stat-value">{stats.completed}</span>
</div>
<div className="stat-item">
<span className="stat-label">Pending:</span>
<span className="stat-value">{stats.pending}</span>
</div>
<div className="stat-item">
<span className="stat-label">Progress:</span>
<span className="stat-value">{completionRate}%</span>
</div>
</div>
);
}
export default Stats;样式文件
创建 src/App.css:
css
.app {
min-height: 100vh;
background: #f5f5f5;
}
.app-header {
background: #fff;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
margin: 0 0 15px 0;
color: #333;
}
.app-content {
max-width: 800px;
margin: 30px auto;
padding: 0 20px;
}
.filters {
margin: 20px 0;
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
gap: 10px;
align-items: center;
}
.filter-group label {
font-weight: 600;
color: #666;
}
.filter-group button {
padding: 8px 16px;
border: 1px solid #ddd;
background: #fff;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.filter-group button:hover {
background: #f0f0f0;
}
.filter-group button.active {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #2196F3;
color: white;
}
.btn-primary:hover {
background: #1976D2;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
.btn-icon {
background: transparent;
border: 1px solid #ddd;
}
.btn-danger {
border-color: #f44336;
}
.btn-danger:hover {
background: #f44336;
color: white;
}运行和测试
bash
# 开发模式
npm run tauri dev
# 测试功能:
# 1. 添加不同分类的待办事项
# 2. 标记完成/未完成
# 3. 编辑待办事项
# 4. 删除待办事项
# 5. 使用筛选功能
# 6. 重启应用验证数据持久化打包应用
bash
# 构建生产版本
npm run tauri build
# 查看输出
# Windows: src-tauri/target/release/bundle/msi/
# macOS: src-tauri/target/release/bundle/dmg/
# Linux: src-tauri/target/release/bundle/deb/功能扩展建议
1. 搜索功能
typescript
const [searchQuery, setSearchQuery] = useState('');
const filteredTodos = todos.filter((todo) => {
// 搜索筛选
if (searchQuery && !todo.title.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
// ...其他筛选
return true;
});2. 排序功能
rust
#[tauri::command]
pub fn get_todos_sorted(
state: State<AppState>,
sort_by: String,
) -> Result<Vec<Todo>, String> {
let mut todos = state.todos.lock().unwrap().clone();
match sort_by.as_str() {
"created" => todos.sort_by_key(|t| t.created_at),
"updated" => todos.sort_by_key(|t| t.updated_at),
"title" => todos.sort_by(|a, b| a.title.cmp(&b.title)),
_ => {}
}
Ok(todos)
}3. 导入导出
rust
#[tauri::command]
pub fn export_todos(
state: State<AppState>,
path: String,
) -> Result<(), String> {
let todos = state.todos.lock().unwrap();
let json = serde_json::to_string_pretty(&*todos)
.map_err(|e| e.to_string())?;
std::fs::write(path, json)
.map_err(|e| e.to_string())?;
Ok(())
}4. 提醒功能
rust
// 使用 tauri-plugin-notification
use tauri::api::notification::Notification;
#[tauri::command]
pub fn send_reminder(app: tauri::AppHandle, todo: Todo) -> Result<(), String> {
Notification::new(&app.config().tauri.bundle.identifier)
.title("Todo Reminder")
.body(&format!("Don't forget: {}", todo.title))
.show()
.map_err(|e| e.to_string())?;
Ok(())
}总结
🎉 恭喜!你已经完成了一个功能完整的 Tauri Todo 应用!
✅ 学到的知识
项目架构
- 前后端分离设计
- 模块化代码组织
- 类型安全实践
数据管理
- 状态管理
- 数据持久化
- CRUD 操作
用户界面
- React 组件设计
- 样式管理
- 用户交互
Tauri 特性
- 命令系统
- 文件系统操作
- 跨平台打包
🎯 项目特点
- ✅ 体积小(约 5-8 MB)
- ✅ 启动快(< 1 秒)
- ✅ 内存占用低(< 50 MB)
- ✅ 跨平台支持
- ✅ 数据安全可靠
💡 改进方向
- 添加云同步
- 实现协作功能
- 添加标签系统
- 支持附件
- 主题定制
下一篇文章,我们将详细介绍 Tauri 应用的打包、签名和发布流程。
相关文章推荐:
项目源码:
有问题欢迎留言讨论,我会及时解答!