简介这套C#基于WPF的企业OA系统源码是一份面向Windows桌面的C/S架构完整工程适合.NET客户端开发者、OA系统维护者及需要快速搭建内部管理系统的团队参考。系统以Visual Studio 2012与SQL Server 2008R2为运行环境按审批中心、计划中心、日程管理、考勤管理、劳资管理、人事、客户管理、内置记事本与系统设置等模块划分共覆盖4大部分17个子模块其中审批中心可发起人事、项目、公文申请并完成待办审核计划中心支持销售计划与年度业绩制定日程管理可查阅历史日程。压缩包共582个文件大小约17.3MB主体为253个cs源码、38个xaml与74个baml界面文件同时附带dll、exe以及mdf/ldf数据库备份便于编译运行与数据查看。目前已有790人学习下载这套源码对理解WPF绑定、多窗口布局、C/S分层结构与OA审批流均有具体参考价值也可直接用于课程设计或二次开发。1. WPF在企业OA里还有位置吗先想清楚要什么再说源码一家制造企业的信息科给车间和行政装一套办公审批系统要内网、要离线、要双显示器、要求审批界面像Excel一样能连续快速操作。浏览器里的OA不是不好而是每次打开都要走一遍登录和权限接口表单输入还经常因为网络延迟丢数据。C#基于WPF的企业OA系统源码指的就是用Windows桌面客户端承载这些办公场景把审批、通知、日程、文档都放到WPF窗口里刷新。它解决的痛点很明确局域网内高交互、支持离线填写后同步、能把本地文件直接拖进审批附件。适合谁看已经会用C#做点小工具、想往全项目方向走的开发者以及公司想自建OA但又不想被SaaS供应商绑死的技术负责人。你不需要一上来就懂全套但要有心理准备WPF是一门值得较真的桌面技术不是新项目也不是淘汰项目。2. 选型与模块拆解为什么是WPFMVVMOA的骨架怎么搭2.1 为什么OA这种“表单密集”系统反而适合WPF很多团队一听到OA就下意识要做Web理由无非是“浏览器免安装”“好维护”。但在真正的企业内网环境里事情会反过来SmartScreen拦截、安全策略不允许装插件、内网DNS解析慢导致首次加载要等几秒钟。WPF应用部署成ClickOnce或者自助更新目录双击 exe 就能进系统登录后所有页面都在本地后续操作几乎不需要再等网络。表单密集型界面是WPF最能打的地方。一个请假审批页面顶部是申请人信息中间是表单字段底部是审批意见和时间线放在WPF里用 Grid 和 StackPanel 布局非常整齐配合数据绑定改一个ViewModel字段界面上所有引用了这个属性的控件自动刷新省去大量手写赋值代码。还要看到OA的用户群特殊。行政人员和车间主管习惯的是Office式操作Tab切换、回车确认、右键菜单、批量勾选。WPF的DataGrid、ComboBox、DatePicker原生支持键盘导航做出来手感比其他框架更接近Excel而不是“一个套着输入框的网页”。如果你把源码交给新人维护WPF的XAML和MVVM虽然要学但边界清晰比在JavaScript框架里追状态更新更容易讲透。当然WPF不是万能的如果OA里有大量H5报表、三维看板或者需要兼容手机端桌面端就应该只做管理台把可视化页面扔给浏览器内嵌组件。我的经验是桌面OA的定位别贪大凡是“键盘高频、本地文件、离线可用”的模块进WPF凡是“展示为主、跨设备”的模块留在网页端。2.2 模块划分从数据库到UI最少七个模块拿一套可直接跑的企业OA来说我认为至少要拆出七个业务模块用户与组织架构、权限与菜单、流程审批、通知与已读回执、日程安排、文档中心、系统配置。要注意“用户”和“权限”分开避免写成一个“用户模块”里堆了角色分配又堆了菜单管理后头做二次开发时到处改业务代码。具体模块职责可以参考下表模块核心表/实体主要职责用户与组织架构T_User、T_Dept用户启停、部门树、职级权限与菜单T_Role、T_Menu、T_RoleMenu动态菜单、操作按钮权限流程审批T_ProcessInstance、T_Task请假、报销、用章的审批链通知与已读T_Message、T_MessageRead待办提醒、抄送、已读状态日程安排T_Schedule、T_ScheduleUser会议、个人日程、冲突检测文档中心T_Document、T_DocVersion上传下载、版本、分类系统配置T_Config、T_Log字典项、日志、连接池配置模块之间怎么通讯是OA架构的关键。比如用户提交一个请假申请流程模块要把一条待办推给上级同时给抄送人发通知用户模块要拿到申请人所在部门作为默认审批路径。如果直接跨模块 new 对方的Service类库引用会乱成蜘蛛网。系统持续扩展后互相引用会导致编译时间变长、改一个接口涉及多个项目。我一般用两个手段解耦数据库层面只通过服务接口访问UI层面通过事件总线通知跨模块关注的信息。“模块化 接口隔离”不是给大公司准备的业务量到了几十个窗体你就知道拆得越干净越容易维护。2.3 MVVM框架怎么选Prism还是CommunityToolkit.MvvmMVVM不是WPF法律的强制要求但没有MVVM的WPF源码两万行之后没人敢改。OA系统结构类似主窗口若干模块页面大量表单对话框适合用MVVM把视图和业务分开。框架选择上大多数人会在Prism和CommunityToolkit.Mvvm之间犹豫。我的判断是单窗体小工具用CommunityToolkit要模块化、导航、进程内事件通讯的完整OA用Prism。下表是两者的适用差异能力CommunityToolkit.MvvmPrism绑定与命令轻量上手快功能全学习曲线陡模块化需要自己实现内置IModule区域导航不提供ContentControlRegion 是核心事件通讯无内置EventAggregator依赖注入可配但简单内置容器扩展点适合规模小工具、单模块OA、ERP级如果你的源码打算给别人拿去改我更推荐Prism。老同事接手后看到 MainWindow 里只有几个Region不会去翻几千行事件逻辑。OA里最常见的“左侧菜单、右侧页面切换”Prism的Region导航一套命令就能完成CommunityToolkit则需要你手动管理当前Content后期页面一多很容易出现“不知道谁给这个Content赋过值”的局面。3. 从零搭一个可复用的OA外壳登录、主窗体、导航与权限3.1 项目结构与依赖注入明白选型理由后你需要一套能当模板用的项目结构。我习惯用解决方案加模块类库组织源码AppShell是启动壳只放主窗口、登录窗口、启动逻辑其余每个业务模块一个类库各自管自己的Views和ViewModels。这样的好处是权限菜单直接按模块扫描新增模块不碰已有模块的代码。典型的源码目录长这样OA.sln |-- AppShell | |-- App.xaml | |-- App.xaml.cs | |-- Views | | |-- LoginWindow.xaml | | |-- MainWindow.xaml | |-- ViewModels | |-- LoginViewModel.cs | |-- MainWindowViewModel.cs |-- OA.Modules.Core // 公共接口、模型、工具类 |-- OA.Modules.Org // 用户/部门 |-- OA.Modules.Permission // 角色/菜单/权限 |-- OA.Modules.Workflow // 流程审批 |-- OA.Modules.Message // 通知/已读回执 |-- OA.Modules.Document // 文档中心 |-- OA.Modules.Setting // 系统配置每个类库内部的Views和ViewModels一一对应XAML里做DataContext绑定不写Code-behind。下面是App.xaml.cs用Prism启动项目的写法核心是把主窗口、登录窗口和各个模块注册进容器protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog) { moduleCatalog.AddModuleOrgModule(); moduleCatalog.AddModulePermissionModule(); moduleCatalog.AddModuleWorkflowModule(); moduleCatalog.AddModuleMessageModule(); moduleCatalog.AddModuleDocumentModule(); moduleCatalog.AddModuleSettingModule(); } protected override void RegisterTypes(IContainerRegistry containerRegistry) { // 全部单例注册保持会话期间数据唯一 containerRegistry.RegisterSingletonIDbService, SqlServerDbService(); containerRegistry.RegisterSingletonIUserSession, UserSession(); containerRegistry.RegisterSingletonIEventAggregator, EventAggregator(); containerRegistry.RegisterForNavigationMainPage, MainPageViewModel(); } protected override Window CreateShell() { // 实际项目先弹登录窗口登录成功再创建主窗口 return Container.ResolveLoginWindow(); }这段代码的关键是“先 LoginWindow 后 MainWindow”的次序。如果直接在CreateShell里返回主窗口用户没登录就能看到界面权限校验全在后台做不优雅也不安全。RegisterForNavigation是Prism的导航注册只有注册过的页面才能被Region导航使用。注意RegisterTypes里不要每次new DbServiceOA的数据库连接池和用户会话必须全局唯一否则每个窗口一套连接资源崩溃是迟早的事。3.2 登录窗口密码不在内存里裸奔登录窗口是OA源码里第一个要写对的地方。密码校验建议用非对称思路数据库不存明文密码存“盐值密码哈希”。整个过程在服务端完成WPF窗体只负责把输入传给服务永远不接触数据库连接字符串。下面是一个简化但可用的密码哈希工具类public static class PasswordHasher { private const int SaltSize 16; private const int KeySize 32; private const int Iterations 10000; public static (string Salt, string Hash) Create(string password) { byte[] salt RandomNumberGenerator.GetBytes(SaltSize); byte[] hash Rfc2898DeriveBytes.Pbkdf2( password, salt, Iterations, HashAlgorithmName.SHA256, KeySize); return (Convert.ToBase64String(salt), Convert.ToBase64String(hash)); } public static bool Verify(string password, string saltBase64, string hashBase64) { byte[] salt Convert.FromBase64String(saltBase64); byte[] expected Convert.FromBase64String(hashBase64); byte[] actual Rfc2898DeriveBytes.Pbkdf2( password, salt, Iterations, HashAlgorithmName.SHA256, KeySize); return CryptographicOperations.FixedTimeEquals(actual, expected); } }参数说明SaltSize取16字节足够防止彩虹表Iterations取10000在配置差一点的办公电脑上也就几十毫秒不建议再提高太多因为OA登录是高频操作。Verify里用了FixedTimeEquals做时间恒定比较这是防“按耗时推测密码长度”的标准做法。另一个安全细节是登录失败的提示信息不要告诉用户“用户名不存在”或“密码错误”统一提示“用户名或密码不正确”。内网OA尤其要防内部员工撞库对方知道账号存在后可以一直试弱口令。登录成功后的Token和用户信息放到UserSession单例里主窗口从那里读取当前用户不要在多个ViewModel里重复查询数据库。3.3 主窗体定制导航、动态菜单和主题切换登录后进入主窗体。经典布局是左侧菜单栏、顶部用户栏、中间工作区。我把中间工作区做成Prism的Region左侧菜单数据来自权限表用户登录时根据角色返回菜单集合。动态菜单不必在XAML里写死按钮而是绑定一个ObservableCollection 数据来了界面自然生成。下面给出主窗体ViewModel里的核心导航方法public class MainWindowViewModel : BindableBase { private readonly IRegionManager _regionManager; public ObservableCollectionNavItem MenuItems { get; set; } private DelegateCommandNavItem _navigateCommand; public DelegateCommandNavItem NavigateCommand _navigateCommand ?? new DelegateCommandNavItem(Navigate); private void Navigate(NavItem item) { if (item null || string.IsNullOrEmpty(item.TargetView)) return; // 通过区域管理器导航到目标页面导航参数携带当前用户ID NavigationParameters param new NavigationParameters { { userId, _userSession.UserId } }; _regionManager.RequestNavigate(MainRegion, item.TargetView, param); } }这里的代码思路是菜单项只包含视图名称TargetView真正显示什么页面由Region从容器里解析。这样做的好处是权限菜单的添加不需要改XAML只要在数据库里给角色分配菜单下次登录导航项自动出现。NavItem的TargetView必须和RegisterForNavigation注册的视图名一致比如“WorkflowListPage”写错了会在运行时抛“视图未发现,请检查RegisterForNavigation”的异常排错时先查注册名别在XAML里浪费时间。导航传参里我习惯带上userId而不是整个User对象避免导航参数被日志打印出敏感信息。如果后续要传复杂对象建议单独建一个参数类而不是把实体直接怼进去。主题切换看起来是加分项但OA里其实很实用行政人员偏好浅色车间大屏偏好深色做成一个切换按钮就能少掉一批抱怨。常见做法是把颜色和画刷定义在ResourceDictionary里切换时动态替换Application的ResourceDictionaryvar dictionaries Application.Current.Resources.MergedDictionaries; var light new ResourceDictionary { Source new Uri(Themes/Light.xaml, UriKind.Relative) }; var dark new ResourceDictionary { Source new Uri(Themes/Dark.xaml, UriKind.Relative) }; dictionaries.Clear(); dictionaries.Add(isDark ? dark : light);这段逻辑要放在用户设置模块里默认跟随系统记住用户上次选择。需要注意的是ResourceDictionary里所有画刷的Key必须一致比如把“BackgroundBrush”“PrimaryBrush”这两个Key都定义在浅色和深色皮肤文件里切换才不会有空白控件。4. 把业务功能填进去工作流审批与动态报表的落地写法4.1 工作流模块的数据模型与状态机工作流是OA里最容易被小瞧的模块。请假单看起来只有“提交、审批、驳回”三个动作实际到了报销、用章、合同评审就会有“多级审批、抄送、加签、撤回、委托”这些状态转换。数据模型上我建议用一张流程主表加一张任务表而不是把审批记录全塞进主表一行。核心字段设计如下表字段含义T_ProcessInstanceInstanceId流程实例主键T_ProcessInstanceProcessType请假、报销、用章等类型T_ProcessInstanceApplyUserId申请人T_ProcessInstanceStatus状态机0申请中1审批中2通过3驳回T_TaskTaskId审批任务主键T_TaskInstanceId关联流程实例T_TaskApproverId审批人T_TaskAction待办、通过、驳回T_TaskComment审批意见状态机的变化要控制在服务层WPF源码里不要直接update状态字段。否则出现多个窗口同时处理同一流程时会出现数据覆盖A窗口看到待办点通过B窗口同时点驳回后写入的人把前一个人的结果覆盖掉。我常用的方式是每个审批操作都带一个“期望状态”参数更新时用SQL的UPDATE ... WHERE Status expectedStatus如果影响行数为0说明流程已被别人处理界面立刻提示“该审批已由××处理”。这样做能挡住大部分并发问题又比数据库行锁简单。4.2 用EventAggregator做“已读回执”和“抄送提醒”流程审批必须跟通知模块联动。用户提交请假单后审批人的待办列表要出现一条新待办同时抄送人的通知栏要弹一个提示。如果提交模块直接调用通知模块的Service虽然也能实现但源码里会多出一条隐藏依赖。更干净的做法是用Prism的EventAggregator发布一个“任务已创建”事件通知模块自行订阅并处理弹窗。代码层面的发布端是这样public class WorkflowSubmitService { private readonly IEventAggregator _eventAggregator; public async Task SubmitAsync(ProcessInstance instance, Listint approverIds) { // 保存流程主表和首批审批任务 await _db.ProcessInstances.AddAsync(instance); await _db.ProcessTasks.AddRangeAsync(BuildInitialTasks(instance, approverIds)); await _db.SaveChangesAsync(); // 发布事件让订阅方决定如何通知审批人 _eventAggregator.GetEventTaskCreatedEvent() .Publish(new WorkflowTaskMessage { InstanceId instance.InstanceId, ApproverIds approverIds, ApplyUserId instance.ApplyUserId }); } }订阅端放在消息模块里public class MessageSubscriber { public MessageSubscriber(IEventAggregator eventAggregator) { _eventAggregator.GetEventTaskCreatedEvent() .Subscribe(OnTaskCreated, ThreadOption.UIThread); } private void OnTaskCreated(WorkflowTaskMessage message) { // 查询审批人姓名、部门插入消息表并刷新前台待办 // 如果消息类型是抄送只弹Toast不进入待办流程 } }这里有两个容易踩的细节ThreadOption.UIThread一定要写因为事件Publish时如果发生在后台线程订阅回调也在后台线程直接操作ObservableCollection会抛“调用线程无法访问此对象”的异常。另一个是订阅要记得退订MessageSubscriber如果是窗口级的窗口关闭时不执行Unsubscribe事件源会一直持住这个窗口导致内存泄漏。我把MessageSubscriber设计成单例和主窗口生命周期相同所以不涉及窗口销毁如果你把订阅放在具体页面里一定要在页面OnNavigatedFrom里调用Unsubscribe。4.3 大数据量DataGrid虚拟化与批量更新OA里最卡的界面往往是“审批列表”和“消息记录”。一个五千条的待办列表如果直接for循环Add到ObservableCollection界面会像幻灯片一样卡顿。原因是每Add一次集合都会触发CollectionChangedDataGrid要重置UI操作次数是O(n)。解决方法是虚拟化和批量更新双管齐下。先说虚拟化DataGrid默认开了UI虚拟化但很多人会在样式里覆盖ItemsPanel或者为了支持自动列宽把ScrollViewer的CanContentScroll改成false这两个动作都会让虚拟化失效。排查时在XAML里显式声明DataGrid VirtualizingPanel.IsVirtualizingTrue VirtualizingPanel.VirtualizationModeRecycling EnableRowVirtualizationTrue ScrollViewer.CanContentScrollTrueVirtualizationMode.Recycling比Standard更快它会重用容器行而不是销毁重建。EnableRowVirtualization只针对行如果列表里有几百行还不能卡那是数据加载的问题不是UI问题。下面这段代码演示了“分块加载”的思路避免一次性把一万条数据塞进界面private async Task LoadTasksAsync() { _allTasks.Clear(); var tasks await _taskService.GetPendingTasksAsync(_userSession.UserId); // 按300条一个批次追加到集合每批之间让出UI线程 foreach (var batch in tasks.Chunk(300)) { foreach (var task in batch) { _allTasks.Add(task); } await Task.Delay(30); } }参数说明Chunk(300)里的300是经验值电脑性能差可以调成100性能好调成500但不要一次上一万。Task.Delay(30)是关键它让界面有机会处理集合变更事件并重绘卡顿感会明显下降。如果数据源来自SQL Server建议分页查询而不是全查出来再ChunkWPF端只保留当前页数据和下一页预加载配合DataGrid的分页控件用户体验更稳。5. 避坑指南WPF OA开发里最容易被数据绑定和异步拖死的五个坑5.1 绑定不刷新界面纹丝不动后台值早就变了现象已经实现了INotifyPropertyChangedViewModel里给属性赋值界面上Label还是旧值。原因有三种属性没有调用SetProperty而是直接等号赋值DataContext设置时机太早或太晚绑定路径写成了属性名的首字母大写或错了一个字母而Binding默认对路径是大小写不敏感的但对拼错会无声失败。解决先用输出窗口排查绑定错误WPF会把“Cannot find source for binding with reference”写到调试输出里你会看到具体是哪个属性找不到。赋值统一走SetProperty不要在类的构造函数里直接给属性赋值后还指望绑定到视图绑定要等DataContext赋值完成才生效。如果数据在后台线程刷新还要记得通过Dispatcher调度到UI线程后再更新属性值。5.2 DataGrid滚动像PPT上千行界面直接卡死现象待办列表超过一千行拖动滚动条时CPU占用百分之百窗口跟死机一样。原因多数是虚拟化被破坏常见破坏方式是给DataGrid包了一层ScrollViewer或者为了达到“悬浮列”效果用了Canvas或自定义Panel这样VirtualizingPanel就不会生效。解决先检查XAML有没有显式关闭虚拟化再检查DataGrid是否直接放在Grid里中间不要套ScrollViewer。另外绑定集合类型也有影响绑定List 时DataGrid无法感知集合变化会退化为一次性绘制绑定ObservableCollection 才能配合虚拟化做增量更新。如果以上都排除了就用上一章的Chunk批量加载让集合变更通知频率降下来。5.3 死锁玄学点击按钮后整个窗口转圈点哪里都没反应现象登录成功后点“加载待办”按钮程序不崩溃但界面无响应过十几分钟后提示“任务已取消”。原因通常是同步等待异步任务代码里写了Task.Run(...).Result或者.Wait()。WPF的同步上下文不像控制台程序UI线程在Wait时会阻塞而异步任务内部又要回到UI线程调度两边互相等着就是典型的死锁。解决界面层不要用.Result/.Wait()改用await如果遇到的是第三方SDK只提供同步方法用await Task.Run(() sdk.SyncMethod())包一层。我还在源码里加了一个兜底在App.DispatcherUnhandledException里记录异常这样就算漏网之鱼用户也能看到“操作失败请重试”而不是体验一次假死。5.4 App.config读不到连接串部署到客户机器后就找不到数据库现象源码在自己电脑能跑发布后装到别的机器运行时抛ConfigurationErrorsException连配置文件里的连接字符串都读不到。原因是有时把App.config放在了某个业务模块类库而WPF启动时只加载启动项目的配置文件。WPF的ConfigurationManager默认读的是可执行文件所在目录的“程序名.exe.config”业务模块的配置文件根本不会被打包。解决把DbConnectionString统一放在AppShell项目的App.config里业务模块通过Core里的配置服务读取不要自己在每个模块里各自找配置。发布时确认输出的exe.config文件存在并且包含连接串不要手贱改文件名字。如果不想明文存连接串就在第一次启动时走一次配置向导把连接串加密后写入用户目录这对“每次部署都让甲方面子挂不住”的尴尬特别有用。5.5 窗口关不掉内存偷偷涨事件订阅成了内存泄漏的帮凶现象连续打开关闭“审批详情”窗口任务管理器里进程内存只涨不降最后变成几个G。原因多半是这个窗口的ViewModel订阅了EventAggregator的事件关闭窗口时没有退订事件源一直握着订阅者引用。解决订阅之前先想好生命周期。如果是窗口级订阅在OnNavigatedFrom或窗口Closed事件里调用Unsubscribe。还有一种隐蔽情况是匿名方法订阅GetEventT().Subscribe(p {...})如果不用subseteq存住委托后面无法退订。我在项目里统一要求消息订阅类都实现IDisposable并用WeakReference让事件源不阻止GC回收OA的消息量不大弱引用带来的性能损失可忽略。6. 上线前的验证与优化绑定泄漏排查、启动速度和打包分发源码写完之后真正决定能不能稳定运行的是上线前的验证流程。我的习惯是三步走先查绑定错误再测启动耗时最后带着日志安装包去试用户机器。绑定错误排查不用装额外工具Visual Studio的输出窗口就能看到所有Binding表达式找不到源路径的警告。把Debug配置里“输出窗口”的显示改成“绑定错误”启动系统后手动走一遍登录、打开待办、编辑个人资料输出窗口里只要出现“Cannot find source for binding”逐条改掉。用Snoop也能查但OA这种几十个窗体的项目里输出窗口已经能解决九成问题。启动速度优化要考虑用户感受。不要一打开App就把所有模块加载完Prism支持按需加载模块我通常让首页、权限、待办三个模块在启动时加载其余模块等用户第一次点击导航再加载。这样启动时间能从三秒压到一秒半对老电脑尤其明显。还顺手做了“数据库连接检查”打在启动日志里连接失败时提示并退出不再黑屏白屏让用户猜测。打包分发是桌面系统绕不过去的坑。ClickOnce适合内网小规模部署但每次发布都要手动更新版本号MSIX在企业环境有依赖商店服务的问题遇到Windows 10家庭版就太麻烦自包含单文件发布会让exe变得很大首次启动要解压。我最终选了“签名自更新目录”方案WPF程序启动时检查局域网共享目录的版本清单本地版本低就提示更新再启动。Windows的SmartScreen对未签名exe拦截得越来越勤给exe签一个代码签名证书部署时才不会吓到业务部门。最后说一下我的个人习惯每次发布前我都会在安装目录下放一个只保存本次启动记录的日志文件内容包含数据库连接状态、各模块加载耗时、当前版本号和编译时间。用户说“系统有问题”时先让他把日志文件发过来不用盲人摸象。现在很多问题都是上线第三天才暴露而logs里早就写明是客户机器缺少.NET组件还是数据库连接串错了。希望帮到你。本文还有配套的精品资源点击获取