我是路标
本系列文章
- 12306订票客户端 FOR .NET 演示项目 【7】登录9年前 (2015-08-18)
- 12306订票客户端 FOR .NET 演示项目 【6】验证码输入9年前 (2015-08-12)
- 12306订票客户端 FOR .NET 演示项目 【5】获得余票数据9年前 (2015-06-10)
- 12306订票客户端 FOR .NET 演示项目 【4】界面框架&基础数据初始化9年前 (2015-06-08)
- 12306订票客户端 FOR .NET 演示项目 【3】流程分析和项目规划9年前 (2015-05-28)
- 12306订票客户端 FOR .NET 演示项目 【2】准备工具9年前 (2015-05-22)
- 12306订票客户端 FOR .NET 演示项目 【1】项目概况9年前 (2015-05-19)
FSLIB.NETWORK 网络库系列文章
- 12306订票助手.NET V10.6.1 发布8年前 (2016-09-01)
- 开源 FSLIB.NETWORK 库 2.2.0.08年前 (2016-08-02)
- FSLIB.NETWORK手册(1) · 基本概念和流程8年前 (2016-05-05)
- 原创FSLib.Network库更新 2.0.0 版8年前 (2016-04-05)
- 原创FSLib.Network库更新 1.6.0版(目前专注于HTTP的高性能高易用性网络库)9年前 (2015-12-13)
- 玩具系列:批量QQ群签到工具v2 (暂时屏蔽自定义位置功能)9年前 (2015-08-29)
- 玩具系列:批量QQ群签到工具(支持自定义位置)9年前 (2015-08-28)
- 12306订票助手.NET 8.0.8 发布9年前 (2015-08-21)
- 12306订票客户端 FOR .NET 演示项目 【7】登录9年前 (2015-08-18)
- 12306订票客户端 FOR .NET 演示项目 【6】验证码输入9年前 (2015-08-12)
- 12306订票客户端 FOR .NET 演示项目 【5】获得余票数据9年前 (2015-06-10)
- 原创FSLib.Network库发布 1.5 版9年前 (2015-06-09)
- 12306订票客户端 FOR .NET 演示项目 【4】界面框架&基础数据初始化9年前 (2015-06-08)
- 12306订票客户端 FOR .NET 演示项目 【3】流程分析和项目规划9年前 (2015-05-28)
- 12306订票客户端 FOR .NET 演示项目 【2】准备工具9年前 (2015-05-22)
- 12306订票客户端 FOR .NET 演示项目 【1】项目概况9年前 (2015-05-19)
- 原创FSLib.Network库发布 1.4 版9年前 (2015-05-08)
- 放一个抓取网页的信息监控小工具源码9年前 (2015-04-27)
- FSLib.Network网络库使用教程[2] 实例教程·美女们快到硬盘里来!9年前 (2015-01-30)
- FSLib.Network网络库使用教程[1] 基本使用9年前 (2015-01-19)
- 原创FSLib.Network库(目前专注于HTTP的高性能高易用性网络库)9年前 (2015-01-18)
- 构造主窗体并将大致界面构造出来
- 将UI界面与当前订票的上下文关系关联起来
- 完成基本数据的准备和相关过程中的界面等待
4.1 构造大致界面
为了简便起见,这里我们约定使用比较简单的界面布局,暂时不添加复杂的功能选项界面。初步规划的界面如下图所示(这是个原型图,表以为我设计是多么的专业,其实我从来不用这玩意儿,这次拿出来耍耍只是为了装逼):
从原型图可以看出来,这是一个比较简单直接的订票界面,没有考虑到刷票等比较复杂的功能,也并没有设计什么多账户登录的功能。一是这样可以简化设计,比较复杂的内容不适合写成教程,二是部分高级功能可以通过后期变更设计来引入。
依据上面设计的原型,在项目的主窗口(UI/Dialogs/MainForm
)中,设计出如下的界面(点击小图查看大图)。
这里使用DataGridView
作为数据展示控件,主要是为了简便。使用ListView
显示更快速,但是ListView
要显示多种信息会比较麻烦(自绘很痛苦,所以订票助手.NET使用的是BetterListView
第三方库+自绘实现的,但这里并不想要引入第三方库),同时嵌入控件(预定按钮)比较麻烦,虽然可以解决,但对于一个DEMO来说不是好事儿。好吧主要还是因为我懒。
备注:图中各个地方的图标都是出自一款叫做Basic Set
的图标集,版权归原作者所有;图中的DataGridView
尚未设置任何数据绑定信息。
现在按Ctrl+F5,应该已经可以编译运行了。但此时还是一个空窗体,没有任何实际的功能。
4.2 实现查询信息编辑
4.2.1 发站和到站
其实发站到站是一个比较复杂的事情,需要列出所有的车站信息并可以进行检索。严格来说检索还要实现多种检索方式:通过电报码检索,通过拼音检索,通过拼音首字母以及通过文本检索。但是要实现这么复杂的检索对于WinForm来说其实并不是那么简单的事情。在订票助手.NET中,是通过SimplePopup+自定义检索以及自定义窗体实现的,实现的代码还颇为复杂。
为了简便起见,这里使用ComboBox来实现选择车站,并且暂定只支持站点的拼音首字母检索。在这么简化设计之后,就可以使用ComboBox
自带的Suggestion来实现。有能力有时间的同学可以试着通过自定义的无焦点窗口来尝试进行更复杂智能的站点检索。
这里并没有牵涉到数据的填充,数据参见后面的章节。
4.2.2 日期编辑
参考12306的设定,日期最小值为当天,而最大值为当天之后的第59天,而当前默认值为明天。
以下代码包含了初始化代码,在MainForm
中绑定了Load
事件,在Load
事件中调用InitQueryParamEdit()
函数初始化查询参数编辑。Load
事件绑定的函数是一个统一的初始化入口,后面还有其它的初始化代码要插入MainForm_Load
函数中。
public MainForm() { InitializeComponent(); Load += MainForm_Load; } private void MainForm_Load(object sender, EventArgs e) { InitQueryParamEdit(); } #region 查询参数编辑 /// <summary> /// 初始化查询参数编辑 /// </summary> void InitQueryParamEdit() { dtDate.MinDate = DateTime.Now.Date; dtDate.MaxDate = DateTime.Now.Date.AddDays(59); dtDate.Value = DateTime.Now.AddDays(1); } #endregion
4.2.3 状态栏链接和操作状态显示
如果看过上面的UI图的话可以看到我们加了好几个风骚的链接。那么现在来实现其点击打开的效果。为了实现简单的代码,链接对应的网址都已经放在其Tag
属性中了。这样实现后,便可以用简单的代码来初始化它们的事件。别忘记了在Load
事件处理函数中加入对InitStatusBar
的调用。
/// <summary> /// 初始化状态栏 /// </summary> void InitStatusBar() { //绑定链接处理 foreach (var label in st.Items.OfType<ToolStripStatusLabel>().Where(s => s.IsLink && s.Tag != null)) { label.Click += (s, e) => { try { Process.Start((s as ToolStripStatusLabel).Tag.ToString()); } catch (Exception ex) { MessageBox.Show(this, "错误:无法打开网址,错误信息:" + ex.Message + "。", "错误", MessageBoxButtons.OK, MessageBoxIcon.Asterisk); } }; } }
同时,由于我们想用状态栏前的文本和滚动条来指示当前的操作状态,所以在这里同时加入两个函数表示开始操作和操作结束,以提示用户等待。
/// <summary> /// 表示开始一个操作 /// </summary> /// <param name="opName">当前操作的名称</param> /// <param name="maxItemsCount">当前操作如果需要显示进度,那么提供任务总数;不提供则为跑马灯等待</param> /// <param name="disableForm">是否禁用当前窗口的操作</param> void BeginOperation(string opName, int maxItemsCount = 100, bool disableForm = false) { stStatus.Text = opName.DefaultForEmpty("正在操作,请稍等..."); stProgress.Visible = true; stProgress.Maximum = maxItemsCount > 0 ? maxItemsCount : 100; stProgress.Style = maxItemsCount > 0 ? ProgressBarStyle.Blocks : ProgressBarStyle.Continuous; if (disableForm) Enabled = false; } /// <summary> /// 操作结束 /// </summary> void EndOperation() { stStatus.Text = "就绪."; stProgress.Visible = false; Enabled = true; }
4.3 上下文数据绑定
在MainForm的Load事件中,加入一个region来加入当前窗体的ServiceContext,并初始化它。
#region 服务接入 ServiceContext _context; /// <summary> /// 初始化服务状态 /// </summary> void InitServiceContext() { _context=new ServiceContext(); } #endregion
然后在Load
中调用这个初始化函数。
private void MainForm_Load(object sender, EventArgs e) { InitServiceContext(); InitStatusBar(); InitQueryParamEdit(); }
注意看代码。InitServiceContext我放在了最前面,主要是写到了这里的时候突然想起刚才的日期编辑时出现了一个59天和默认的第2天。类似这俩数据的可配置数据很多,后期翻代码去找不怎么好,所以我重构了一下代码,引入了一个DataService。这个Service啥事儿不干,专门用来处理这些数据。
namespace Ticket12306Demo.Service { /// <summary> /// 数据服务 /// </summary> class DataService : ServiceBase { public DataService(ServiceContext context) : base(context) { } /// <summary> /// 最大售票天数 /// </summary> public int MaxSellDays { get { return 59; } } /// <summary> /// 默认的买票日期选择 /// </summary> public int DefaultDayOffset { get { return 1; } } } }
然后在ServiceContext
中注册这个Service。
/// <summary> /// 数据服务 /// </summary> public DataService DataService { get; private set; }
最后重构一下之前的代码。
/// <summary> /// 初始化查询参数编辑 /// </summary> void InitQueryParamEdit() { dtDate.MinDate = DateTime.Now.Date; //dtDate.MaxDate = DateTime.Now.Date.AddDays(59); //dtDate.Value = DateTime.Now.AddDays(1); dtDate.MaxDate = DateTime.Now.Date.AddDays(_context.DataService.MaxSellDays); dtDate.Value = DateTime.Now.AddDays(_context.DataService.DefaultDayOffset); }
4.4 车站数据抓取和绑定
现在来实现车站数据的抓取和填充。
打开上次保存的抓包记录,挨个请求找找,很容易就能看到站点数据存放的位置。
这个JS对应的URL是 https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version=1.8383
。
这个代码可以用浏览器直接访问,也可以先下载了再用文本编辑器打开。阅读这个文件需要一点Javascript基础才可以方便地查看,不懂javascript的只能靠猜了。
var station_names = '@bjb|北京北|VAP|beijingbei|bjb|0@bjd|北京东|BOP|beijingdong|bjd|1@bji|北京|BJP|beijing|bj|2@bjn|北京南|VNP|beijingnan|bjn|3@bjx|北京西|BXP|beijingxi|bjx|4@gzn|广州南|IZQ|guangzhounan|gzn|5@cqb|重庆北|CUW|chongqingbei|cqb|6@cqi|重庆|CQW|chongqing|cq|7@cqn|重庆南|CRW|chongqingnan|cqn|8@gzd|广州东|GGQ|guangzhoudong|gzd|9@sha|上海|SHH|shanghai|sh|10@shn|上海南|SNH|shanghainan|shn|11@shq|上海虹桥|AOH|shanghaihongqiao|shhq|12@shx|上海西|SXH|shanghaixi|shx|13 ………
不过这个文件结构比较简单,大概看一下就会明白,在这个文件中,所有的站点都集中在了一起,用@
做分隔,每个站点中又用|
进行隔开,保存的格式如下:
@简拼|站点名|电报码|全拼|首字母拼音|权重
对于这种规范的数据,用正则表达式来提取是非常简单的事情。用以下的正则即可以提取:
(PS:分析了权重数据后,感觉这部分数据没啥用,所以就扔掉了。上图所示的测试窗口是Resharper)
现在我们用代码实现它。在这之前,我们已经将这部分任务放在了StationDataService
中,现在我们打开这个服务类,准备实现它。
先根据实际的数据,生成相对应的Poco数据类。
namespace Ticket12306Demo.Service.Entities { class Station { /// <summary> /// 首字母拼音 /// </summary> public string FirstLetter { get; private set; } /// <summary> /// 车站名 /// </summary> public string Name { get; private set; } /// <summary> /// 电报码 /// </summary> public string Code { get; private set; } /// <summary> /// 全拼 /// </summary> public string Spell { get; private set; } /// <summary> /// Initializes a new instance of the <see cref="Station"/> class. /// </summary> /// <param name="firstLetter"></param> /// <param name="name"></param> /// <param name="code"></param> /// <param name="spell"></param> public Station(string firstLetter, string name, string code, string spell) { FirstLetter = firstLetter; Name = name; Code = code; Spell = spell; } } }
然后在StationDataService
中引入相关的属性。
/// <summary> /// 所有车站 /// </summary> List<Station> _allStations; /// <summary> /// 所有车站的电报码-车站索引 /// </summary> Dictionary<string, Station> _allStationsCodeMap; /// <summary> /// 所有的车站列表 /// </summary> public List<Station> AllStations { get { return _allStations; } set { _allStations = value; _allStationsCodeMap = value != null ? value.ToDictionary(s => s.Code) : null; } }
最后,我们加入一个LoadStationDatasAsync
方法,来初始化站点数据。注意这个方法,我们将其设计为异步方法,以便于异步调用。
/// <summary> /// 异步加载站点数据 /// </summary> /// <returns></returns> public async Task LoadStationDatasAsync() { if (_allStations != null) return; //创建网络请求并发送。注意地址中最后的随机数,并没有发送。 //对于静态文件来说,这个查询字符串是无效的 //一般用来区分版本号,以便于更新的时候拿到过期的数据。 var stCtx = ServiceContext.Session.NetClient .Create<string>(HttpMethod.Get, "https://kyfw.12306.cn/otn/resources/js/framework/station_name.js") .SendTask(); //等待任务完成 var jsContent = await stCtx; //解析请求 var matches = Regex.Matches(jsContent, @"@[a-z]+\|([^\|]+)\|([a-z]+)\|([a-z]+)\|([a-z]+)\|(\d+)", RegexOptions.IgnoreCase); var list = new List<Station>(matches.Count); foreach (Match match in matches) { //转换为实体。注意:这里简拼没有使用 list.Add(new Station(match.GetGroupValue(4), match.GetGroupValue(1), match.GetGroupValue(2), match.GetGroupValue(3))); } _allStations = list; }
最后,我们对MainForm
的相关代码做一点修改,以便于在窗体加载的时候调用它。具体的说,就是将Form
事件函数改为异步(加上async
),并将InitContext
也改为异步(加上async
),最后在新建对象后,调用对应服务的方法。
private async void MainForm_Load(object sender, EventArgs e) { await InitServiceContext(); InitStatusBar(); InitQueryParamEdit(); }
/// <summary> /// 初始化服务状态 /// </summary> async Task InitServiceContext() { _context = new ServiceContext(); BeginOperation("正在初始化站点数据...", 0, true); await _context.StationDataService.LoadStationDatasAsync(); EndOperation(); }
最后在InitQueryParamEdit事件中,我们将这些数据绑定到选择下拉框中。
var allstationText = _context.StationDataService.AllStations.Select(s => (object)(s.FirstLetter.PadRight(8, ' ') + s.Name)).ToArray(); cbFrom.Items.AddRange(allstationText); cbTo.Items.AddRange(allstationText);
4.5 测试
现在按Ctrl+F5,如果没有问题的话,项目已经可以跑起来了,在短暂的加载后,已经可以设置各项参数。
下一章开始,我们将会开始真正的查票。
鱼哥,BetterListView 怎么免费使用,我遇到一个项目也需要用这个排列。好像下载过来是试用装。
鱼哥,BetterListView 怎么免费试用?
引用通知 专注于HTTP的高性能高易用性网络库:Fslib.network库-IT大道
[…] 12306订票客户端 FOR .NET 演示项目 【4】界面框架&基础数据初始化 […]
Basic Set的图标集哪里有下载的呢
百度一下Basic set 图标,有下载的。
反正重新写 不如用WPF做做界面喽
因为我还不会wpf
先马下。 最近略动荡。
顺便问下,图一用啥画的
一个叫Mockups的东西。
原型图的界面挺萌的:lol: