简介:面向有一定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?我的经验是:不一定要上 EF,Dapper 是平衡点。两个选择标准:第一,查询以单表和简单 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.QueryFirstOrDefault<UserDto>(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 版(Winform) | B-S 版(浏览器) |
|---|---|---|
| 表现层 | Winform 窗体、控件事件 | HTML/JS 页面,走 Web API |
| 会话 | 客户端内存模拟会话 | 服务端会话或 JWT |
| 部署 | 每台电脑装客户端 | 服务端一套,浏览器零安装 |
| 数据访问 | 客户端直连数据库 | 服务端连接数据库,客户端不碰库 |
| 权限控制 | 界面隐藏 + BLL 校验 | API 鉴权 + 按钮级权限 |
| 升级方式 | 客户端更新 | 刷新页面即是新版 |
这张表也解释了 C-S 的边界:客户端直连数据库时,数据库连接串、账号口令都散在每台电脑上,运维视角很难受。B-S 的代价是表现层交互没那么直接,复杂审批表单在浏览器里做拖拽流程图,工作量反而更大。
4.2 审批列表迁移到Web API:DAL和BLL保留,UI换成前端
如果你已经按第 2 章的三层结构写 C-S 版,迁移 B-S 版时,DAL 和 BLL 绝大多数方法可以直接复用。我做迁移时第一步不是新建前端工程,而是给 BLL 的方法加一个异步 API 壳:
[HttpGet("api/flow/todo")] public async Task<IActionResult> 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.dll,B-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 连接串加 Encrypt=True 和 TrustServerCertificate=False,这是传输层加密;磁盘上的连接串再用 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 只是其中一个客户端。这算是我被客户反复教育后的教训。希望帮到你。
本文还有配套的精品资源,点击获取