WPF桌面笔记软件:Prism模块化+MaterialDesign界面+SQLite本地存储

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:这是一款开箱即用的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模块化就是把代码按功能扔进不同类库,然后在BootstrapperRegisterModule<NoteGroupModule>()。这顶多算“物理隔离”,离真正的模块化差三层楼。NoteBook的模块设计,是从接口契约开始的。

先看项目结构里的核心模块:
- NoteBook.Core:定义所有公共契约——INoteService(笔记增删改查)、IGroupService(分组管理)、IThemeManager(主题服务)、IDialogService(弹窗服务)。注意,这里全是接口,没有实现,也没有引用任何具体实现类库。
- NoteBook.Modules.NoteGroup:实现分组相关逻辑,引用Core,但不引用NoteBook.Modules.NoteEditorNoteBook.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.csOnStartup中,用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提供IEventAggregatorIMessenger两种通信方式。NoteBook全部采用IEventAggregator,原因有三:

  1. 类型安全GroupSelectedEvent是强类型事件,订阅者必须实现IHandle<GroupDto>,编译期就能发现参数不匹配;
  2. 生命周期可控IEventAggregator的事件订阅默认跟随ViewModel生命周期。当NoteListViewModel被销毁时,其订阅自动取消,无需手动调用Unsubscribe
  3. 调试友好:在Visual Studio的“输出”窗口启用Prism日志(Prism.Logging),能看到所有事件发布/订阅记录,排查“为什么点击分组没反应”时,一眼就能看到事件是否发出、谁订阅了、谁处理了。

典型事件流示例(新建笔记):
- 用户点击菜单“文件→新建笔记”
- MainViewModel触发CreateNoteCommand → 调用INavigationService.NavigateAsync("NoteEditorView?groupId=" + SelectedGroupId)
- 导航到NoteEditorView时,NoteEditorViewModelOnNavigatedTo方法被调用
- 此时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>

关键细节:
- GridSplitterResizeBehavior="BasedOnAlignment"确保只调整左右列宽,不影响行高;
- 左侧ColumnDefinitionMinWidth="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" />

CurrentFontFamilyCurrentFontSize的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.dbnotebook_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.SqliteCommon.NLog等)无错误
4. 设置NoteBook为启动项目,按F5运行

注意:首次运行会自动创建notebook.db,如果报错“无法加载MaterialDesignThemes”,检查packages.configMaterialDesignThemes.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.csOnPreviewExecuted断点,检查e.Command是否为ApplicationCommands.Paste确认CommandBindings已添加到RichTextBoxCommandBindings集合
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行悬停背景色是黑色,文字看不见。调试半小时才发现,MaterialDesignThemesDataGrid样式中,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不是终点,而是你构建下一个可靠桌面应用的起点。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:这是一款开箱即用的Windows桌面端笔记工具,基于WPF开发,采用标准MVVM模式,通过Prism框架实现模块划分、依赖注入和跨模块通信;界面使用MaterialDesignThemes构建,支持深色/浅色主题一键切换,具备响应式布局与现代化视觉效果;所有数据——包括笔记正文、分组结构、用户设置等——均保存在本地SQLite数据库(notebook.db)中,不依赖网络或远程服务;功能涵盖树形笔记分类导航、富文本编辑(含字体/颜色/段落格式)、弹窗式操作交互(如新建笔记、确认删除)、全局配置管理(SettingViewModel)和应用级状态共享(GlobalData);项目工程结构清晰,包含独立类库(DataBase.Sqlite、Common.NLog、Common.AutoMap等),集成NLog日志记录、AutoMapper对象映射、自定义RichTextBox扩展与DialogHost封装,配套使用教程.docx和基础HTML说明页,适合WPF进阶学习或直接用于二次开发。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值