简介:这份资源是北风网出品的C# Winform下C/S架构办公OA系统开发课程配套PDF,面向具备一定C#基础、希望提升分层架构与实战能力的学习者。内容以Winform技术复刻WEB版OA系统,涵盖登录、递归菜单树、窗体通信、日志记录、机构部门管理、DataGridView应用、动态控件与自定义控件开发、角色权限分配、内部短消息、信箱及文件上传下载删除等模块,并涉及代码生成工具、VSS源码管理与编码规范。资源包共1个PDF文件,约900KB,便于随时查阅课程讲义与难点解析。目前已有186人学习。读者可借此掌握多层架构设计、SQL Server数据库操作、基于角色与个人的混合授权思想及流转技术,跟随渐进式讲解从零构建完整项目,同时了解团队开发中的常见错误与实用技巧,适合作为入门到进阶的实战参考。
1. 从一份 Winform OA 源码说起:C-S 架构到底解决了什么
很多做企业信息化的朋友都遇到过这种局面:公司内部有一套用了七八年的审批流,跑在浏览器上,表单稍微复杂一点就卡,附件上传大一点就转圈,打印个红头文件还得装一堆控件。这时候有人翻出一份「北风网-基于C#Winform下C-S架构的办公OA系统开发」的代码包,说要不咱们换回桌面端试试。你心里第一反应大概是:Winform 都什么年代的东西了,还能撑起一套 OA?
先别急着下结论。C-S 架构在 OA 这个场景里从来没有真正退场,只是被 B-S 的声量盖住了。OA 的核心诉求其实很朴素:表单录入要快、附件传输要稳、打印格式要准、离线也要能用。这四条恰好是 Winform 的舒适区。浏览器里做一套带复杂校验的报销单,前端框架换三代都不一定稳;Winform 里拖一个 DataGridView,绑一个 List<T>,列值 0/1 直接映射成 CheckBox,半小时能跑通。这不是技术倒退,是场景匹配。
这份代码包的价值不在于它写得多优雅,而在于它把 C-S 架构下 OA 的骨架搭全了:登录鉴权、主窗体导航、部门与用户管理、公文流转、附件上传下载、打印导出。你拿到手能直接看到一条完整的链路是怎么串起来的,而不是对着一堆零散的 Winform 控件教程自己拼。适合谁看?适合已经会写 C# 基础语法、能拖控件、但没独立做过一套完整桌面业务系统的开发者;也适合手里有 B-S 版 OA、想补一套 C-S 客户端做混合部署的团队。
后面几章我会按「架构怎么分层 → 数据库和实体怎么落地 → 核心模块怎么实现 → 踩过哪些坑 → 怎么验证和进阶」的顺序拆开讲。代码以这份包里的 C-S 版为主线,B-S 版作为对照,讲清楚两套代码在同一个业务模型下各自的取舍。你不需要先看完整个包再动手,跟着章节走,每章都能跑出一个可验证的小结果。
2. C-S 架构分层与 Winform 项目骨架搭建
2.1 为什么 OA 的 C-S 端要分三层而不是全塞进 Form
新手做 Winform 最容易犯的错,是把 SQL 语句、业务判断、界面刷新全写在按钮的 Click 事件里。一个btnSubmit_Click方法三百行,改一个字段要翻半天。这份 OA 代码包采用的是典型三层结构:UI 层(Form)、业务逻辑层(BLL)、数据访问层(DAL),中间用实体类(Model)传递数据。
分层的直接好处是:换数据库只动 DAL,改业务规则只动 BLL,调界面只动 Form。OA 系统里「请假单」和「报销单」的审批逻辑高度相似,如果逻辑写在 Form 里,就得复制两遍;放在 BLL 里,抽一个基类就能复用。
具体到项目结构,常见做法是这样组织的:
OASystem/ ├── OASystem.UI/ # Winform 窗体,只负责展示和收集输入 │ ├── Forms/ │ │ ├── FrmLogin.cs │ │ ├── FrmMain.cs │ │ └── FrmLeaveApply.cs │ └── Program.cs ├── OASystem.BLL/ # 业务规则,审批流转、权限判断 │ ├── UserManager.cs │ └── LeaveManager.cs ├── OASystem.DAL/ # 只跟数据库打交道 │ ├── SqlHelper.cs │ └── UserDAL.cs ├── OASystem.Model/ # 实体类,跟数据库表一一对应 │ ├── UserInfo.cs │ └── LeaveInfo.cs └── OASystem.Common/ # 工具类,加密、日志、配置读取 └── Md5Helper.cs这个结构不是唯一解,但它是这份代码包采用的、也是企业里最不容易出错的划分方式。UI 层引用 BLL,BLL 引用 DAL 和 Model,DAL 引用 Model,Common 被所有层引用。引用方向单向,不允许 DAL 反过来引用 UI。
2.2 用 SqlHelper 封装数据库访问:参数化查询与连接管理
DAL 层最核心的一个类就是SqlHelper。它的职责只有两件事:管理数据库连接、执行参数化 SQL。很多 Winform 项目翻车就翻在拼接 SQL 字符串上,用户输入一个单引号就报错,严重一点直接 SQL 注入。
public class SqlHelper { // 从配置文件读取,不硬编码 private static readonly string connStr = ConfigurationManager.ConnectionStrings["OAConn"].ConnectionString; // 执行增删改,返回受影响行数 public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (SqlConnection conn = new SqlConnection(connStr)) { using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (parameters != null && parameters.Length > 0) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } } // 执行查询,返回 DataTable public static DataTable ExecuteQuery(string sql, params SqlParameter[] parameters) { using (SqlConnection conn = new SqlConnection(connStr)) { using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (parameters != null && parameters.Length > 0) cmd.Parameters.AddRange(parameters); SqlDataAdapter adapter = new SqlDataAdapter(cmd); DataTable dt = new DataTable(); adapter.Fill(dt); return dt; } } } }逻辑说明:using块保证连接和命令对象在方法结束时自动释放,即使抛异常也不会泄漏连接。params SqlParameter[]让调用方可以传任意数量的参数,避免字符串拼接。参数说明:connStr从App.config的connectionStrings节点读取,部署时改配置不用重新编译。ExecuteNonQuery用于 INSERT/UPDATE/DELETE,ExecuteQuery用于 SELECT。
调用时这样写:
string sql = "SELECT * FROM UserInfo WHERE UserName=@name AND Password=@pwd"; SqlParameter[] ps = { new SqlParameter("@name", txtUser.Text.Trim()), new SqlParameter("@pwd", Md5Helper.Encrypt(txtPwd.Text.Trim())) }; DataTable dt = SqlHelper.ExecuteQuery(sql, ps);注意密码存的是 MD5 值,登录时先加密再比对,数据库里不存明文。这是 OA 系统最基本的安全底线,代码包里Md5Helper已经封装好了。
2.3 主窗体导航:用 Panel 容器切换 UserControl 而不是弹一堆 Form
OA 主界面左侧是菜单树,右侧是内容区。新手常见做法是点一个菜单new FrmLeaveApply().ShowDialog(),结果弹窗套弹窗,任务栏一排窗口,用户体验很差。这份代码包用的是「单主窗体 + 多 UserControl」模式:主窗体FrmMain里放一个Panel作为内容容器,每个功能模块做成UserControl,点击菜单时清空 Panel 再加载对应控件。
private void LoadModule(UserControl uc) { panelContent.Controls.Clear(); // 清掉上一个模块 uc.Dock = DockStyle.Fill; // 填满容器 panelContent.Controls.Add(uc); // 加载新模块 } private void menuLeave_Click(object sender, EventArgs e) { LoadModule(new UCLeaveApply()); }逻辑说明:Dock = DockStyle.Fill让 UserControl 自动跟随 Panel 大小变化,不用手动算坐标。参数说明:panelContent是主窗体上预留的内容面板,建议设置Anchor或Dock让它随窗体缩放。这种模式的好处是模块之间切换不产生新窗口,内存占用可控,也方便做权限控制——没有权限的菜单直接不加载对应 UserControl。
提示:UserControl 里如果需要跟主窗体通信(比如刷新状态栏),不要直接引用
FrmMain,用事件或委托解耦。C# 委托和事件在这里是刚需,后面第 5 章会展开。
3. 数据库设计与实体类映射:从建表到 DataGridView 绑定
3.1 OA 核心表结构:用户、部门、角色、公文、附件
一套 OA 最少需要五张核心表。这份代码包的建表脚本在OASystem.DAL/DbScripts目录下,我按实际业务补全了字段说明:
| 表名 | 用途 | 关键字段 | 说明 |
|---|---|---|---|
| UserInfo | 用户账号 | UserID, UserName, Password, DeptID, RoleID | Password 存 MD5 |
| Department | 部门 | DeptID, DeptName, ParentID | ParentID 支持树形部门 |
| Role | 角色权限 | RoleID, RoleName, MenuAuth | MenuAuth 存菜单 ID 列表 |
| Document | 公文 | DocID, Title, Content, AuthorID, Status | Status 控制流转状态 |
| Attachment | 附件 | AttachID, DocID, FileName, FilePath, FileSize | FilePath 存服务器相对路径 |
建表时有两个容易忽略的点。第一,UserInfo的UserName要加唯一索引,否则并发注册会出现重复账号。第二,Attachment的FilePath不要存绝对路径,存相对于附件根目录的路径,换服务器时不用改数据。
CREATE TABLE UserInfo ( UserID INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL UNIQUE, Password NVARCHAR(64) NOT NULL, DeptID INT NOT NULL, RoleID INT NOT NULL, CreateTime DATETIME DEFAULT GETDATE() );3.2 实体类与 DataGridView 绑定:List<T> 直接映射 CheckBox 列
实体类就是数据库表的 C# 映射,字段名和类型一一对应。这份代码包里UserInfo.cs长这样:
public class UserInfo { public int UserID { get; set; } public string UserName { get; set; } public string Password { get; set; } public int DeptID { get; set; } public int RoleID { get; set; } public DateTime CreateTime { get; set; } }把List<UserInfo>绑到 DataGridView 上,一行代码:
List<UserInfo> users = UserDAL.GetAllUsers(); dgvUsers.DataSource = users;但这里有个高频需求:数据库里Status字段是 0/1,界面上要显示成 CheckBox。直接绑List<T>的话,DataGridView 会显示 0 和 1 两个数字,用户看不懂。解决办法是在实体类里加一个只读属性,或者用DataGridViewCheckBoxColumn手动映射。
// 实体类里加一个转换属性 public bool IsActive { get { return Status == 1; } set { Status = value ? 1 : 0; } }然后在 DataGridView 的Columns集合里,把IsActive列的ColumnType设为DataGridViewCheckBoxColumn。注意:如果直接绑List<T>,自动生成的列类型是 TextBox,需要手动改列类型,或者在AutoGenerateColumns之后遍历列做替换。这个坑我在第 4 章会详细说。
3.3 用 Sqlite 做本地缓存:离线场景下的数据同步
OA 系统经常遇到外勤人员没网的情况,填了单子提交不了。这份代码包的 B-S 版没处理这个,但 C-S 版可以加一层本地 Sqlite 缓存。思路是:提交时先写本地 Sqlite,标记为「待同步」,网络恢复后后台线程批量推到服务器。
// 本地缓存表 string createSql = @"CREATE TABLE IF NOT EXISTS LocalLeave ( LocalID INTEGER PRIMARY KEY AUTOINCREMENT, LeaveType TEXT, StartDate TEXT, EndDate TEXT, Reason TEXT, SyncStatus INTEGER DEFAULT 0)"; // 写入本地 using (var conn = new SQLiteConnection("Data Source=local_oa.db")) { conn.Open(); var cmd = new SQLiteCommand("INSERT INTO LocalLeave (LeaveType,StartDate,EndDate,Reason) VALUES (@t,@s,@e,@r)", conn); cmd.Parameters.AddWithValue("@t", leaveType); cmd.Parameters.AddWithValue("@s", startDate); cmd.Parameters.AddWithValue("@e", endDate); cmd.Parameters.AddWithValue("@r", reason); cmd.ExecuteNonQuery(); }逻辑说明:SyncStatus为 0 表示未同步,1 表示已同步。后台用一个Timer或Task定时扫描SyncStatus=0的记录,逐条 POST 到服务器接口,成功后更新为 1。参数说明:Sqlite 连接字符串指向本地文件,不需要服务器。注意 Sqlite 不支持并发写,多个线程同时写要加锁,或者用lock包住写入操作。
注意:本地缓存只适合「提交类」操作,查询类操作还是走服务器,否则数据一致性很难保证。
4. 核心模块实现与避坑排查
4.1 登录鉴权与 MD5 加密:别把密码明文写进配置
登录流程本身不复杂:用户输入账号密码,DAL 查库比对,成功则记录CurrentUser全局对象,打开主窗体。但有几个细节容易翻车。
第一,密码加密。代码包里用的是 MD5,虽然现在不推荐用于高安全场景,但 OA 内部系统够用。关键是别在数据库里存明文,也别在代码里硬编码一个「万能密码」做后门。
public static string Encrypt(string input) { using (MD5 md5 = MD5.Create()) { byte[] bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(input)); StringBuilder sb = new StringBuilder(); foreach (byte b in bytes) sb.Append(b.ToString("x2")); return sb.ToString(); } }第二,登录失败次数限制。连续输错五次锁定账号十分钟,这个逻辑放在 BLL 层,用内存字典记录失败次数即可,不用建表。
第三,CurrentUser全局对象。常见做法是建一个静态类GlobalData,登录成功后把UserInfo存进去,其他模块直接读。但要注意:静态变量在单元测试里不好清理,如果以后要写测试,建议改成依赖注入。
4.2 公文流转状态机:用枚举代替魔法数字
公文有「草稿、待审、审核中、已通过、已驳回」五个状态。新手容易在代码里写if (status == 2),过两个月自己都忘了 2 代表什么。正确做法是定义枚举:
public enum DocStatus { Draft = 0, // 草稿 Pending = 1, // 待审 Reviewing = 2, // 审核中 Approved = 3, // 已通过 Rejected = 4 // 已驳回 }状态流转规则写在 BLL 里,比如「只有草稿可以提交」「只有待审可以审核」。每次流转前先校验当前状态是否允许该操作,不允许就抛业务异常,UI 层捕获后弹提示。
public void Submit(int docId) { var doc = DocDAL.GetById(docId); if (doc.Status != (int)DocStatus.Draft) throw new InvalidOperationException("只有草稿状态可以提交"); DocDAL.UpdateStatus(docId, (int)DocStatus.Pending); }4.3 附件上传下载:大文件分块与断点续传的简化实现
OA 里附件动辄几十兆,直接File.ReadAllBytes再一次性发过去,内存直接爆。这份代码包用的是分块上传:客户端把文件切成 1MB 的块,逐块发送,服务端按顺序拼接。
const int ChunkSize = 1024 * 1024; // 1MB using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read)) { byte[] buffer = new byte[ChunkSize]; int bytesRead; int chunkIndex = 0; while ((bytesRead = fs.Read(buffer, 0, ChunkSize)) > 0) { byte[] chunk = new byte[bytesRead]; Array.Copy(buffer, chunk, bytesRead); UploadChunk(fileName, chunkIndex, chunk); chunkIndex++; } }逻辑说明:每次读 1MB 到缓冲区,实际读到的字节数可能小于 1MB(文件末尾),所以用bytesRead重新拷贝一个精确大小的数组再发送。参数说明:ChunkSize可以根据网络状况调整,内网可以调到 4MB,外网建议 512KB。断点续传的简化做法是:服务端记录已接收的块索引,客户端上传前先问一下「从第几块开始」,跳过已上传的块。
4.4 避坑排查:Winform OA 开发中最容易翻车的五个点
现象一:DataGridView 绑 List<T> 后,0/1 列显示成数字而不是 CheckBox。原因:自动生成的列类型是DataGridViewTextBoxColumn,不会根据属性类型自动变成 CheckBox。 解决:在DataSource赋值后,遍历列找到目标列,替换为DataGridViewCheckBoxColumn,或者直接在设计器里手动添加列并设置DataPropertyName。
现象二:跨线程更新 UI 报「线程间操作无效」。原因:后台线程(比如文件上传线程)直接改了 Label 的 Text。 解决:用Control.Invoke或BeginInvoke把更新操作切回 UI 线程。
this.Invoke(new Action(() => { lblProgress.Text = $"{percent}%"; }));现象三:程序关闭后 SqlConnection 没释放,数据库连接数暴涨。原因:SqlConnection没有用using包住,或者异常路径下没走到Close()。 解决:所有数据库操作统一走SqlHelper,SqlHelper内部用using保证释放。
现象四:附件上传到一半网络断了,服务端留下一个不完整的文件。原因:没有做完整性校验。 解决:上传完成后,客户端发送文件的总 MD5,服务端比对拼接后的 MD5,不一致就删除并返回失败。
现象五:发布到客户机器上报「找不到 ConfigurationManager」。原因:项目没有引用System.Configuration程序集。 解决:在解决方案资源管理器里右键引用,添加System.Configuration,或者改用appsettings.json加Microsoft.Extensions.Configuration。
5. 从能跑到好用:C-S 与 B-S 混合部署的验证与进阶
5.1 用日志和埋点验证 OA 核心链路的稳定性
代码跑起来只是第一步,能不能在客户环境稳定跑三个月才是关键。我一般会在Common层加一个基于 NLog 的日志封装,记录三类信息:登录成功/失败、公文状态流转、附件上传结果。日志按天切分,保留 30 天。
private static readonly Logger logger = LogManager.GetCurrentClassLogger(); public static void LogLogin(string userName, bool success) { logger.Info($"登录 {(success ? "成功" : "失败")}:{userName},时间:{DateTime.Now}"); }验证方法:部署后让测试人员跑一轮完整流程——登录、新建公文、上传附件、提交审批、审核通过、导出打印。然后去日志目录看有没有异常堆栈。重点看三个指标:登录失败率是否异常高、公文流转是否有卡在中间状态超过 24 小时的、附件上传失败后是否有重试记录。
5.2 C-S 客户端与 B-S 后台共用同一套数据库的注意事项
很多团队的做法是:C-S 客户端给内部高频操作用户,B-S 后台给领导审批用,两边共用同一个数据库。这种混合部署能跑,但有几个约束必须遵守。
第一,数据库连接数要算够。Winform 客户端每个用户至少占一个连接,B-S 那边连接池也要占。按「同时在线人数 × 1.5」估算最大连接数,在数据库服务器上设好上限。
第二,字段变更要同步。C-S 端的实体类是编译进去的,B-S 端如果是动态 SQL 可能不受影响,但如果 B-S 也用了 ORM 映射,加字段时两边都要改。建议所有表结构变更走统一的 SQL 脚本,放在版本控制里。
第三,时间字段统一用数据库服务器时间。C-S 客户端机器时间可能不准,GETDATE()比DateTime.Now可靠。
5.3 一个具体技巧:用委托和事件解耦 UserControl 与主窗体
最后分享一个我在多个 Winform 项目里反复用的技巧。主窗体需要知道 UserControl 里发生了什么(比如「公文提交成功,刷新待办数量」),但 UserControl 不应该直接引用主窗体。用委托和事件做解耦:
// UserControl 里定义事件 public event Action<int> DocumentSubmitted; private void btnSubmit_Click(object sender, EventArgs e) { int docId = SaveDocument(); DocumentSubmitted?.Invoke(docId); // 通知订阅者 } // 主窗体里订阅 private void menuDoc_Click(object sender, EventArgs e) { var uc = new UCDocument(); uc.DocumentSubmitted += (docId) => { RefreshTodoCount(); // 主窗体自己的逻辑 }; LoadModule(uc); }逻辑说明:Action<int>是 C# 内置的泛型委托,省去自定义delegate的代码。?.Invoke保证没有订阅者时不报空引用。参数说明:docId是传递的业务数据,订阅者可以根据需要决定用不用。这个模式的好处是 UserControl 可以独立测试,主窗体也不用关心 UserControl 内部怎么实现。
我自己的习惯是:每做一个新模块,先想清楚它需要向外抛哪些事件,再动手写界面。这个习惯帮我省掉了大量「改一处、崩三处」的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取