简介:管理信息系统的核心是让多方角色在统一平台上完成数据录入、查询与流转。桌面端Winform凭借开发周期短、部署简单,仍是局域网内部应用的高效选择。在三层架构中,UI、BLL、DAL各司其职,配合SQLite这类轻量级数据库,可快速构建稳定可维护的系统。DataGridView作为Winform中展示二维数据的核心控件,结合物理分页与参数化查询,能够流畅支撑千级线路数据的浏览与检索;联动查询与主题美化则为界面操作带来更高的效率与体验。上述技术模式广泛适用于旅行社订单管理、企业内部台账、ERP前端等场景。以旅游信息管理系统为例,完整呈现从表结构设计、分层实现到流程可视化的开发路径。
1. 旅游信息管理系统(Winform)到底解决什么问题
旅行社手里同时滚着线路、客户、订单三套电子表格,报价时还要回头数一个团收了几个人,这是旅游信息管理系统最常见的起点。这套系统要解决的不是写一个多漂亮的网站,而是让前台、计调、财务几台 Windows 电脑共用一份数据,录入、查询、改价、打印合同都能在桌面端完成。Winform 在局域网内部系统里仍有明确的位置:开发周期短,DataGridView 处理二维表格顺手,双击安装就能跑,不需要像 B/S 那样折腾服务器和浏览器兼容。下面按一条可落地的路径展开:先建表分层,再做 Winform 主窗体、DataGridView 分页与联动查询,接着做主题美化,最后用 Flowchart 思路画出线路行程预览。适合需要快速交付的小团队全栈,也适合第一次接手 Winform 维护的工程师参考。
2. 先定数据模型和分层,旅游信息管理系统的地基
2.1 为什么用三层架构而不是把查询写在按钮里
很多 Winform 小项目第一版都是“窗体加 SQL 字符串”,写起来爽,但线路、订单、客户变多以后,三个窗体各写一份重复查询,改一个字段名要搜索半天。常见做法是把代码拆成 UI、BLL、DAL 三层,外加一个 Models 类库放实体。UI 层只处理用户操作和控件绑定,BLL 层放价格计算、余位检查这类业务规则,DAL 层负责所有数据库读写。这样每一层都能单独替换,测试时也能直接调 DAL 验证 SQL。
TourManager.sln ├─ TourManager.UI // 窗体、控件、界面主题 │ └─ Forms ├─ TourManager.BLL // 业务规则:算价格、查余位 ├─ TourManager.DAL // SQLite 访问、SQL 封装 ├─ TourManager.Models // 实体类:TourRoute、Customer、OrderInfo └─ TourManager.Common // 通用扩展方法、常量这个结构不复杂,但已经够用。注意 BLL 不是所有项目都强制要,如果只是做增删改查,BLL 里全是透传方法,那就可以省掉;旅游管理系统里价格计算和订单状态流转有规则,单独分层能把这些业务逻辑从按钮事件里提出来,后续要改成 REST API 时也能直接复用。
2.2 用一句话说清表关系:线路、客户、订单怎么挂
旅游信息管理系统里最核心的关系就是“客户在什么时候订了哪条线路”。线路归属于类型,订单关联线路和客户,一张订单可以记成人价、儿童价和余位状态。SQLite 做桌面单机或局域网共享数据库都很方便,数据库文件就是一个文件,备份直接复制。注意 SQLite 默认不启用外键约束,每个连接打开后要执行一次PRAGMA foreign_keys = ON;,否则外键只写在建表语句里但不会真正检查。
PRAGMA foreign_keys = ON; CREATE TABLE TourCategory ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT NOT NULL UNIQUE ); CREATE TABLE TourRoute ( Id INTEGER PRIMARY KEY AUTOINCREMENT, CategoryId INTEGER NOT NULL REFERENCES TourCategory(Id), Name TEXT NOT NULL, Destination TEXT NOT NULL, Days INTEGER NOT NULL DEFAULT 1, Price DECIMAL(10,2) NOT NULL, StartDate TEXT NOT NULL, EndDate TEXT NOT NULL, Description TEXT ); CREATE TABLE Customer ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT NOT NULL, Phone TEXT NOT NULL, IdCard TEXT, CreatedAt TEXT DEFAULT (datetime('now','localtime')) ); CREATE TABLE OrderInfo ( Id INTEGER PRIMARY KEY AUTOINCREMENT, OrderNo TEXT NOT NULL UNIQUE, RouteId INTEGER NOT NULL REFERENCES TourRoute(Id), CustomerId INTEGER NOT NULL REFERENCES Customer(Id), AdultCount INTEGER NOT NULL DEFAULT 1, ChildCount INTEGER NOT NULL DEFAULT 0, OrderAmount DECIMAL(10,2) NOT NULL, Status INTEGER NOT NULL DEFAULT 0, CreateTime TEXT DEFAULT (datetime('now','localtime')) );| 表名 | 作用 | 关键字段 |
|---|---|---|
| TourCategory | 线路类型:周边、国内、出境 | Name |
| TourRoute | 具体线路,含目的地、天数、报价 | CategoryId、Destination、Price |
| Customer | 游客基础信息 | Name、Phone、IdCard |
| OrderInfo | 订单主表,记录客户订了哪条线路 | OrderNo、RouteId、CustomerId、OrderAmount |
日期字段我用 TEXT 存储,SQLite 对日期函数支持有限,用 ISO 格式的yyyy-MM-dd字符串反而方便比较和显示。金额用DECIMAL(10,2),不要在 C# 里用 float 保存钱,累计之后误差很难查。
2.3 实体类和 DAL 的最小可用代码
实体类要保持干净,不写任何数据库逻辑。下面这个TourRoute对应刚才的线路表,字段名和列名尽量一致,后面用 ADO.NET 映射时少写一层转换。
public class TourRoute { public long Id { get; set; } public long CategoryId { get; set; } public string Name { get; set; } public string Destination { get; set; } public int Days { get; set; } public decimal Price { get; set; } public string StartDate { get; set; } public string EndDate { get; set; } public string Description { get; set; } }DAL 层最简单的做法是用System.Data.SQLite,连接字符串放在 App.config 里,数据库文件放在程序目录下的data\tour.db。查询方法必须用参数化 SQL,不要用字符串拼LIKE,否则用户在搜索框里输入一个单引号就能让整个查询报错。
public class TourRouteDal { private readonly string _connString; public TourRouteDal(string connString) { _connString = connString; } public List<TourRoute> GetRoutes(string keyword) { var list = new List<TourRoute>(); const string sql = @"SELECT Id, CategoryId, Name, Destination, Days, Price, StartDate, EndDate, Description FROM TourRoute WHERE Name LIKE @kw OR Destination LIKE @kw ORDER BY Id DESC"; using (var conn = new SQLiteConnection(_connString)) using (var cmd = new SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue("@kw", "%" + keyword + "%"); conn.Open(); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new TourRoute { Id = reader.GetInt64(0), CategoryId = reader.GetInt64(1), Name = reader.GetString(2), Destination = reader.GetString(3), Days = reader.GetInt32(4), Price = reader.GetDecimal(5), StartDate = reader.GetString(6), EndDate = reader.GetString(7), Description = reader.IsDBNull(8) ? string.Empty : reader.GetString(8) }); } } } return list; } }这段代码的关键是cmd.Parameters.AddWithValue("@kw", ...),而不是把%keyword%直接拼进 SQL。参数化一方面避免 SQL 注入,另一方面让 SQLite 可以复用查询计划。读取时用 reader 的强类型方法,空值字段要判断IsDBNull,否则Description为 NULL 时GetString会抛异常。连接对象和命令对象都包在using里,保证异常时连接被释放。
3. Winform 主界面与核心功能:DataGridView 分页和联动查询
3.1 用布局器搭主窗体,别让控件悬空定位
新手做 Winform 界面习惯把一个按钮拖到坐标 (500, 300),换一台高分辨率电脑,按钮就飘到角落里。主界面应该用TableLayoutPanel和FlowLayoutPanel这类布局器来划分区域。常见做法是:窗体顶部放一个搜索条件区,中间放 DataGridView,底部放分页条。
public MainForm() { InitializeComponent(); var tableLayout = new TableLayoutPanel(); tableLayout.Dock = DockStyle.Fill; tableLayout.ColumnCount = 1; tableLayout.RowCount = 2; tableLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 90)); tableLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); tableLayout.Controls.Add(this.panelSearch, 0, 0); tableLayout.Controls.Add(this.panelList, 0, 1); this.Controls.Add(tableLayout); }panelSearch和panelList在窗体设计器里定义,这样窗体缩放时,搜索栏固定 90 像素高,列表区域占满剩余空间。顶部搜索栏内部再用FlowLayoutPanel从左向右排布控件,拖动窗口时控件不再需要手动调整坐标。布局器的另一个好处是给后面换分辨率或给客户加一台竖屏显示器时不必重排界面。
3.2 DataGridView 分页:几千条线路不能一次塞进表格
旅游线路表不会像流水账一样爆发增长,但几年下去几千条很正常。一次性SELECT *到 DataGridView 会拖慢首次加载,也会让内存里存一堆看不到的数据。实际操作时用LIMIT ... OFFSET ...做物理分页,先查总数算出页数,再只取当前页。
private void LoadRoutePage(int pageIndex) { int pageSize = 20; string where = string.Empty; if (!string.IsNullOrEmpty(_keyword)) { where = " WHERE Name LIKE @kw OR Destination LIKE @kw "; } string sqlCount = "SELECT COUNT(*) FROM TourRoute" + where; string sqlPage = "SELECT Id, Name, Destination, Days, Price, StartDate, EndDate " + "FROM TourRoute" + where + " ORDER BY Id LIMIT @pageSize OFFSET @offset;"; using (var conn = new SQLiteConnection(_connString)) using (var cmdCount = new SQLiteCommand(sqlCount, conn)) { if (_keyword.Length > 0) cmdCount.Parameters.AddWithValue("@kw", "%" + _keyword + "%"); conn.Open(); int total = Convert.ToInt32(cmdCount.ExecuteScalar()); _pageCount = Math.Max(1, (int)Math.Ceiling(total / (double)pageSize)); } var table = new DataTable(); using (var conn = new SQLiteConnection(_connString)) using (var cmdPage = new SQLiteCommand(sqlPage, conn)) { if (_keyword.Length > 0) cmdPage.Parameters.AddWithValue("@kw", "%" + _keyword + "%"); cmdPage.Parameters.AddWithValue("@pageSize", pageSize); cmdPage.Parameters.AddWithValue("@offset", (pageIndex - 1) * pageSize); conn.Open(); using (var adapter = new SQLiteDataAdapter(cmdPage)) { adapter.Fill(table); } } routeGrid.DataSource = table; labelPageInfo.Text = pageIndex + " / " + _pageCount; }查询里@offset的值是(pageIndex - 1) * pageSize,第一页从 0 开始。分页条上“上一页”“下一页”按钮只需要修改当前页并重新调用LoadRoutePage。这里对不得不在代码里拼接的where变量做了限制:where的内容是自己写死的常量,用户输入的关键字永远走@kw参数,不会破坏 SQL 结构。
DataGridView 配合分页时,以下属性值得在窗体加载时设置一次,否则会出现选不中整行、空行、列宽错乱等问题:
| DataGridView 属性 | 推荐值 | 说明 |
|---|---|---|
| AutoGenerateColumns | false | 分页返回的表列顺序不固定,手动绑定更可控 |
| SelectionMode | FullRowSelect | 单击选中整行,符合表格软件习惯 |
| ReadOnly | true | 防止用户在列表上误改单元格 |
| RowHeadersVisible | false | 隐藏最左侧灰色列头,界面更干净 |
| AllowUserToAddRows | false | 去掉表格下方的空白新行 |
3.3 下拉框联动:线路类型到目的地到景点列表
搜索区最常见的交互是先选线路类型,再选目的地,最后出现符合条件的线路。Winform 里把两个 ComboBox 串起来,核心是SelectedIndexChanged事件,注意事件里要做空值判断,否则窗体初始化时控件还没绑定完就触发事件,会出现空引用。
private void cmbType_SelectedIndexChanged(object sender, EventArgs e) { if (_loading) return; if (cmbType.SelectedValue == null) return; long categoryId = (long)cmbType.SelectedValue; List<string> destinations = _routeBll.GetDestinationsByCategory(categoryId); _loading = true; cmbDest.DataSource = destinations; _loading = false; ApplyFilter(); }_loading是一个类字段,初始为 false。给cmbDest重新赋 DataSource 时会触发下一次SelectedIndexChanged,用_loading做开关可以避免事件连锁。GetDestinationsByCategory在 BLL 层直接查SELECT DISTINCT Destination FROM TourRoute WHERE CategoryId = @id,返回List<string>,绑定到 ComboBox 后默认会显示每个城市的名字。
如果列表里需要展示景点宣传视频,常见做法是从ToolBox里引入 Windows Media Player 的 COM 组件,把player.URL设为视频文件路径。切换线路时先调用player.close(),再重新设置 URL,否则旧视频不会立即停止,文件也会被占用。需要提醒的是这类组件只适合内网环境,视频路径存相对路径更好,客户换机器后不必改数据库。
4. 让旅游信息管理系统脱离“学生作业”:C# Winform 主题与美化
4.1 C# Winform 主题实现的方法:自己写一个 ThemeManager
很多 Winform 项目交付后被说“界面像学生作业”,问题通常不在功能,而在默认控件的灰底和立体边框。与其每个窗体复制粘贴颜色,不如写一个ThemeManager静态类,集中管理主色、背景色和文字色,并提供递归应用方法。
public static class ThemeManager { public static Color Primary = Color.FromArgb(14, 121, 191); public static Color Back = Color.FromArgb(245, 247, 250); public static Color Fore = Color.FromArgb(30, 45, 60); public static void ApplyTheme(Control control) { foreach (Control c in control.Controls) { if (c is TextBox) { c.BackColor = Color.White; c.ForeColor = Fore; } else if (c is Button btn) { btn.FlatStyle = FlatStyle.Flat; btn.BackColor = Primary; btn.ForeColor = Color.White; btn.FlatAppearance.BorderSize = 0; btn.Cursor = Cursors.Hand; } else if (c is DataGridView grid) { grid.EnableHeadersVisualStyles = false; grid.ColumnHeadersDefaultCellStyle.BackColor = Primary; grid.ColumnHeadersDefaultCellStyle.ForeColor = Color.White; grid.RowHeadersVisible = false; grid.BackgroundColor = Color.White; grid.BorderStyle = BorderStyle.None; } else { c.BackColor = Back; c.ForeColor = Fore; } ApplyTheme(c); } } }递归调用ApplyTheme(c)是这里的关键,它会钻到 TableLayoutPanel、GroupBox、TabPage 这些容器控件里,把子控件也统一。TextBox 必须特殊处理,保持白底黑字,否则输入框和窗体会融为一体,用户很难找到输入位置。窗体入口只写一句ThemeManager.ApplyTheme(this);,所有子窗体都会继承这套风格。
4.2 不引入 AntdUI 也能做出统一的弹出输入框
Winform 项目想要更漂亮的界面,也可以直接引入 AntdUI 这类第三方控件库,它们提供了现代风格的按钮、输入框、日期选择器和弹出框,使用后界面质感提升明显。不过每个控件的 API 依赖具体版本,团队必须固定同一个 NuGet 包版本,否则升级后布局属性会变。如果不想引入大型依赖,我一般会自封装一个模态输入框,效果和第三方库的弹出输入框一致:一个居中窗体加一个 TextBox,两个按钮。
public static string ShowInput(string title, string label, IWin32Window owner = null) { using (var form = new Form()) { form.Text = title; form.Size = new Size(420, 180); form.FormBorderStyle = FormBorderStyle.FixedDialog; form.StartPosition = FormStartPosition.CenterParent; form.MaximizeBox = false; form.MinimizeBox = false; var lbl = new Label { Text = label, Left = 20, Top = 20, Width = 360 }; var txt = new TextBox { Left = 20, Top = 52, Width = 360 }; var btnOk = new Button { Text = "确定", DialogResult = DialogResult.OK, Left = 270, Top = 95, Width = 110 }; var btnCancel = new Button { Text = "取消", DialogResult = DialogResult.Cancel, Left = 150, Top = 95, Width = 100 }; btnOk.BackColor = ThemeManager.Primary; btnOk.ForeColor = Color.White; btnOk.FlatStyle = FlatStyle.Flat; form.Controls.AddRange(new Control[] { lbl, txt, btnOk, btnCancel }); form.AcceptButton = btnOk; form.CancelButton = btnCancel; if (form.ShowDialog(owner) != DialogResult.OK) return null; return txt.Text.Trim(); } }这样每次需要弹窗接收一个字符串时,调用一行InputDialog.ShowInput("新增线路", "线路名称", this)就能拿到用户输入。按钮的DialogResult已设置,点在“确定”上时会自动关闭窗体并返回DialogResult.OK,不需要写按钮点击事件。自封装的好处是所有地方都能复用同一个样式,后面想调整成圆角或加图标,只改这一个文件。
4.3 统一控件外观的几个必调参数
界面美化不只是换一个背景色。以下几个参数对最终观感影响很大,新控件放入窗体前先在属性窗口里过一遍:
| 控件/属性 | 推荐值 | 说明 |
|---|---|---|
| Button.FlatStyle | Flat | 去掉系统立体边框 |
| Button.FlatAppearance.BorderSize | 0 | 用纯色表达按钮,不需要边框 |
| Button.Cursor | Hand | 鼠标悬停变手型,给用户可点击提示 |
| DataGridView.CellBorderStyle | SingleHorizontal | 只保留水平分隔线,表格更透气 |
| Form.Font | Microsoft YaHei UI 9pt | 中文渲染比默认宋体好得多 |
| Panel.BackColor | 与 Back 环境色一致 | 避免不同区域出现多种灰 |
另外建议给所有窗体的StartPosition设为CenterScreen,弹出的子窗体用CenterParent,窗口居中能让第一次接触系统的人更快找到操作焦点。如果做了主题类,就不需要再逐个控件去改颜色,这比当初拖控件时顺手设置一遍要省很多维护时间。
5. 用 Flowchart 思路在 Winform 里预览旅游线路
演示项目时如果只给客户看一堆 DataGridView,对方很难直观理解这条线路是怎么走的。我习惯在“线路详情”窗体里加一个自定义 Panel,把出发、景点、用餐、住宿、返程这些节点按顺序用线条连起来,相当于一个简化版 Flowchart。不借用任何流程图控件,直接用 GDI+ 在Paint事件里画,渲染速度快,也能稳定打包部署。
private void itineraryPanel_Paint(object sender, PaintEventArgs e) { string[] stops = { "出发", "高铁/飞机", "景点A", "午餐", "景点B", "酒店", "返程" }; int count = stops.Length; int panelWidth = itineraryPanel.ClientSize.Width; int stepX = Math.Max(100, panelWidth / (count + 1)); int topY = 40; int boxWidth = 70; int boxHeight = 30; var g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; using (var linePen = new Pen(Color.FromArgb(14, 121, 191), 2f)) using (var fillBrush = new SolidBrush(Color.FromArgb(240, 248, 255))) using (var textBrush = new SolidBrush(Color.FromArgb(30, 45, 60))) { for (int i = 0; i < count; i++) { int centerX = (i + 1) * stepX; var rect = new Rectangle(centerX - boxWidth / 2, topY, boxWidth, boxHeight); g.FillRectangle(fillBrush, rect); g.DrawRectangle(linePen, rect); var textRect = new Rectangle(rect.X, rect.Y, rect.Width, rect.Height); StringFormat format = new StringFormat(); format.Alignment = StringAlignment.Center; format.LineAlignment = StringAlignment.Center; g.DrawString(stops[i], Font, textBrush, textRect, format); if (i > 0) { int startX = (i - 1) * stepX + boxWidth / 2; int endX = centerX - boxWidth / 2 - 8; int lineY = topY + boxHeight / 2; g.DrawLine(linePen, startX, lineY, endX, lineY); g.DrawLine(linePen, endX, lineY, endX - 6, lineY - 5); g.DrawLine(linePen, endX, lineY, endX - 6, lineY + 5); } } } }节点宽度固定 70,如果文本太长就截断再加省略号。stepX根据面板宽度自动计算,窗口拉宽时节点间距跟着变大。不要忘记在窗体构造函数里给 Panel 挂上Resize事件,否则窗口最大化后图不会重画:
itineraryPanel.Resize += (s, e) => itineraryPanel.Invalidate();Invalidate会触发一次Paint,让流程图及时刷新。画完预览后还能顺带解决打印需求:把 Panel 内容用DrawToBitmap复制到一张 Bitmap 上,变相生成一张行程单,再交给PrintDocument输出,打印时把这个 Bitmap 直接画到PrintPageEventArgs.Graphics上即可。整个 Flowchart 区域只依赖一个 Panel 和一段绘制逻辑,不引入额外控件,也不会出现滚动条、缩放比例错乱之类的 Winform 老问题。
本文还有配套的精品资源,点击获取