ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WPF企业OA系统开发实战:C#桌面端从MVVM到权限审批全解析

WPF企业OA系统开发实战:C#桌面端从MVVM到权限审批全解析 简介服务于企业办公自动化场景这份C# WPF企业OA系统源码采用C/S架构开发环境为Visual Studio 2012与SQL Server 2008 R2覆盖审批流程、计划管理、日程管理、考勤记录、劳资统计、人事客户管理、内置记事本等17个功能子模块适合需要参考完整桌面端OA实现的WPF开发者及相关课程设计。资源总计582个文件压缩包仅17.3MB以253个cs源码文件、38个xaml与74个baml界面描述文件、png/ico图标资源为主并包含sln工程、mdf/ldf数据库备份及dll依赖便于恢复数据库后直接编译运行或二次开发。已有790人学习下载可重点关注其多角色权限划分、审批工作流与考勤汇总逻辑模块化目录结构对理解企业级桌面应用分层也有参考价值。1. 为什么 WPF 仍是做企业 OA 桌面端的最稳选择很多人以为企业 OA 早就被网页端统一了但真去一线制造业、政企和金融单位看过就知道WPF 桌面端不仅没退场反而是很多核心业务台的唯一选择它能直接操作本地硬件扫码枪、U 盾、加密狗、能离线运行、能把手里的部门级 Demo 平滑升级成多部门协作的 OA 系统。C# 基于 WPF 的企业 OA 系统源码本质是在 .NET 生态里用桌面壳解决「审批流 数据台账 轻 BI」这三件事适合有 C# 基础、想用最低成本把内部流程线上化、又不想被前端三大框架拖住的团队。它解决的关键痛点很具体网页 OA 在弱网环境卡成幻灯片而 WPF 的离线优先级和数据绑定机制天然适合局域网办公同时 MVVM 模式让后期塞进「合同台账」「车辆管理」这类模块时不用把界面推倒重来。这篇笔记我会直接从模块拆分讲到数据绑定和 HTTP 通信再到权限和工作流的落地套路最后把我在权限绕过和 DataGrid 性能上踩过的坑一次性交代清楚。2. 把 OA 系统拆成四个能落地的模块别再一上来就画 ER 图2.1 登录认证与本地会话管理最常见的错误是只校验账号密码先说登录。新手抄的 OA 源码多半有个LoginWindow.xaml里面接了用户名密码框点按钮走一次SQLite查询就放行。这只能叫「窗口」不叫认证。我一般会加一道本地会话令牌把用户信息做成一个SessionContext单例登录成功后由主窗口持有而不是到处传参。public class SessionContext { public static SessionContext Current { get; private set; } public int UserId { get; private set; } public string UserName { get; private set; } public Liststring Roles { get; private set; } public DateTime LoginTime { get; private set; } public static void SignIn(int userId, string userName, Liststring roles) { Current new SessionContext { UserId userId, UserName userName, Roles roles, LoginTime DateTime.Now }; } public void SignOut() Current null; }这段代码背后有一个容易被忽略的细节SessionContext 里存的是「当前登录态」而不是每次查询数据库。这样后续任务中心、审批记录、待办红点都可以直接读内存不用每个窗口都开数据库连接。登录窗口校验完密码之后只做一件事调用SessionContext.SignIn(...)然后打开主窗体。这么做之后最明显的好处是将来接 LDAP 或企业微信扫码登录时只需要改SignIn之前的认证来源不用碰任何业务窗口的代码。参数上要注意Roles类型是Liststring它决定了下文要讲的权限过滤别用string[]定死长度。如果公司组织架构是「部门—岗位—用户」三层建议在 Roles 里同时塞入岗位代号和部门编码例如DEPT:FIN和ROLE:LEADER避免后期为了一个「部门负责人可见下属单子」的需求改表结构。2.2 权限模型先分清按钮级和路由级再做第一个 MenuItem权限是整个 OA 源码里最容易被事后追加的部分。常见做法是拿到用户 Roles 后在窗口构造时动态裁剪 MenuItem 列表。但有个前提菜单裁剪只是「界面隐藏」真正防绕过必须叠加 ViewModel 层的命令校验。否则用户用快捷键或反射调用命令照样能执行功能。public class PermissionService { public static bool HasPermission(string requiredPermission, Liststring roles) { // 超级管理员直接放行避免给 admin 配一堆奇怪角色 if (roles.Contains(SUPER_ADMIN)) return true; switch (requiredPermission) { case oa.approval.view: return roles.Contains(APPROVER) || roles.Contains(LEADER); case oa.approval.reject: return roles.Contains(APPROVER); case oa.user.manage: return roles.Contains(ADMIN); default: return false; } } }这段校验的写法刻意用了字符串常量而不是enum原因是 OA 系统的权限点会随模块扩展而不断增长enum每加一个值就要改一次编译单元而字符串常量可以直接在数据库权限表里维护。命令侧使用时类似这样var canReject PermissionService.HasPermission(oa.approval.reject, SessionContext.Current.Roles); if (!canReject) { // 这里不要直接 return记录日志后提示更安全 Logger.Warn($用户 {SessionContext.Current.UserName} 尝试驳回审批单 {approvalId}); MessageBox.Show(当前账号无权限执行此操作, 权限不足); return; }新手容易在这里犯一个既隐蔽又致命的问题只判断canReject却不记日志。等真出了「员工偷偷驳回领导审批单」的劳动争议时系统拿不出任何审计证据。合规的 OA 必须做到「每个危险动作都有日志」哪怕这句日志只是写入本地 SQLite 的OperationLog表也比没有强。2.3 审批流引擎用有限状态机代替画流程图的冲动审批流是 OA 系统的灵魂但也是最容易过早设计复杂化的地方。很多源码一上来就引入WorkflowEngine、Activity、Transition一堆类最后写了两周连「部门经理审批 → 财务复核 → 归档」都没跑通。我建议第一步先用有限状态机把单子状态管住Draft - Pending - Approved - Rejected - Archived。public class ApprovalStateMachine { private static readonly Dictionarystring, Liststring Transitions new() { [Draft] new() { Submit }, [Submitted] new() { Approve, Reject }, [Approved] new() { Archive }, [Rejected] new() { Resubmit }, [Archived] new() { } }; public static bool CanTransit(string currentState, string action) { return Transitions.ContainsKey(currentState) Transitions[currentState].Contains(action); } }这里Transitions字典就是一个最精简的有限状态机表。每条 { 状态, 动作 } 就是一条规则规则越少越不容易写出「已归档的单子还能被驳回」这种 bug。当后续业务要求「部门经理审批后还要分管副总审批」只需要把状态从Pending拆成PendingDept、PendingVp再往Transitions里加两行即可不用动现有代码的骨架。对应到ApprovalService核心方法长这样public bool Approve(int approvalId, string action, string comment) { var current _repo.GetCurrentState(approvalId); if (!ApprovalStateMachine.CanTransit(current, action)) { _logger.Warn($非法状态流转: {current} - {action}, 单据ID{approvalId}); return false; } var newState action switch { Approve Approved, Reject Rejected, Archive Archived, _ current }; _repo.UpdateState(approvalId, newState, comment); _logger.Info($审批单 {approvalId} 已从 {current} 流转到 {newState}); return true; }注意这个方法里没有写任何if (userRole ...)因为权限已经在 ViewModel 层拦过了这里只关心「状态能不能流转」。把这两个关注点拆开是 OA 源码从「能跑」到「能维护」的分水岭。2.4 待办与数据看板WPF 里最值得先做的不是图表控件待办中心的功能并不复杂就是「查询当前用户所有处于 Pending 的审批单」但有一个 WPF 特有的实现细节待办集合必须是可观察集合否则你补上一单界面半天不动。public ObservableCollectionApprovalItem TodoItems { get; set; } new(); public async Task LoadTodoAsync() { var list await _apiClient.GetTodoListAsync(SessionContext.Current.UserId); TodoItems.Clear(); foreach (var item in list) { TodoItems.Add(item); } }先把这段代码跑通再考虑图表。原因很简单WPF 的DataGrid绑定到ObservableCollection时增删会自动通知 UI而如果绑定到普通ListT你在后台线程清空再赋值界面不会刷新这就是典型的「我明明更新了数据但屏幕没反应」的翻车现场。很多新手抄完源码后第一句「为什么我的待办列表不刷新」都是栽在这里。3. 把 MVVM 数据绑定弄顺企业 OA 界面卡不卡全看这一步3.1 手写一个可靠的 ViewModel 基类别再依赖第三方框架开头WPF 的 MVVM 在 OA 场景里最大的价值不是代码分层好看而是让「界面」和「业务逻辑」可以分开测试。但如果你一开始就引入CommunityToolkit.Mvvm或Prism新手很容易被源生成器、容器等概念绕晕。更稳妥的路线是自己手写一个ViewModelBase代码就 20 行但每一步都知道在干什么public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected void SetPropertyT(ref T field, T value, [CallerMemberName] string? propertyName null) { if (!EqualityComparerT.Default.Equals(field, value)) { field value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } protected void OnPropertyChanged(string propertyName) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }[CallerMemberName]是 C# 编译器提供的语法糖它会在编译时自动把调用属性名填进propertyName。这样你写SetProperty(ref _searchText, value)时不需要手写字符串省掉了「属性名拼写错误导致绑定静默失效」这一整类玄学问题。在 OA 源码里所有模块的 ViewModel 都继承这个类之后加属性只需要三行private string _searchText; public string SearchText { get _searchText; set SetProperty(ref _searchText, value); }3.2 DataGrid 动态列绑定字段很多时别写二十个 DataGridTextColumn企业 OA 最常出现的就是台账类页面员工台账、合同台账、固定资产台账字段动辄十几二十列。新手通常会写一长串DataGridTextColumn Binding{Binding Name} Header姓名/。二十列写完手就酸了而且每个列还要单独调宽度、可见性。更好的做法是构建列配置集合用DataGridTemplateColumn绑定public class ColumnConfig { public string Header { get; set; } public string BindingPath { get; set; } public double Width { get; set; } 120; public bool IsVisible { get; set; } true; }然后在前台用ItemsSource{Binding Columns}并配合DataGrid.AutoGeneratingColumn事件做列生成。这里的关键参数是BindingPath要和实体属性名完全一致大小写敏感。以前遇到过一位同事把实体属性叫ApplyDate列配置里写applyDate结果列是空的找了一下午没找到原因最后在Output窗口看到绑定错误才发现大小写问题。3.3 树形表格组织架构和权限树的上手最快方案OA 里绕不开「树」最典型的是组织架构树和菜单权限树。WPF 实现树形表格TreeView是刻板印象但真正做台账嵌套时TreeView配合HierarchicalDataTemplate才是正确姿势TreeView ItemsSource{Binding Departments} TreeView.ItemTemplate HierarchicalDataTemplate ItemsSource{Binding Children} StackPanel OrientationHorizontal TextBlock Text{Binding DeptName} FontWeightBold/ TextBlock Text{Binding EmployeeCount} Margin8,0,0,0 ForegroundGray/ /StackPanel /HierarchicalDataTemplate /TreeView.ItemTemplate /TreeViewHierarchicalDataTemplate的关键就在于ItemsSource{Binding Children}它告诉 WPF「这是一棵树下一层去哪找」。如果子集合属性名不叫Children而叫SubDepartments必须同步改绑定路径否则运行时只显示根节点不报任何错——这是 WPF 绑定最常见的静默失败场景。组织架构树的实体推荐用自引用结构Id、ParentId、DeptName、Children。注意Children必须是ObservableCollectionDepartmentNode否则后加子部门时树不会自动展开。3.4 数据表格的实时搜索别在 getter 里做 LINQ搜索是企业 OA 台账页的标配功能。很多源码会写成搜索框绑定到SearchText然后在SearchText的 setter 里直接调用视图集合的Filter()方法。直觉上没毛病但问题是你把业务查询塞进了属性注入过程一旦查询慢超过 300ms整个 UI 线程被卡住拖拽窗口都会掉帧。我给出一套更实用的套路private string _searchText; public string SearchText { get _searchText; set { if (SetProperty(ref _searchText, value)) { // 防抖只执行最后一次输入 _searchDebounce?.Dispose(); _searchDebounce DispatcherTimer.StartNew(TimeSpan.FromMilliseconds(300), () { FilterItems(); }); } } } private void FilterItems() { if (string.IsNullOrWhiteSpace(_searchText)) { ItemsView.Filter _ true; return; } var keyword _searchText.Trim().ToLower(); ItemsView.Filter item item.Name.Contains(keyword) || item.Code.Contains(keyword); }这段代码里ItemsView是ICollectionView通过CollectionViewSource.GetDefaultView(ObservableCollection)拿到。给Filter属性赋委托后DataGrid 会自动刷新显示不用每次重建集合。防抖的意义在于用户连续输入「张」「张三」「张三丰」时只有停下来 300ms 后才会真正执行过滤把数据库压力从「每一键一次查询」降为「一次查询」。DispatcherTimer.StartNew是 WPF 自带的 UI 计时器能在 UI 线程安全地执行回调比System.Timers.Timer省去Invoke的麻烦。4. 打通 HTTP 通信层OA 源码里最容易被忽视的 API 封装4.1 HttpClient 单例化每个窗口 new 一个 HttpClient 是灾难WPF OA 前端连接后端 API 时最常见的问题是每个窗口或服务各自new HttpClient()。这会耗尽套接字并导致端口被占满。正确做法是全局单例并设置合理的超时时间public class ApiClient { private static readonly HttpClient _httpClient; static ApiClient() { _httpClient new HttpClient { BaseAddress new Uri(http://oa-server:8080/api/), Timeout TimeSpan.FromSeconds(30) }; _httpClient.DefaultRequestHeaders.Accept.Add( new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue(application/json)); } public static async TaskT? GetAsyncT(string url, CancellationToken ct default) { var response await _httpClient.GetAsync(url, ct); response.EnsureSuccessStatusCode(); var json await response.Content.ReadAsStringAsync(ct); return JsonSerializer.DeserializeT(json, JsonOptions); } }static构造函数保证整个进程只有一个HttpClient实例BaseAddress设为后端 API 根路径后调用方只需写相对路径。Timeout设 30 秒是局域网内比较合理的值——太短5 秒在领导审批上传附件时必然超时太长60 秒会让用户因为一条卡死的请求而一直转圈等待。注意CancellationToken参数当用户在 WPF 里关闭窗口时如果发起异步请求的可观察任务没有取消标记窗口关了但网络请求还在继续后续回调里更新界面就会抛出TaskCanceledException或直接在已销毁的控件里赋值。4.2 统一 API 响应模型别让解析层处理脏数据企业 OA 后台返回的数据格式如果不做统一规范前端解析代码会写得像打补丁一会儿解析data字段一会儿解析data.list一会儿又要处理message为空。我一般会要求后端配合定义统一响应结构public class ApiResponseT { public int Code { get; set; } // 0成功 非0业务错误 public string Message { get; set; } public T? Data { get; set; } public bool IsSuccess Code 0; }前端封装统一解析public static async TaskT? PostAsyncT(string url, object body, CancellationToken ct default) { var json JsonSerializer.Serialize(body, JsonOptions); using var content new StringContent(json, Encoding.UTF8, application/json); var response await _httpClient.PostAsync(url, content, ct); var responseJson await response.Content.ReadAsStringAsync(ct); var result JsonSerializer.DeserializeApiResponseT(responseJson, JsonOptions); if (result null || !result.IsSuccess) { throw new BusinessException(result?.Message ?? 未知错误); } return result.Data; }这里的BusinessException建议单独定义目的是让 ViewModel 层用try-catch区分「网络异常」和「业务拒绝」。比如重复提交审批单时后端返回Code1001, Message该单据已审批前端直接在 catch 里提示用户即可。把错误码判断集中在这一个类里后续后端扩展错误码时前端只改一处这是 OA 系统降维护成本的关键动作。4.3 文件上传与下载的进度反馈别让用户对着假死界面OA 里经常要上传合同附件、审批图片。HttpClient 上传大文件时如果什么都不做UI 会假死十几秒。WPF 的await天然能在异步操作后回到 UI 线程所以只需要在后台用流式上传并报告进度public static async Task UploadFileAsync(string url, string filePath, IProgressdouble? progress null, CancellationToken ct default) { using var fileStream File.OpenRead(filePath); using var form new MultipartFormDataContent(); using var fileContent new StreamContent(fileStream); form.Add(fileContent, file, Path.GetFileName(filePath)); // 这里用 SendAsync 而不是 PostAsync因为要拿 response using var request new HttpRequestMessage(HttpMethod.Post, url) { Content form }; var response await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); }严格来说上报进度需要更高阶的自定义HttpContent来监听SerializeToStreamAsync中的读取字节数。对于 OA 系统我更推荐在 UI 上用「确定上传中」的动画 超时提示来替代精确百分比。原因是局域网内 OA 上传一般在 2~10MB 之间精确到小数点不可靠且统计耗时更容易把 Watermark 文本写对。真正需要关注的是HttpCompletionOption.ResponseHeadersRead——它让SendAsync在收到响应头时就返回而不是等整个下载完成这样下载大文件时 UI 可以提前把进度条设成 90%剩 10% 留给文件落盘。4.4 离线与重试策略局域网 OA 最现实的需求企业 OA 的典型场景之一是生产车间里网络不稳定。如果每一次按钮点击都要在线那系统用两天就会被骂。我在做过的一个车间考勤系统里用的策略是所有写操作先写本地 SQLite 队列后台线程逐条同步到服务器。这里给出 WPF 端的简单实现public class SyncQueue { private static readonly ConcurrentQueueSyncItem _queue new(); private static readonly SemaphoreSlim _gate new(1, 1); public static async Task EnqueueAndSyncAsync(SyncItem item, CancellationToken ct default) { _queue.Enqueue(item); // 立即尝试同步一次失败则留在队列中等后台重试 await TryFlushAsync(ct); } private static async Task TryFlushAsync(CancellationToken ct) { await _gate.WaitAsync(ct); try { while (_queue.TryPeek(out var item)) { try { await ApiClient.PostAsyncobject(item.Url, item.Payload, ct); _queue.TryDequeue(out _); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { // 网络不通就退出循环等下次调用或定时重试 Logger.Warn($同步失败, 队列剩余 {_queue.Count} 条, 原因: {ex.Message}); break; } } } finally { _gate.Release(); } } }这段代码里值得琢磨的是SemaphoreSlim。它保证「只有一个后台任务在刷队列」避免同时触发TryFlushAsync时两条线程同时出队导致同一数据被发送两次。TryPeekTryDequeue的搭配是「先确认发送成功再移出队列」的经典手法如果先TryDequeue再发送一旦发送失败这条记录就丢了。catch里只捕获HttpRequestException和TaskCanceledException而放过业务异常因为业务错误比如「单据已删除」代表这条数据永远无法成功继续重试只会卡死队列。5. 企业 OA 系统开发避坑五条血泪经验与排查清单5.1 现象DataGrid 加载 5000 行居然卡了 3 秒原因没有启用 UI 虚拟化。WPF 的DataGrid默认EnableRowVirtualization为 True但如果你的数据源是ListT且不断重建WPF 无法进行增量渲染每次都重新布局所有行。解决数据源固定为一个ObservableCollection每次刷新用Clear()Add()并确认DataGrid的两个关键属性EnableRowVirtualizationTrue和VirtualizingPanel.IsVirtualizingTrue。另外如果列数超过 15 列建议冻结前几列FrozenColumnCount3否则横向滚动时表头和对不齐会让用户疯掉。还有一点不要在LoadingRow事件里做复杂计算它在滚动时会被高频率触发任何超过 5ms 的逻辑都会让滚动变得粘滞。5.2 现象列表上点「驳回」按钮权限校验通过了但数据没变原因命令执行在 UI 线程但数据更新在后台线程或另一个 DataContext。更常见的是你在CanExecute里判断了权限但Execute里直接调用了被禁用的按钮所绑定的方法而 WPF 对Command的CanExecute只在部分交互时机刷新如鼠标移动并不会在权限状态变化时自动轮询。解决手动调用CommandManager.InvalidateRequerySuggested()或者让PermissionService在登录后主动触发一次CommandManager刷新。我踩过最深的一次坑是领导账号登录后点「导出 Excel」按钮一直灰色因为CanExecute返回 False 后界面没有重新评估。后来查源码发现是CanExecute里用的角色字符串拼写错误LEADER写成了Leader大小写不一致。所以诚心建议所有角色字符串统一用常量类管理别在代码里裸写字符串字面量。5.3 现象关闭审批窗口后后台 HTTP 请求还在跑甚至程序退出时卡住原因async void的事件处理器不会等待任务完成且没有取消令牌。WPF 窗口关闭时如果触发了一个async void事件方法中的await ApiClient.PostAsync(...)当窗口关闭后方法后续访问this或控件就会抛异常而这个异常发生在async void里会直接让整个进程崩溃。解决所有耗时操作的方法签名不要用async void统一用async Task并且发起请求时传入窗口的Closed事件里手动触发的CancellationTokenSource。实操上我是在MainWindow里维护一个全局CancellationTokenSource在OnClosing里调用Cancel()并Dispose()然后所有 ApiClient 方法都接受这个 Token。这样用户关闭窗口时所有在途请求都会被取消不会出现「程序退出后进程还在后台挂着」的怪象。5.4 现象同一份源码换台电脑打开 XAML 编辑器就疯狂报错原因XAML 里写了d:DataContext{Binding YourViewModel}且设计时类型解析失败或者引用了不存在的静态资源名。WPF 的设计时绑定和运行时绑定是两套机制d:前缀的绑定只在 Visual Studio 设计器里生效如果 ViewModel 的无参构造函数里访问了未初始化的服务设计器直接抛异常但 F5 运行却正常。解决给 ViewModel 写一个无参构造或设计时代理DesignTimeViewModel保证设计器不会执行真实业务代码。排查思路很简单把报错信息里的「找不到资源」关键字记下来检查App.xaml中ResourceDictionary的合并顺序以及是否有多个资源文件定义了同名 key。我见过最离奇的坑是两份SolidColorBrush资源都叫BtnBrush后合并的文件覆盖了先合并的导致某个按钮颜色诡异。这件小事实在太坑了建议全局统一资源命名前缀如Brush_、Color_。5.5 现象局域网内偶尔出现「接口报错 500过一会儿自己好了」原因十有八九不是代码问题而是后端 IIS 应用池回收了。WPF 客户端请求量小服务端闲置 20 分钟后 IIS 默认回收工作进程第一个请求会触发重新编译耗时 10~30 秒。解决后端把应用池的「闲置超时」设为 0永久运行并且把「回收固定时间间隔」取消。还有一个加分项前端在HttpClient超时回调里加一条「首次请求慢是正常现象」的日志这样后续排查时不用反复确认是网络问题还是应用池问题。这条经验在维护老 OA 系统时几乎是必踩的记录在案能省去很多不必要的后端部署排查。6. 进阶把离线缓存和本地服务做到位OA 才算真正能用6.1 用 SQLite 做本地缓存与离线补偿而不是只做内存缓存企业 OA 的终极形态并不是「所有数据都从服务器拉」而是「打开系统 3 秒内看到上次的工作台」。我在一个上千人规模的项目里用过的一招是登录成功后后台线程把「我的待办」「我的已办」「常用联系人」拉下来写入本地 SQLite。之后应用启动时主界面秒开并显示本地数据后台再异步刷新。public class LocalCacheService { private readonly string _dbPath Path.Combine(AppContext.BaseDirectory, oa_cache.db); public async Task InitAsync() { await using var db new SqliteConnection($Data Source{_dbPath}); await db.OpenAsync(); var cmd db.CreateCommand(); cmd.CommandText CREATE TABLE IF NOT EXISTS TodoCache ( Id INTEGER PRIMARY KEY, Title TEXT, ApproverId INTEGER, ApplyTime TEXT, State TEXT ); ; await cmd.ExecuteNonQueryAsync(); } public async TaskListTodoCacheItem GetTodosAsync() { await using var db new SqliteConnection($Data Source{_dbPath}); var result new ListTodoCacheItem(); // 这里用 ORDER BY 保证待办顺序稳定并限制条数防止以后缓存膨胀 var cmd db.CreateCommand(); cmd.CommandText SELECT * FROM TodoCache ORDER BY ApplyTime DESC LIMIT 200; await db.OpenAsync(); await using var reader await cmd.ExecuteReaderAsync(); while (reader.Read()) { result.Add(new TodoCacheItem { Id reader.GetInt32(0), Title reader.GetString(1), ApproverId reader.GetInt32(2), ApplyTime reader.GetString(3), State reader.GetString(4) }); } return result; } }注意这里SqliteConnection是Microsoft.Data.Sqlite包不是旧版System.Data.SQLite。连接字符串里只写路径即可ModeReadWriteCreate是库默认行为。缓存表一定要设置合理的过期清理策略可以在每次启动时执行DELETE FROM TodoCache WHERE ApplyTime datetime(now, -30 day)避免缓存文件无限膨胀。GetTodosAsync里LIMIT 200也藏着一个小经验缓存数据是「冷启动时的兜底」不是全量数据限制条数可以显著降低启动耗时。6.2 验证离线队列是否生效的压测脚本如果你部署了离线补偿机制上线前可以做一个简单的验证断掉服务器网络在客户端提交 3 条单据观察本地 SQLite 的队列表记录数应为 3恢复网络后等待一个重试周期或手动触发同步记录应变为 0且服务器端多出 3 条数据。这个验证方式可以有效区分「离线模式真的管用」还是「只是假装管用」。# 伪代码手动触发同步并观察队列清空 curl -X POST http://oa-server:8080/api/sync/flush真实项目中我没有用这个接口而是直接重启客户端并观察日志。这里写出来只是提供一个思路验证离线补偿最直接的方法就是看队列表的条数变化。如果队列表一直是 0说明数据根本没有进入队列得检查SyncQueue.EnqueueAndSyncAsync是否被正确调用。6.3 用 MiniProfiler 定位 WPF 卡顿是 UI 线程问题还是网络问题WPF 应用最常见的 「假死」 问题排查方式不是猜而是先确认卡在哪里。我习惯在关键步骤加计时日志。给ApiClient的GetAsync方法里加一个计时开关var sw Stopwatch.StartNew(); await _httpClient.GetAsync(url, ct); sw.Stop(); Log.Debug(${url} 耗时 {sw.ElapsedMilliseconds}ms);如果所有 API 调用都不超过 50ms但界面依然卡顿那大概率是 DataGrid 绑定的集合操作太多检查是不是在循环里频繁调用Clear和Add如果 API 调用超过 1 秒那就先优化后端接口而不是在 WPF 端加异步思维硬扛。这个排查顺序能避免把大量时间花在「优化 UI 线上」却发现瓶颈在网络。6.4 主窗口载入体验的最后一公里最后补一个锦上添花但值得做的点把登录窗口到主窗口的过渡做成「先显示主窗框架再加载业务数据」。很多人一上来就在MainWindow的构造函数里拉数据导致窗口白屏好几秒。我的习惯是在MainWindow_Loaded事件里启动一个BackgroundWorker加载数据同时主界面立刻渲染布局。代码里只要记住一条规则构造函数只做「界面初始化」Loaded事件里才做「数据加载」。这个习惯可以避免 90% 的启动白屏问题。我从做第一个 WPF OA 模块到现在最大的一个教训是永远不要在 XAML 里直接写数据库连接字符串哪怕看着方便也不行。后来审计时发现有个窗口的Window_Loaded里嵌了明文数据库密码费了好大劲才清理完。所以我现在的习惯是统一走ApiClient连接串只存在于服务端配置客户端只存一个 API 地址。希望这篇笔记能帮你少走这些弯路在实际项目里把 WPF 企业 OA 做得更稳、更可维护。本文还有配套的精品资源点击获取
返回列表