
简介面向有一定C#基础与面向对象经验的开发者这是一份基于Winform实现C/S架构办公OA系统开发的课程PDF同时附赠C/S与B/S版完整代码重点讲解分层架构、SQL Server操作、权限管理、自定义控件及团队开发规范适合希望提升企业级桌面应用开发能力的人群。包体为1个PDF文件压缩后约900KB内容浓缩了系统介绍、数据库设计与关系图、19个课程模块安排及项目总结。目前已有186人学习下载。文档覆盖登录日志、递归菜单树、窗体通信、内部短消息、文件上传下载等实战模块并引入代码生成工具与VSS源代码管理能够帮助读者按从需求分析到项目成型的完整路径快速搭建并理解一个中小型OA系统。1. 基于C#Winform的C-S架构OA系统为什么这件事到今天依然值得做如果你在二三十人的公司做内部系统OA 的刚需往往是“这个请假单今天能走完审批”。这个前提让 C# Winform 下的 C-S 架构办公 OA 系统成了最直给的方案客户端直连数据库界面反馈快开发链条短。北风网这套教程标题里写了“附赠C-S、B-S版完整代码”正好把从业者最常纠结的问题摆上台面同一条审批流程到底用桌面端还是浏览器端。我按自己落地这类项目的顺序展开框架、数据库权限、工作流、双版迁移再到避坑和交付技巧。想快速交付内部工具、或拿现成代码入门 C# 桌面开发的读者可以直接照这套打法走老手重点看第 4 章双版迁移和第 5 章翻车点值得对号入座。2. 拆解C-S架构OA的地基三层结构、数据库设计与登录会话2.1 三层结构为什么是C-S架构OA的第一条底线很多 Winform 项目最后变成“屎山”不是因为 C-S 架构有问题而是因为按钮点击事件里直接拼 SQL。数据库结构一改整个窗体飘红换个人维护没人敢动。我一般会把工程从创建开始就拆成三层OASystem/ ├── OA.UI // Winform 界面层窗体、控件、事件 │ ├── LoginForm.cs │ ├── MainForm.cs │ └── Modules/ ├── OA.BLL // 业务逻辑层审批规则、状态流转、校验 │ ├── UserService.cs │ ├── FlowService.cs │ └── LeaveService.cs └── OA.DAL // 数据访问层SQL、连接、事务 ├── DbHelper.cs ├── UserDal.cs └── FlowDal.csUI 层的职责是把用户操作变成参数传下去、把返回结果显示出来这里不该出现 SqlConnection。BLL 层处理请假天数计算、审批条件判断这类规则DAL 层只做最基础的 CRUD。有人觉得小系统这么拆浪费但 OA 后续一定会加资产管理、加合同审批三层结构是唯一不用重写的保障。Winform 和 C-S 本身不背锅背锅的是没有边界。2.2 数据库与权限模型从用户表到部门-角色-菜单OA 系统的业务表可以很多但权限相关的五张表是骨架用户、角色、菜单三张主表加用户-角色、角色-菜单两张关联表再配一张部门表。CREATE TABLE sys_user ( user_id INT IDENTITY(1,1) PRIMARY KEY, user_name NVARCHAR(50) NOT NULL UNIQUE, password_hash NVARCHAR(128) NOT NULL, dept_id INT, is_active BIT DEFAULT 1 ); CREATE TABLE sys_menu ( menu_id INT IDENTITY(1,1) PRIMARY KEY, menu_name NVARCHAR(50) NOT NULL, parent_id INT DEFAULT 0, form_code NVARCHAR(100) NOT NULL, -- 对应Winform窗体类名如 LeaveApply sort_no INT DEFAULT 0 );菜单表里的 form_code 对应 Winform 窗体的类名登录后根据权限动态创建菜单项这是 C-S 版 OA 和 B-S 版在菜单权限上最明显的差异点B-S 版一般用路由地址C-S 版用窗体类名。取菜单权限的查询也值得单独写出来SELECT DISTINCT m.menu_id, m.menu_name, m.parent_id, m.form_code, m.sort_no FROM sys_user u JOIN sys_user_role ur ON u.user_id ur.user_id JOIN sys_role_menu rm ON ur.role_id rm.role_id JOIN sys_menu m ON rm.menu_id m.menu_id WHERE u.user_id UserId AND m.form_code IS NOT NULL ORDER BY m.sort_no;DISTINCT 是为了防“一个用户挂了两个角色菜单重复出现”。桌面客户端直连数据库的架构里权限不能只在界面层隐藏——用户改改连接串连到同一个库照样能绕过窗体看到数据所以严格权限还要在 BLL 或数据库视图层再拦一道。表单提交、审批操作时在 BLL 里再查一次该用户有没有对应权限码宁可多查一次也不能信界面状态。2.3 用SqlConnection还是Dapper数据访问层选型的两个标准小 OA 系统要不要上 ORM我的经验是不一定要上 EFDapper 是平衡点。两个选择标准第一查询以单表和简单 join 为主不需要复杂的实体状态跟踪第二直连数据库的情况下Dapper 的 SQL 可控性让你能看清每条语句在干什么出问题好定位。using Dapper; using System.Data.SqlClient; public class UserDal { private readonly string _connStr; public UserDal(string connStr) { _connStr connStr; } public UserDto GetByName(string userName) { const string sql SELECT user_id AS UserId, user_name AS UserName, dept_id AS DeptId FROM sys_user WHERE user_name UserName AND is_active 1; using (var conn new SqlConnection(_connStr)) { return conn.QueryFirstOrDefaultUserDto(sql, new { UserName userName }); } } }这里有两个参数层面的讲究。一是 UserName 用了参数化而不是字符串拼接除了防注入还能让 SQL Server 的查询计划有复用机会二是 WHERE 里带 is_active 1避免禁用账号还能登进来很多人删账号是物理删结果关联表全炸。代码里的 using 保证连接用完后立刻归还连接池这个细节后面专门展开。如果你的开发环境是 VS2015默认 C# 版本是 6.0上面代码没问题但像 using var 这种 C# 8 的语法就得装新编译器。遇到这种情况别硬上新语法显式释放也不丢人。2.4 登录与会话保持心跳、超时与模拟会话B-S 架构的会话由服务端管理C-S 直连数据库时没有这个现成机制登录状态得自己做。我常用的做法是登录成功后维护一个当前用户对象同时记录“最后操作时间”用全局 Timer 检查超时。public static class Session { public static UserDto CurrentUser { get; set; } public static DateTime LastAction { get; set; } } private Timer _heartbeatTimer; private void InitHeartbeat() { _heartbeatTimer new Timer { Interval 60000 }; _heartbeatTimer.Tick (s, e) { if ((DateTime.Now - Session.LastAction).TotalMinutes 20) { AutoLock(); } }; _heartbeatTimer.Start(); }20 分钟不是固定标准我一般看客户日常习惯坐席岗中午吃饭常忘关客户端20 分钟锁屏就够车间看板那种全屏展示就不该用锁屏而是只刷新待办数不清空内容。AutoLock 只退到锁屏界面而不关进程比直接退出登录温和得多。实现时注意在键盘、鼠标、按钮事件里刷新 Session.LastAction否则用户明明在操作却被踢下线那是最让用户恼火的“我很安全”。3. 把OA核心模块落到Winform上待办、发文与工作流状态机3.1 待办列表的DataGridView与后台线程刷新OA 的第一个高频率界面是待办列表。Winform 的 DataGridView 绑定数据源很方便但别在 UI 线程里执行 SQL——数据量一大窗体就进入“正在响”状态。private async void LoadTodoList() { try { var list await Task.Run(() _flowService.GetTodoList(Session.CurrentUser.UserId)); dataGridView1.AutoGenerateColumns false; dataGridView1.DataSource list; } catch (Exception ex) { MessageBox.Show(加载待办失败 ex.Message); } }逻辑说明Task.Run 把数据库查询丢到线程池await 回来时自动回到 UI 线程更新控件。重点在控件属性设置顺序——先把 AutoGenerateColumns 设为 false 再赋 DataSource否则会自动生成所有字段的列连 UserId 都显示出来界面又丑又泄露信息。列我一般手动加四个发起人、表单类型、提交时间、当前状态。审批状态不要用数字显示在单元格格式化时把 1 翻译成“审批中”、2 翻译成“已通过”。这个翻译放 UI 层还是 BLL 层看团队习惯我倾向在 BLL 返回 DTO 时就带状态文本UI 不承担业务含义。3.2 工作流状态机从提交到审批的状态流转与并发控制工作流是 OA 的核心。不要把状态散落在业务表里比如请假表一个 Status、用章表又一个 Status而是建一张 flow_instance 主表业务表通过 flow_id 关联。public enum FlowStatus { Draft 0, // 草稿 Pending 1, // 审批中 Approved 2, // 已通过 Rejected 3, // 已驳回 Canceled 4 // 已撤销 }审批动作的核心是“条件更新”。同一张请假单被两个窗口同时打开在 OA 里太常见了我用下面这条 SQL 保证只有期望状态被更新UPDATE flow_instance SET status NewStatus, approver_id ApproverId, approve_time GETDATE() WHERE flow_id FlowId AND status ExpectedStatus;执行后看影响行数0 行说明别人已经处理过了直接提示“单据已被处理请刷新列表”别再往下写业务表。这就是给用户后悔药的方式——不是靠回归撤销而是靠并发控制让错误状态根本进不去。BLL 里的审批方法参数里必须带 ExpectedStatus让调用方明确说“我期望它在什么状态下被更新”而不是无脑 Update。驳回流程也别只改状态。我一般会把驳回原因写进 flow_log 表同时把流程送回上一节点而不是直接回到发起人。这里用到的就是状态机的动作概念驳回是 Pending → Rejected但如果是退回上一步其实是 Pending → Pending 并且变更节点指针。两者处理逻辑不同别用一个 Update 糊弄过去。3.3 新待办提醒轮询、未读数字段与托盘气泡桌面 C-S 架构没有服务端推送新待办提醒最可靠的做法是轮询。我一般设置 30 秒一次查询只做 count不做全列查询减少数据库压力。private Timer _notifyTimer; private int _lastPendingCount; private void StartNotifyPolling() { _notifyTimer new Timer { Interval 30000 }; _notifyTimer.Tick async (s, e) { var count await Task.Run(() _flowService.GetPendingCount(Session.CurrentUser.UserId)); if (count ! _lastPendingCount) { this.Text $办公OA系统 - 待办 {count} 条; } if (count _lastPendingCount) { notifyIcon.ShowBalloonTip(3000, 新待办, $您有 {count} 条待办, ToolTipIcon.Info); } _lastPendingCount count; }; _notifyTimer.Start(); }注意两个细节第一轮询间隔别少于 15 秒这个架构下每条 count 查询都会连一次数据库20 个客户端就是 20 个轮询源第二新消息通知用托盘气泡而不是 MessageBox。MessageBox 弹出来必须点确定才能继续操作审批人正在全屏演示时会被你逼疯。提醒逻辑要支持最小化到托盘这才是桌面办公该有的分寸。3.4 审批通过后的业务回写把流程状态同步到业务数据流程节点走完业务表也要跟着变。最典型的场景是请假审批通过后扣减年假或者用章申请通过后生成用章记录。这个回写动作绝不能放在按钮点击的偶然路径里要和流程状态更新放同一个事务。if (approveResult FlowStatus.Approved) { await _leaveService.DeductAnnualLeave(userId, days, flowId); }DeductAnnualLeave 内部要验证业务流水对应的流程状态是 Pending并且该业务流水之前没扣过假。做法是在业务表加一个 flow_id 唯一约束重复回写会因为唯一键冲突被拦下来。这里最容易翻车的是只更新了 flow_instance 的状态忘了回写业务表结果审批记录显示“已通过”员工年假却没扣月底对账时两边对不上。4. C-S和B-S同需求的双版迁移哪些层根本不用动哪些层要重写4.1 从C-S到B-S并不是换个界面那么简单同一个 OA 需求客户一开始说内网用做了 C-S 版半年后又提“领导出差要网页审批”于是要 B-S 版。那套资料把两版代码都给了价值就在这里——你能直接对比出哪些层要动、哪些层一次也没动过。关注点C-S 版WinformB-S 版浏览器表现层Winform 窗体、控件事件HTML/JS 页面走 Web API会话客户端内存模拟会话服务端会话或 JWT部署每台电脑装客户端服务端一套浏览器零安装数据访问客户端直连数据库服务端连接数据库客户端不碰库权限控制界面隐藏 BLL 校验API 鉴权 按钮级权限升级方式客户端更新刷新页面即是新版这张表也解释了 C-S 的边界客户端直连数据库时数据库连接串、账号口令都散在每台电脑上运维视角很难受。B-S 的代价是表现层交互没那么直接复杂审批表单在浏览器里做拖拽流程图工作量反而更大。4.2 审批列表迁移到Web APIDAL和BLL保留UI换成前端如果你已经按第 2 章的三层结构写 C-S 版迁移 B-S 版时DAL 和 BLL 绝大多数方法可以直接复用。我做迁移时第一步不是新建前端工程而是给 BLL 的方法加一个异步 API 壳[HttpGet(api/flow/todo)] public async TaskIActionResult GetTodoList(int userId) { var list await _flowService.GetTodoList(userId); return Ok(list); }这个壳只有十几行核心逻辑还在原来的 FlowService 里。真正要重写的是三块第一块是会话。B-S 版不能再依赖客户端内存里的 Session 对象我一般用 JWT登录接口校验账号密码后签发 token前端每次请求带上API 层解析出用户和角色。第二块是权限。C-S 版的“隐藏菜单”在 B-S 版里不算数API 每个操作都要声明需要的权限码否则懂点前端的人就能调接口越权操作。第三块是时间。C-S 版里很多 BLL 方法用了 DateTime.Now客户端本地跑没问题同一套 BLL 放到服务端DateTime.Now 变成了服务器时间而 SQL 里的 GETDATE() 也来自服务器——两者一致了但如果服务器设的是 UTC用户看到的时间全错。迁移时统一用数据库时间或在进 BLL 前把时间参数化。4.3 什么时候该同时维护C-S和B-S两版真实企业里两个版本并存并不少见但并存不是“同一个功能各写一遍”而是按场景分工。我一般建议客户遵守一条原则日常高频录入、单据量大的岗位用 C-S 客户端比如前台登记用章、库房做入库管理层出差时的查询和审批用 B-S 网页版。桌面端补录数据、打印票据顺手网页端手机也能点审批。双版并存最怕的是各改各的。我的做法是让两个版本共享同一个数据库、同一份 BLL 工程C-S 的 Winform 引用 OA.BLL.dllB-S 的 Web API 也引用同一份 OA.BLL.dll。业务规则只改一处两边部署后行为一致。UI 层别放任何规则比如“请假超过 3 天要总经理审批”这种判断出现在两个版本的按钮事件里就是灾难。4.4 读双版完整代码时先对比“差文件”而不是从头读标题里“附赠 C-S、B-S 版完整代码”这个点很多人拿到资料后的第一反应是从登录窗体开始逐行读这是效率最低的学法。我拿到双版代码会先做一次文件差异对比把两个版本的目录摆在一起看哪些文件名不一样然后只读那些不一样的文件。通常结果是你想象得到的Winform 专属的窗体类、Program.cs、前端页面这些在变而 DAL 里的 SQL、BLL 里的审批逻辑几乎不动。把“几乎不动”的部分确认下来C-S 和 B-S 的关系就清楚了。做对比时顺手把两个版本里的数据库脚本也 diff 一遍——很多教程双版代码的库结构并不完全一致这是你复现时最容易踩的第一个坑。5. 避坑Winform OA从开发到部署最常见的6个翻车点5.1 跨线程更新控件界面卡在“正在响”里现象待办数量一大点“刷新”后窗体标题变成“正在响”点哪里都没反应。原因查询是同步执行在 UI 线程上数据访问层阻塞了界面。解决用 Task.Run 把阻塞操作挪到后台线程再回到 UI 线程更新前面 3.1 的写法就是标准答案。补充一个细节如果你的代码里还有 this.Invoke 满天飞可以改用 async/await 后把大部分删掉——await 的后续代码会自动回到 UI 线程不需要手动切换。只要别在后台线程里直接改 Control.Text就不会崩。5.2 事务没包住多表写入主表进了、明细表丢了一半现象新建请假单主表插入成功明细表报错但主表记录留下来了审批人看到一张没有明细的废单。原因每条 SQL 都用独立连接执行没有放在同一个事务里。解决在 BLL 层用显式事务包裹所有写操作并且 DAL 的方法要接受连接和事务参数。using (var conn new SqlConnection(_connStr)) { conn.Open(); using (var tran conn.BeginTransaction()) { try { await _flowDal.InsertMain(conn, tran, main); await _flowDal.InsertDetails(conn, tran, details); tran.Commit(); } catch { tran.Rollback(); throw; } } }DAL 方法签名里一定要带 IDbTransaction否则事务串不起来。有人图省事在 DAL 内部 new 连接那是把事务的坑从 BLL 挪到 DAL两边都做不了原子操作。5.3 连接串裸放与连接池耗尽现象系统运行两小时后数据库报“连接池已满”或超时。原因某条查询路径没有释放 SqlConnection或者连接串里的 Max Pool Size 被调得极小。解决所有连接创建都走 using连接串统一放在 App.config并且不要明文放密码。对 OA 这类小系统Max Pool Size 保持默认即可真正要查的是“哪个方法里 new 了 SqlConnection 没扔掉”。还有个隐藏坑连接串里不要写死 localhost安装包分发到别的机器后连不上库正确做法是部署时改 App.config 里的服务器地址。5.4 打包后目标机器跑不起来装了却没反应现象在自己电脑上运行好好的做成安装包放到客户电脑双击没反应或报“应用程序无法启动”。原因目标机器缺少对应版本的 .NET Framework安装包没带上依赖的 DLL数据库连接串还是开发机的机器名。解决在 VS2015 里创建安装项目时把 .NET Framework 作为先决条件勾上并把项目引用设为“包含在安装包中”。杀毒软件也很喜欢在第一次运行时拦截 Winform 程序——部署完先去 Windows 事件查看器看 .NET 运行库错误比瞎试快得多。5.5 权限改了用户重新登录还是看不到新菜单现象管理员给某角色勾了“资产管理”菜单该角色用户反复重开客户端都看不到。原因登录时把菜单权限集合缓存进了静态 Session但代码里没有在重新登录时清空 Session或者主窗体菜单是登录后一次性生成的没走重新加载。解决退出登录流程里先清理 Session 再回登录页如果要求不重启就刷新权限就在主窗体提示“权限已更新请重新登录”而不是动态重构菜单——动态改菜单的坑比收益大。B-S 版要等 JWT 过期后新 token 才生效这也是同样的边界别在文档里写“即时生效”。5.6 敏感数据在连接里裸奔数据库加密连接串与加密连接现象网络抓包能看到 Winform 客户端传给数据库的账号密码和查询结果。原因C-S 直连数据库时TDS 协议默认没加密。解决SQL Server 连接串加 EncryptTrue 和 TrustServerCertificateFalse这是传输层加密磁盘上的连接串再用 DPAPI 或配置文件加密段处理避免安装目录里的 App.config 一打开就是明文密码。OA 里工资、绩效、合同条款都属于敏感数据桌面架构不等于内网就安全这个认知最好在设计阶段就建立起来。6. 进阶把Winform OA从“能跑”磨到“好交付”的几个土办法先给一个我每次交付前的验证清单。这套清单不用自动化框架就是三次手工操作但每次都能拦下第 5 章列过的那几类翻车现场。验证项做法通过标准断网/停库停掉 SQL Server 服务再点登录报错友好程序不退出双开客户端同账号开两个客户端同时审批同一单后提交的一方提示“已被处理”新装机器拿干净虚拟机跑安装包能装、能连库、能走通一条审批界面美化这件事很多新手一上来就找皮肤包结果换肤后控件变形、滚动条错位。我自己的习惯是先做三件不依赖第三方库的事统一字体微软雅黑 9pt和主色调给按钮和菜单配上统一的图标把窗体的 AutoScaleMode 设为 Dpi 并按 TableLayoutPanel 排布局客户换高分屏不至于错位。Winform 菜单折叠的箭头绘制这类细节很多人以为是个控件属性实际是用 ToolStripMenuItem 的 DropDown 事件配合自定义绘制实现的——改之前先想清楚美化是让信息更清楚不是表演。交付时还有两件事容易被忽略。一件是给程序做混淆或签名至少把强名称签上不然客户机器上被杀毒软件误报“未知发布者”另一件是把数据库升级脚本单独留一份别让实施人员手动改生产库。我在早期项目里吃过直接改库、改到客户数据对不上账的亏后来所有表结构变更都走 scripts 目录下的增量 SQL升级前先备份。现在回看这个项目的选型C# Winform C-S 架构的 OA 系统适合内部工具、局域网高频操作这类场景如果需求一开始就明确“手机也要用、出差也要批”直接做 B-S 更省事没必要先 C 后 B 折腾。我个人的习惯是凡是需求里出现“领导出差要看”这句话第一版就按 B-S 的 API 方式把 BLL 暴露出来Winform 只是其中一个客户端。这算是我被客户反复教育后的教训。希望帮到你。本文还有配套的精品资源点击获取