ARTICLE DETAIL

资讯详情

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

C# Winform C-S架构OA系统开发实战:分层设计、数据库映射与避坑指南

C# Winform C-S架构OA系统开发实战:分层设计、数据库映射与避坑指南 简介这份资源是北风网出品的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绑一个 ListT列值 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 层引用 BLLBLL 引用 DAL 和 ModelDAL 引用 ModelCommon 被所有层引用。引用方向单向不允许 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/DELETEExecuteQuery用于 SELECT。调用时这样写string sql SELECT * FROM UserInfo WHERE UserNamename AND Passwordpwd; 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 而不是弹一堆 FormOA 主界面左侧是菜单树右侧是内容区。新手常见做法是点一个菜单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, RoleIDPassword 存 MD5Department部门DeptID, DeptName, ParentIDParentID 支持树形部门Role角色权限RoleID, RoleName, MenuAuthMenuAuth 存菜单 ID 列表Document公文DocID, Title, Content, AuthorID, StatusStatus 控制流转状态Attachment附件AttachID, DocID, FileName, FilePath, FileSizeFilePath 存服务器相对路径建表时有两个容易忽略的点。第一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 绑定ListT 直接映射 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; } }把ListUserInfo绑到 DataGridView 上一行代码ListUserInfo users UserDAL.GetAllUsers(); dgvUsers.DataSource users;但这里有个高频需求数据库里Status字段是 0/1界面上要显示成 CheckBox。直接绑ListT的话DataGridView 会显示 0 和 1 两个数字用户看不懂。解决办法是在实体类里加一个只读属性或者用DataGridViewCheckBoxColumn手动映射。// 实体类里加一个转换属性 public bool IsActive { get { return Status 1; } set { Status value ? 1 : 0; } }然后在 DataGridView 的Columns集合里把IsActive列的ColumnType设为DataGridViewCheckBoxColumn。注意如果直接绑ListT自动生成的列类型是 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 Sourcelocal_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定时扫描SyncStatus0的记录逐条 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 绑 ListT 后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()。 解决所有数据库操作统一走SqlHelperSqlHelper内部用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 Actionint 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); }逻辑说明Actionint是 C# 内置的泛型委托省去自定义delegate的代码。?.Invoke保证没有订阅者时不报空引用。参数说明docId是传递的业务数据订阅者可以根据需要决定用不用。这个模式的好处是 UserControl 可以独立测试主窗体也不用关心 UserControl 内部怎么实现。我自己的习惯是每做一个新模块先想清楚它需要向外抛哪些事件再动手写界面。这个习惯帮我省掉了大量「改一处、崩三处」的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表