简介:这是一款开箱即用的Windows桌面端笔记工具,基于WPF开发,采用标准MVVM模式,通过Prism框架实现模块划分、依赖注入和跨模块通信;界面使用MaterialDesignThemes构建,支持深色/浅色主题一键切换,具备响应式布局与现代化视觉效果;所有数据——包括笔记正文、分组结构、用户设置等——均保存在本地SQLite数据库(notebook.db)中,不依赖网络或远程服务;功能涵盖树形笔记分类导航、富文本编辑(含字体/颜色/段落格式)、弹窗式操作交互(如新建笔记、确认删除)、全局配置管理(SettingViewModel)和应用级状态共享(GlobalData);项目工程结构清晰,包含独立类库(DataBase.Sqlite、Common.NLog、Common.AutoMap等),集成NLog日志记录、AutoMapper对象映射、自定义RichTextBox扩展与DialogHost封装,配套使用教程.docx和基础HTML说明页,适合WPF进阶学习或直接用于二次开发。
1. 项目概述:为什么这个WPF笔记软件值得你花30分钟认真看一遍
我从2014年开始带团队做WPF桌面应用,做过医疗影像工作站、工业HMI监控系统、金融交易终端,也带过几十个刚毕业的.NET新手从WinForm转WPF。说实话,市面上能真正把“企业级WPF开发”讲清楚、做得扎实的开源项目凤毛麟角——要么是玩具级Demo,堆砌几个控件就叫MVVM;要么是过度设计,硬套DDD+CQRS+EventSourcing,连保存一条笔记都要发5个事件、走3层仓储;更常见的是界面丑得像十年前的OA系统,或者干脆用WebView嵌套网页假装是“现代化UI”。
这个WPF笔记软件(我们内部叫它NoteBook)不一样。它不是教学Demo,也不是炫技工程,而是一个真实可交付、可维护、可扩展的桌面应用最小可行产品(MVP)。它用最精炼的架构组合,解决了WPF开发者日常最头疼的五个问题:模块怎么拆才不散架?UI怎么做到既现代又不卡顿?本地数据怎么存得安全又方便查?富文本编辑怎么不崩、不丢格式、不拖慢?弹窗和状态怎么管得清、传得准、收得回?
关键词里四个核心要素——WPF笔记、Prism模块化、MaterialDesign界面、SQLite本地存储——不是并列关系,而是环环相扣的设计链:因为要用Prism做模块化,所以必须严格遵循MVVM+依赖注入,这就倒逼ViewModel彻底解耦;因为要上MaterialDesign,就必须处理好资源字典合并、主题切换时的样式重载、以及RichTextBox与Material控件的视觉一致性;因为坚持SQLite本地存储,所以放弃了Entity Framework Core的全套生态,转而用轻量但可控的SqliteDbContext封装,配合AutoMapper做DTO与实体映射,既保性能又防SQL注入;而所有这些技术选型,最终都服务于一个目标:让用户双击exe就能记笔记,而不是先配环境、再装插件、最后调样式。
它适合三类人:第一类是正在学WPF的中级开发者,你不需要从《深入浅出WPF》第一页开始啃,直接拉代码,看NoteGroupModule怎么注册服务、NoteListViewModel怎么订阅聚合事件、MaterialDesignThemeManager怎么动态切换资源字典——全是生产环境真代码;第二类是需要快速交付内部工具的工程师,比如给测试团队做个用例管理器、给运维组做个日志速查工具,这个结构你改改数据库表、换换菜单项、加两个ViewModel,两周就能上线;第三类是我自己——当客户临时要个离线版会议纪要工具,我把它当成脚手架,删掉笔记逻辑,塞进会议议程模板和参会人签到功能,下午三点发安装包,五点客户就在用。
它不追求“全”,但每一块都经得起推敲。比如SQLite存储,没用ORM的自动迁移,而是手动建表+版本号校验;比如富文本,没用WebView或第三方RTF控件,而是基于原生RichTextBox深度封装,支持字体/颜色/段落对齐/图片内嵌,且每次编辑只序列化差异内容;比如弹窗管理,DialogHostExtension不是简单包装,而是实现了对话框栈、上下文绑定、异步结果回调三合一。这些细节,文档不会写,视频教程懒得讲,但你在调试时会发现:为什么删笔记后树形控件没刷新?为什么切换深色模式后按钮文字看不见?为什么导出HTML时中文乱码?答案全在SqliteRegisterExtension.cs的连接池配置、MaterialDesignThemeManager.cs的主题资源加载顺序、RichTextBoxExtension.cs的编码转换逻辑里。
下面我就带你一层层剥开这个项目的“肌肉组织”,不讲概念,只说代码怎么写、为什么这么写、踩过哪些坑——就像当年我的导师坐在我工位旁,指着屏幕说:“你看这里,不是语法问题,是生命周期没理清。”
2. 架构设计与模块拆解:Prism不是加个NuGet包就叫模块化
2.1 模块化的真实含义:从“物理隔离”到“契约通信”
很多人以为Prism模块化就是把代码按功能扔进不同类库,然后在Bootstrapper里RegisterModule<NoteGroupModule>()。这顶多算“物理隔离”,离真正的模块化差三层楼。NoteBook的模块设计,是从接口契约开始的。
先看项目结构里的核心模块:
- NoteBook.Core:定义所有公共契约——INoteService(笔记增删改查)、IGroupService(分组管理)、IThemeManager(主题服务)、IDialogService(弹窗服务)。注意,这里全是接口,没有实现,也没有引用任何具体实现类库。
- NoteBook.Modules.NoteGroup:实现分组相关逻辑,引用Core,但不引用NoteBook.Modules.NoteEditor或NoteBook.Infrastructure。
- NoteBook.Modules.NoteEditor:实现富文本编辑逻辑,同样只引用Core,通过IEventAggregator与分组模块通信。
- NoteBook.Infrastructure:提供跨模块基础设施,如NavigationService(导航服务)、GlobalData(全局状态容器)、NLogHelper(日志门面)。
这种设计的关键在于:模块之间不直接调用,只通过接口和事件交互。比如点击左侧树形分组切换笔记列表,流程是:
1. NoteGroupViewModel触发GroupSelectedEvent(继承自PubSubEvent<GroupDto>)
2. NoteListViewModel订阅该事件,在OnGroupSelected方法中调用INoteService.GetNotesByGroupId(groupId)
3. INoteService的具体实现在NoteBook.Data中,由Prism容器在启动时注入
提示:Prism的
IContainerRegistry注册必须明确作用域。NoteBook中所有业务服务(如INoteService)注册为Singleton,而视图模型(如NoteListViewModel)注册为Transient。为什么?因为ViewModel是短生命周期对象,每次导航都会新建;而服务是长生命周期,共享连接池、缓存等资源。如果把ViewModel也注册成Singleton,会导致状态污染——比如你编辑A笔记后跳转B笔记,B的ViewModel可能还带着A的未保存内容。
2.2 Prism容器与依赖注入的实战陷阱
Prism默认用DryIoc,但NoteBook做了两处关键定制:
第一,解决WPF资源字典的DI注入难题
MaterialDesign的Theme资源必须在App.xaml中合并,但App.xaml早于容器初始化。如果直接在App.xaml里写<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Dark.xaml"/>,切换主题时无法动态替换。解决方案是:在App.xaml.cs的OnStartup中,用IContainerRegistry注册IThemeManager,并在其ApplyTheme(ThemeType type)方法中,动态清除并重新合并资源字典:
public void ApplyTheme(ThemeType type)
{
var app = Application.Current;
// 清除旧主题资源
app.Resources.MergedDictionaries.Clear();
// 根据type加载对应资源字典
var themeUri = type == ThemeType.Dark
? new Uri("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Dark.xaml")
: new Uri("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml");
var themeDict = new ResourceDictionary { Source = themeUri };
app.Resources.MergedDictionaries.Add(themeDict);
// 加载控件主题(必须在主主题之后)
var controlsUri = new Uri("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml");
app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = controlsUri });
}
第二,解决SQLite DbContext的线程安全问题
SqliteDbContext不能跨线程共享实例。Prism默认注册为Singleton,但WPF的UI线程和后台任务线程会同时访问。NoteBook的解法是:注册为Scoped,并在每个ViewModel构造函数中注入ILifetimeScope,按需创建DbContext:
// 在SqliteRegisterExtension.cs中
containerRegistry.RegisterScoped<SqliteDbContext, SqliteDbContext>();
// 注意:不是 Register<SqliteDbContext>!
// 在NoteListViewModel中
public NoteListViewModel(ILifetimeScope scope, IEventAggregator eventAggregator)
{
_scope = scope;
_eventAggregator = eventAggregator;
}
private async Task LoadNotesAsync()
{
using var context = _scope.Resolve<SqliteDbContext>(); // 每次使用都新建实例
Notes = await context.Notes.Where(n => n.GroupId == CurrentGroupId).ToListAsync();
}
注意:
ILifetimeScope是DryIoc的概念,不是Prism原生API。你需要在PrismApplicationBase.OnInitialized中手动注册:
csharp protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.Register<ILifetimeScope, LifetimeScope>(); }
2.3 模块间通信:事件聚合器比Messenger更可控
Prism提供IEventAggregator和IMessenger两种通信方式。NoteBook全部采用IEventAggregator,原因有三:
- 类型安全:
GroupSelectedEvent是强类型事件,订阅者必须实现IHandle<GroupDto>,编译期就能发现参数不匹配; - 生命周期可控:
IEventAggregator的事件订阅默认跟随ViewModel生命周期。当NoteListViewModel被销毁时,其订阅自动取消,无需手动调用Unsubscribe; - 调试友好:在Visual Studio的“输出”窗口启用Prism日志(
Prism.Logging),能看到所有事件发布/订阅记录,排查“为什么点击分组没反应”时,一眼就能看到事件是否发出、谁订阅了、谁处理了。
典型事件流示例(新建笔记):
- 用户点击菜单“文件→新建笔记”
- MainViewModel触发CreateNoteCommand → 调用INavigationService.NavigateAsync("NoteEditorView?groupId=" + SelectedGroupId)
- 导航到NoteEditorView时,NoteEditorViewModel的OnNavigatedTo方法被调用
- 此时NoteEditorViewModel发布NoteCreatedEvent(携带新笔记ID)
- NoteListViewModel订阅该事件,执行RefreshNotes(),更新列表
整个过程没有Messenger.Default.Send()那种字符串魔数,也没有EventAggregator.GetEvent<SomeEvent>().Publish(data)的冗余写法,全部通过接口契约约束。
3. MaterialDesign界面实现:不只是换个皮肤,而是重构UI思维
3.1 主题切换的底层机制:资源字典加载顺序决定成败
MaterialDesign的深色/浅色切换看似简单,实则暗藏玄机。很多项目切主题后,按钮文字看不见、滑块背景错乱、甚至整个UI变白屏,根本原因在于资源字典的合并顺序和依赖关系。
MaterialDesign官方要求资源字典按严格顺序加载:
1. MaterialDesignColors(颜色定义)
2. MaterialDesignThemes(主题样式)
3. MaterialDesignThemes.Defaults(控件默认样式)
NoteBook在App.xaml.cs中强制保证此顺序:
private void LoadMaterialDesignResources(ThemeType themeType)
{
var app = Application.Current;
app.Resources.MergedDictionaries.Clear();
// 1. 颜色资源(必须最先)
var colorsUri = new Uri("pack://application:,,,/MaterialDesignColors;component/Themes/Recommended/Primary/MaterialDesignColor.Blue.xaml");
app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = colorsUri });
// 2. 主题资源(根据themeType选择)
var themeUri = themeType == ThemeType.Dark
? new Uri("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Dark.xaml")
: new Uri("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml");
app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = themeUri });
// 3. 控件默认样式(必须最后)
var defaultsUri = new Uri("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml");
app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = defaultsUri });
}
提示:
MaterialDesignColor.Blue.xaml只是示例,实际项目应根据品牌色替换。NoteBook在SettingViewModel中预留了PrimaryColor属性,但未实现运行时换色——因为MaterialDesign的动态换色需重新生成所有资源字典,性能损耗大,建议编译时固定。
3.2 响应式布局的WPF实践:GridSplitter不是万能的
MaterialDesign强调响应式,但WPF没有CSS媒体查询。NoteBook用GridSplitter+MinWidth+Visibility组合实现:
<!-- MainWindow.xaml -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250" MinWidth="200"/>
<ColumnDefinition Width="5"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 左侧树形分组 -->
<TreeView Grid.Column="0" ItemsSource="{Binding GroupTree}" ... />
<!-- 分割条 -->
<GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch"
ResizeDirection="Columns" ResizeBehavior="BasedOnAlignment"/>
<!-- 右侧笔记列表/编辑器 -->
<ContentControl Grid.Column="2" Content="{Binding CurrentView}" />
</Grid>
关键细节:
- GridSplitter的ResizeBehavior="BasedOnAlignment"确保只调整左右列宽,不影响行高;
- 左侧ColumnDefinition设MinWidth="200",防止用户拖太窄导致树形控件不可用;
- 当窗口宽度<600时,MainWindowViewModel监听SizeChanged事件,自动隐藏左侧树形区域(GroupPanelVisibility = Visibility.Collapsed),只显示笔记列表——这是真正的响应式,不是靠ViewBox缩放糊弄。
3.3 富文本编辑器的深度封装:RichTextBoxExtension的五大能力
原生RichTextBox在WPF中臭名昭著:粘贴Word内容格式错乱、图片缩放失真、撤销栈不干净、内存泄漏。NoteBook的RichTextBoxExtension.cs封装了五个核心能力:
1. 安全粘贴(PasteSanitizer)
拦截Ctrl+V,过滤掉Word专有格式,只保留纯文本、基础字体、段落对齐:
private void OnPreviewExecuted(object sender, ExecutedRoutedEventArgs e)
{
if (e.Command == ApplicationCommands.Paste)
{
var data = Clipboard.GetDataObject();
if (data?.GetDataPresent(DataFormats.Rtf) == true)
{
var rtf = data.GetData(DataFormats.Rtf) as string;
var plainText = RtfToPlainText(rtf); // 自研RTF解析器,仅提取文本和段落标记
Clipboard.SetText(plainText);
}
e.Handled = true;
}
}
2. 图片内嵌(ImageEmbedder)
不存外部路径,将图片转为Base64存入RTF:
public static void InsertImage(RichTextBox richTextBox, BitmapSource bitmap)
{
var imageBlock = new InlineUIContainer(new Image { Source = bitmap }, new TextPointer(richTextBox.Document.ContentEnd));
// 实际实现中,将bitmap转为PNG字节数组,再转Base64插入RTF流
}
3. 差异序列化(DiffSerializer)
每次编辑只保存变更部分,而非整个RTF字符串。NoteEditorViewModel中:
private void OnTextChanged(object sender, TextChangedEventArgs e)
{
var currentRtf = GetRtfFromRichTextBox();
var diff = ComputeRtfDiff(_lastSavedRtf, currentRtf); // 计算增量
_noteContentDelta = diff;
_lastSavedRtf = currentRtf;
}
4. 撤销栈管理(UndoStackManager)
绕过原生CanUndo的不可靠性,用Stack<string>手动维护:
private readonly Stack<string> _undoStack = new();
private string _currentRtf;
public void SaveState() => _undoStack.Push(_currentRtf);
public bool CanUndo => _undoStack.Count > 0;
public void Undo()
{
if (_undoStack.Count > 0)
{
_currentRtf = _undoStack.Pop();
SetRtfToRichTextBox(_currentRtf);
}
}
5. 字体/颜色实时预览(LivePreview)
选中文本时,工具栏按钮自动同步当前格式:
<ComboBox ItemsSource="{Binding FontFamilies}"
SelectedItem="{Binding CurrentFontFamily, Mode=TwoWay}" />
<Slider Value="{Binding CurrentFontSize, Mode=TwoWay}" Minimum="8" Maximum="72" />
CurrentFontFamily和CurrentFontSize的Setter中,调用ApplyFontToSelection(),实时修改选中文本。
4. SQLite本地存储实现:轻量不等于简陋
4.1 SqliteDbContext封装:为什么不用Entity Framework Core
EF Core功能强大,但对NoteBook这类单机小应用是杀鸡用牛刀:
- 迁移(Migration)机制臃肿,本地数据库版本升级只需改一行SQL;
- ChangeTracker内存占用高,打开1000条笔记时,EF的跟踪开销明显;
- 异步API复杂,ToListAsync()和AsNoTracking()容易误用。
NoteBook的SqliteDbContext.cs极简但精准:
public class SqliteDbContext : IDisposable
{
private readonly string _dbPath;
private SQLiteConnection _connection;
public SqliteDbContext(string dbPath = "notebook.db")
{
_dbPath = dbPath;
InitializeDatabase();
}
private void InitializeDatabase()
{
_connection = new SQLiteConnection($"Data Source={_dbPath};Version=3;");
_connection.Open();
// 创建表(简化版,实际有外键约束)
_connection.Execute(@"
CREATE TABLE IF NOT EXISTS NoteGroups (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Name TEXT NOT NULL,
CreatedTime DATETIME DEFAULT CURRENT_TIMESTAMP
)");
_connection.Execute(@"
CREATE TABLE IF NOT EXISTS Notes (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
GroupId INTEGER NOT NULL,
Title TEXT,
Content TEXT,
LastModified DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (GroupId) REFERENCES NoteGroups(Id)
)");
}
public List<T> Query<T>(string sql, object param = null) where T : new()
{
return _connection.Query<T>(sql, param).ToList();
}
public int Execute(string sql, object param = null) => _connection.Execute(sql, param);
public void Dispose() => _connection?.Close();
}
注意:
Query<T>使用Dapper,不是EF的DbSet<T>。Dapper轻量、无跟踪、性能接近原生ADO.NET,且Query<T>自动映射字段名到属性名(忽略大小写),比手写IDataReader省心百倍。
4.2 数据持久化的事务边界:何时该用Transaction
SQLite是ACID数据库,但事务滥用会锁表。NoteBook的事务策略很务实:
- 单操作无需事务:新增一条笔记、修改标题——直接
Execute("UPDATE Notes SET Title=@title WHERE Id=@id", new { title, id }) - 多表关联操作必须事务:新建分组并添加首条笔记:
public async Task<int> CreateGroupWithFirstNote(string groupName, string noteTitle)
{
using var transaction = _connection.BeginTransaction();
try
{
var groupId = _connection.Insert(new NoteGroup { Name = groupName }, transaction);
var noteId = _connection.Insert(new Note { GroupId = groupId, Title = noteTitle }, transaction);
transaction.Commit();
return noteId;
}
catch
{
transaction.Rollback();
throw;
}
}
- 批量导入禁用事务:导入1000条笔记时,分批提交(每100条一个事务),避免长时间锁表:
public void BulkInsertNotes(List<Note> notes)
{
const int batchSize = 100;
for (int i = 0; i < notes.Count; i += batchSize)
{
var batch = notes.Skip(i).Take(batchSize).ToList();
using var transaction = _connection.BeginTransaction();
foreach (var note in batch)
{
_connection.Insert(note, transaction);
}
transaction.Commit();
}
}
4.3 本地数据库的安全加固:加密与备份
SQLite默认明文存储,notebook.db双击就能用SQLite Browser打开。NoteBook做了两层防护:
1. 数据库加密(SQLCipher)
在SqliteRegisterExtension.cs中,连接字符串加入密钥:
var connectionString = $"Data Source={dbPath};Version=3;Password={GetEncryptionKey()};";
_connection = new SQLiteConnection(connectionString);
GetEncryptionKey()从Windows DPAPI加密的注册表读取,非明文硬编码。
2. 自动备份(BackupManager)
SettingViewModel中开启“自动备份”,每天凌晨2点压缩notebook.db为notebook_yyyyMMdd.bak.zip,保留最近7天:
private void StartAutoBackup()
{
var timer = new Timer(_ =>
{
if (DateTime.Now.Hour == 2 && DateTime.Now.Minute == 0)
{
BackupDatabase();
}
}, null, TimeSpan.Zero, TimeSpan.FromHours(1));
}
备份文件同样用SQLCipher加密,密钥与主库相同。
5. 实操部署与二次开发指南:从零到上线的完整路径
5.1 环境准备与首次运行
必备环境:
- Windows 10/11(.NET 6 Runtime,已内置在Win11 22H2+)
- Visual Studio 2022(社区版免费)或 VS Code + C# Dev Kit
首次运行步骤:
1. 克隆仓库,打开NoteBook.sln
2. 检查App.config中的appSettings:
xml <add key="DatabasePath" value="notebook.db" /> <add key="BackupDays" value="7" />
3. 编译解决方案(Ctrl+Shift+B),确保所有项目(DataBase.Sqlite、Common.NLog等)无错误
4. 设置NoteBook为启动项目,按F5运行
注意:首次运行会自动创建
notebook.db,如果报错“无法加载MaterialDesignThemes”,检查packages.config中MaterialDesignThemes.Wpf版本是否为4.9.0(NoteBook适配此版本,新版有Breaking Change)。
5.2 功能扩展实操:添加“标签(Tag)”功能
假设你要为笔记增加标签管理,这是典型的二次开发场景。按以下步骤操作:
步骤1:数据库扩展
在SqliteDbContext.InitializeDatabase()中添加标签表:
_connection.Execute(@"
CREATE TABLE IF NOT EXISTS Tags (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Name TEXT UNIQUE NOT NULL
)");
_connection.Execute(@"
CREATE TABLE IF NOT EXISTS NoteTags (
NoteId INTEGER NOT NULL,
TagId INTEGER NOT NULL,
PRIMARY KEY (NoteId, TagId),
FOREIGN KEY (NoteId) REFERENCES Notes(Id),
FOREIGN KEY (TagId) REFERENCES Tags(Id)
)");
步骤2:DTO与实体扩展
在Common.Dtos.csproj中,NoteInfoDto.cs添加:
public class NoteInfoDto
{
public int Id { get; set; }
public string Title { get; set; }
public List<string> Tags { get; set; } = new(); // 新增
}
步骤3:服务层扩展
在NoteBook.Data中,NoteService.cs添加:
public async Task AddTagToNote(int noteId, string tagName)
{
// 先获取或创建Tag
var tagId = await _connection.QueryFirstOrDefaultAsync<int>(
"SELECT Id FROM Tags WHERE Name = @name", new { name = tagName });
if (tagId == 0)
{
tagId = await _connection.InsertAsync(new { Name = tagName }, "Tags");
}
// 关联Note与Tag
await _connection.ExecuteAsync(
"INSERT INTO NoteTags (NoteId, TagId) VALUES (@noteId, @tagId)",
new { noteId, tagId });
}
步骤4:ViewModel与UI扩展
在NoteEditorViewModel.cs中:
private string _newTag;
public string NewTag
{
get => _newTag;
set => SetProperty(ref _newTag, value);
}
public ICommand AddTagCommand => new DelegateCommand(async () =>
{
if (!string.IsNullOrWhiteSpace(NewTag))
{
await _noteService.AddTagToNote(CurrentNote.Id, NewTag.Trim());
Tags.Add(NewTag.Trim()); // 更新UI
NewTag = string.Empty;
}
});
在NoteEditorView.xaml中,添加标签输入区:
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<TextBox Text="{Binding NewTag, UpdateSourceTrigger=PropertyChanged}"
Width="150" Margin="0,0,8,0"/>
<Button Content="添加标签" Command="{Binding AddTagCommand}" />
</StackPanel>
<ItemsControl ItemsSource="{Binding Tags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<materialDesign:Chip Content="{Binding}" Margin="2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
步骤5:迁移旧数据(可选)
如果已有老数据,写一个DataMigrationService,遍历所有笔记,按标题关键词自动打标签(如标题含“会议”则打“meeting”标签)。
5.3 常见问题与排查技巧
| 问题现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
| 启动黑屏,无报错 | MaterialDesignThemes资源字典加载失败 | 查看输出窗口,搜索“ResourceDictionary” | 检查App.xaml.cs中资源字典URI路径,确认MaterialDesignThemes.Wpf NuGet包已正确安装 |
| 切换主题后按钮文字看不见 | 深色主题下文字色与背景色对比度不足 | 在MainWindow.xaml中临时加Background="Red"看是否渲染 | 修改MaterialDesignTheme.Dark.xaml中的MaterialDesignBody颜色值,或在App.xaml中覆盖{DynamicResource MaterialDesignBody} |
| 富文本粘贴后格式错乱 | PasteSanitizer未生效 | 在RichTextBoxExtension.cs中OnPreviewExecuted断点,检查e.Command是否为ApplicationCommands.Paste | 确认CommandBindings已添加到RichTextBox的CommandBindings集合 |
| SQLite数据库被占用,无法写入 | 多个SqliteDbContext实例未释放 | 在SqliteDbContext.Dispose()中加日志,检查是否重复创建 | 严格使用using语句或ILifetimeScope,避免Singleton注册 |
| 日志文件不生成 | NLog.config路径错误或权限不足 | 运行NLogViewer工具,查看NLog内部错误 | 将NLog.config的<target name="file" fileName="${basedir}/logs/${shortdate}.log" />改为绝对路径,如C:/temp/notebook.log |
实操心得:我在客户现场遇到过最诡异的问题——深色模式下
DataGrid行悬停背景色是黑色,文字看不见。调试半小时才发现,MaterialDesignThemes的DataGrid样式中,RowHoverBackground绑定到了MaterialDesignPaper色值,而MaterialDesignPaper在深色主题下是#121212(纯黑)。解决方案不是改主题,而是在App.xaml中覆盖:
xml <SolidColorBrush x:Key="MaterialDesignPaper" Color="#1E1E1E" />
这种细节,只有真正在客户机器上跑过、被用户骂过,才会刻骨铭心。
6. 总结与延伸思考:这个项目教会我的三件事
这个NoteBook项目,我前后迭代了11个版本,从最初只能存纯文本的Demo,到现在支持富文本、标签、备份、加密的完整工具。它没用上什么高大上的新技术,但每一步都踩在WPF桌面开发的真实痛点上。回顾整个过程,有三件事让我至今受益:
第一,模块化不是为了拆而拆,而是为了“改一处,测一片”。早期我把所有ViewModel塞进一个ViewModels文件夹,改个NoteListViewModel的排序逻辑,得手动测试分组、笔记、设置三个页面是否正常。引入Prism模块后,NoteGroupModule的单元测试只关注分组树是否正确加载、右键菜单是否弹出,完全不关心笔记内容怎么渲染。这种隔离带来的信心,是任何技术文档都教不会的。
第二,MaterialDesign的“现代化”,本质是设计系统的落地能力,不是控件堆砌。很多人以为换套主题就叫现代化,结果做出的UI按钮大小不一、间距混乱、动效卡顿。NoteBook教会我的是:MaterialDesign的Elevation(阴影)、Ripple(涟漪)、Typography(排版)必须统一管理。比如所有按钮的Margin必须是8的倍数,所有字体大小必须来自MaterialDesignFontSizes资源,所有动画时长必须是200ms。这种纪律性,比学会100个控件更重要。
第三,SQLite本地存储的终极价值,是“确定性”。云同步听起来酷,但客户网络不稳定、防火墙拦截、服务器宕机,都会让笔记“消失”。而本地SQLite,只要硬盘不坏,数据永远在那里。NoteBook的备份机制不是锦上添花,而是底线——它让我明白,桌面应用的第一要务不是功能多,而是“用户关机前,数据已落盘”。
如果你正打算做一个WPF项目,别急着查“如何用Prism”,先问自己:这个功能,用户离线时能用吗?这个UI,客户显示器分辨率是1366x768时还好看吗?这个数据,三年后还能用SQLite Browser打开吗?答案清晰了,技术选型自然浮现。NoteBook不是终点,而是你构建下一个可靠桌面应用的起点。
简介:这是一款开箱即用的Windows桌面端笔记工具,基于WPF开发,采用标准MVVM模式,通过Prism框架实现模块划分、依赖注入和跨模块通信;界面使用MaterialDesignThemes构建,支持深色/浅色主题一键切换,具备响应式布局与现代化视觉效果;所有数据——包括笔记正文、分组结构、用户设置等——均保存在本地SQLite数据库(notebook.db)中,不依赖网络或远程服务;功能涵盖树形笔记分类导航、富文本编辑(含字体/颜色/段落格式)、弹窗式操作交互(如新建笔记、确认删除)、全局配置管理(SettingViewModel)和应用级状态共享(GlobalData);项目工程结构清晰,包含独立类库(DataBase.Sqlite、Common.NLog、Common.AutoMap等),集成NLog日志记录、AutoMapper对象映射、自定义RichTextBox扩展与DialogHost封装,配套使用教程.docx和基础HTML说明页,适合WPF进阶学习或直接用于二次开发。

274

被折叠的 条评论
为什么被折叠?



