在前面隨筆介紹的《ABP開發框架前后端開發系列---(7)系統審計日志和登錄日志的管理》里面,介紹了如何改進和完善審計日志和登錄日志的應用服務端和Winform客戶端,由於篇幅限制,沒有進一步詳細介紹Winform界面的開發過程,本篇隨筆介紹這部分內容,並進一步擴展Winform界面的各種情況處理,力求讓它進入一個新的開發里程碑。
1、回顧審計日志和登陸日志管理界面
前面介紹了如何擴展審計日志應用服務層(Application Service層)和ApiCaller層(API客戶端調用封裝層),同時也展示審計日志和登錄日志在Winform界面的展示,由於整個ABP框架目前我還是采用了.net core的開發路線,所有的封裝項目都是基於.net core基礎上進行的。不過由於目前Winform還沒有能夠以 .net core進行開發,所以界面端還是用.net framework的方式開發,不過可以調用 .net standard的類庫。
下面是審計日志的列表展示界面,和我之前的Winform框架一樣的布局,因此我重用了Winform框架里面公用類庫項目、基礎界面封裝項目、分頁控件等內容,因此整個界面看起來還是很一致的。
由於審計日志主要供底層記錄,因此在界面不能增加增刪改的操作,我們只需要分頁查詢,和導出記錄即可,如下窗體界面所示。

而明細內容,可以通過雙擊或者右鍵選擇菜單打開即可彈出新的展示界面,主要展示審計日志里面的各項信息。

而對於用戶登錄日志來說,處理方式差不多,也是通過在列表中查詢展示,並在列表中整合右鍵菜單或者雙擊處理,可以查看登錄明細內容。

通過雙擊或者右鍵選擇菜單打開即可彈出新的展示界面,主要展示登錄日志里面的各項信息。

2、Winform界面代碼實現
上面展示了列表界面和查看明細界面,實際上我們Winform的界面內部是如何處理的呢,我們這里對其中的一些關鍵處理進行分析介紹。
列表界面的窗體初始化代碼如下所示
/// <summary> /// 審計日志 /// </summary> public partial class FrmAuditLog : BaseDock { private const string Id_FieldName = "Id";//Id的字段名稱 public FrmAuditLog() { InitializeComponent(); //分頁控件初始化事件 this.winGridViewPager1.OnPageChanged += new EventHandler(winGridViewPager1_OnPageChanged); this.winGridViewPager1.OnStartExport += new EventHandler(winGridViewPager1_OnStartExport); this.winGridViewPager1.OnEditSelected += new EventHandler(winGridViewPager1_OnEditSelected); this.winGridViewPager1.OnAddNew += new EventHandler(winGridViewPager1_OnAddNew); this.winGridViewPager1.OnDeleteSelected += new EventHandler(winGridViewPager1_OnDeleteSelected); this.winGridViewPager1.OnRefresh += new EventHandler(winGridViewPager1_OnRefresh); this.winGridViewPager1.AppendedMenu = this.contextMenuStrip1; this.winGridViewPager1.ShowLineNumber = true; this.winGridViewPager1.BestFitColumnWith = false;//是否設置為自動調整寬度,false為不設置 this.winGridViewPager1.gridView1.DataSourceChanged +=new EventHandler(gridView1_DataSourceChanged); this.winGridViewPager1.gridView1.CustomColumnDisplayText += new DevExpress.XtraGrid.Views.Base.CustomColumnDisplayTextEventHandler(gridView1_CustomColumnDisplayText); this.winGridViewPager1.gridView1.RowCellStyle += new DevExpress.XtraGrid.Views.Grid.RowCellStyleEventHandler(gridView1_RowCellStyle); //關聯回車鍵進行查詢 foreach (Control control in this.layoutControl1.Controls) { control.KeyUp += new System.Windows.Forms.KeyEventHandler(this.SearchControl_KeyUp); } //屏蔽某些處理 this.winGridViewPager1.ShowAddMenu = false; this.winGridViewPager1.ShowDeleteMenu = false; }
這些是使用分頁控件來初始化一些界面的處理事件,不要一看就抱怨需要編寫這么多代碼,這些基本上都是代碼生成工具生成的,后面會介紹。
其實窗體的加載的時候,主要邏輯是初始化字典列表和展示列表數據,如下代碼所示。
/// <summary> /// 編寫初始化窗體的實現,可以用於刷新 /// </summary> public override async void FormOnLoad() { await InitDictItem(); await BindData(); }
其中這里都是使用async和await 配對實現的異步處理操作。我們對於審計日志列表來說,字典模塊沒有需要字典綁定信息,那么默認為空不用修改。
/// <summary> /// 初始化字典列表內容 /// </summary> private async Task InitDictItem() { //初始化代碼 //await this.txtCategory.BindDictItems("報銷類型"); await Task.FromResult(0); }
那么我們主要處理的就是BindData的數據綁定操作了。
/// <summary> /// 綁定列表數據 /// </summary> private async Task BindData() { this.winGridViewPager1.DisplayColumns = "Id,BrowserInfo,ClientIpAddress,ClientName,CreationTime,Result,UserId,UserNameOrEmailAddress"; this.winGridViewPager1.ColumnNameAlias = await UserLoginAttemptApiCaller.Instance.GetColumnNameAlias();//字段列顯示名稱轉義 //獲取分頁數據列表 var result = await GetData(); //設置所有記錄數和列表數據源 this.winGridViewPager1.PagerInfo.RecordCount = result.TotalCount; //需先於DataSource的賦值,更新分頁信息 this.winGridViewPager1.DataSource = result.Items; this.winGridViewPager1.PrintTitle = "用戶登錄日志報表"; }
其中我們通過 調用服務端接口 GetColumnNameAlias 來獲取對應的別名,其實我們也可以在Winform客戶端設置對等的別名處理,如下代碼所示。
#region 添加別名解析 //this.winGridViewPager1.AddColumnAlias("Id", "Id"); //this.winGridViewPager1.AddColumnAlias("BrowserInfo", "瀏覽器"); //this.winGridViewPager1.AddColumnAlias("ClientIpAddress", "IP地址"); //this.winGridViewPager1.AddColumnAlias("ClientName", "客戶端"); //this.winGridViewPager1.AddColumnAlias("CreationTime", "時間"); //this.winGridViewPager1.AddColumnAlias("Result", "結果"); //this.winGridViewPager1.AddColumnAlias("UserId", "用戶ID"); //this.winGridViewPager1.AddColumnAlias("UserNameOrEmailAddress", "用戶名或郵件"); #endregion
只是基於服務端更加方便,也減少客戶端的編碼了。
而獲取數據主要通過 GetData 函數進行統一獲取對應的列表和數據記錄信息,如下是GetData的函數實現。
/// <summary> /// 獲取數據 /// </summary> /// <returns></returns> private async Task<IPagedResult<UserLoginAttemptDto>> GetData() { //構建分頁的條件和查詢條件 var pagerDto = new UserLoginAttemptPagedDto(this.winGridViewPager1.PagerInfo) { UserNameOrEmailAddress = this.txtUserNameOrEmailAddress.Text.Trim(), }; //日期和數值范圍定義 //時間,需在UserLoginAttemptPagedDto中添加DateTime?類型字段CreationTimeStart和CreationTimeEnd var CreationTime = new TimeRange(this.txtCreationTime1.Text, this.txtCreationTime2.Text); //日期類型 pagerDto.CreationTimeStart = CreationTime.Start; pagerDto.CreationTimeEnd = CreationTime.End; var result = await UserLoginAttemptApiCaller.Instance.GetAll(pagerDto); return result; }
這個函數里面,主要是接收列表界面里面的查詢條件,並構建對應的分頁查詢條件,這樣根據條件DTO就可以請求服務器的數據了。
前面講了,這個過濾條件並返回對應的數據,主要就是在Application Service層,設置CreateFilteredQuery的控制邏輯即可,如下所示。
/// <summary> /// 自定義條件處理 /// </summary> /// <param name="input">分頁查詢Dto對象</param> /// <returns></returns> protected override IQueryable<AuditLog> CreateFilteredQuery(AuditLogPagedDto input) { //構建關聯查詢Query var query = from auditLog in Repository.GetAll() join user in _userRepository.GetAll() on auditLog.UserId equals user.Id into userJoin from joinedUser in userJoin.DefaultIfEmpty() where auditLog.UserId.HasValue select new AuditLogAndUser { AuditLog = auditLog, User = joinedUser }; //過濾分頁條件 return query .WhereIf(!string.IsNullOrEmpty(input.UserName), t => t.User.UserName.Contains(input.UserName)) .WhereIf(input.ExecutionTimeStart.HasValue, s => s.AuditLog.ExecutionTime >= input.ExecutionTimeStart.Value) .WhereIf(input.ExecutionTimeEnd.HasValue, s => s.AuditLog.ExecutionTime <= input.ExecutionTimeEnd.Value) .Select(s => s.AuditLog); }
這里就不在贅述服務層的邏輯代碼,主要關注我們本篇的主題,Winform的界面實現邏輯。
上面通過GetData獲取到服務端數據后,我們就可以把列表數據綁定到分頁控件上面,讓分頁控件調用GridControl 進行展示出來即可。
//設置所有記錄數和列表數據源 this.winGridViewPager1.PagerInfo.RecordCount = result.TotalCount; this.winGridViewPager1.DataSource = result.Items;
數據的導出操作,我們這里也順便提一下,雖然這些代碼是基於代碼生成工具生成的,不過還是提一下邏輯處理。
數據的導出操作,主要就是通過GetData獲取到數據后,轉換為DataTable,並通過Apose.Cell進行寫入Excel文件即可,如下代碼所示。
/// <summary> /// 導出的操作 /// </summary> private async void ExportData() { string file = FileDialogHelper.SaveExcel(string.Format("{0}.xls", moduleName)); if (!string.IsNullOrEmpty(file)) { //獲取分頁數據列表 var result = await GetData(); var list = result.Items; DataTable dtNew = DataTableHelper.CreateTable("序號|int,Id,時間,用戶名,服務,操作,參數,持續時間,IP地址,客戶端,瀏覽器,自定義數據,異常,返回值"); DataRow dr; int j = 1; for (int i = 0; i < list.Count; i++) { dr = dtNew.NewRow(); dr["序號"] = j++; dr["Id"] = list[i].Id; dr["瀏覽器"] = list[i].BrowserInfo; dr["IP地址"] = list[i].ClientIpAddress; dr["客戶端"] = list[i].ClientName; dr["自定義數據"] = list[i].CustomData; dr["異常"] = list[i].Exception; dr["持續時間"] = list[i].ExecutionDuration; dr["時間"] = list[i].ExecutionTime; dr["操作"] = list[i].MethodName; dr["參數"] = list[i].Parameters; dr["服務"] = list[i].ServiceName; dr["用戶名"] = list[i].UserName; dr["返回值"] = list[i].ReturnValue; dtNew.Rows.Add(dr); } try { string error = ""; AsposeExcelTools.DataTableToExcel2(dtNew, file, out error); if (!string.IsNullOrEmpty(error)) { MessageDxUtil.ShowError(string.Format("導出Excel出現錯誤:{0}", error)); } else { if (MessageDxUtil.ShowYesNoAndTips("導出成功,是否打開文件?") == System.Windows.Forms.DialogResult.Yes) { System.Diagnostics.Process.Start(file); } } } catch (Exception ex) { LogTextHelper.Error(ex); MessageDxUtil.ShowError(ex.Message); } } }
而對於編輯或者查看界面,如下所示。

它的實現邏輯主要就是獲取單個記錄,然后在界面上逐一綁定控件內容顯示即可。
/// <summary> /// 數據顯示的函數 /// </summary> public async override void DisplayData() { InitDictItem();//數據字典加載(公用) if (!string.IsNullOrEmpty(ID)) { #region 顯示信息 var info = await AuditLogApiCaller.Instance.Get(ID.ToInt64()); if (info != null) { tempInfo = info;//重新給臨時對象賦值,使之指向存在的記錄對象 txtBrowserInfo.Text = info.BrowserInfo; txtClientIpAddress.Text = info.ClientIpAddress; txtClientName.Text = info.ClientName; txtCustomData.Text = info.CustomData; txtException.Text = info.Exception; txtExecutionDuration.Value = info.ExecutionDuration; txtExecutionTime.SetDateTime(info.ExecutionTime); txtMethodName.Text = info.MethodName; txtParameters.Text = ConvertJson(info.Parameters); txtServiceName.Text = info.ServiceName; if (info.UserId.HasValue) { txtUserId.Value = info.UserId.Value; } txtUserName.Text = info.UserName;//轉義的用戶名 } #endregion } else { } this.btnAdd.Visible = false; this.btnOK.Visible = false; }
當然對於新增或編輯的界面,我們需要處理它的保存或者更新的操作事件,雖然審計日志不需要這些操作,不過生成的編輯窗體界面,依舊保留這些處理邏輯,如下代碼所示。
/// <summary> /// 新增狀態下的數據保存 /// </summary> /// <returns></returns> public async override Task<bool> SaveAddNew() { AuditLogDto info = tempInfo;//必須使用存在的局部變量,因為部分信息可能被附件使用 SetInfo(info); try { #region 新增數據 tempInfo = await AuditLogApiCaller.Instance.Create(info); if (tempInfo != null) { //可添加其他關聯操作 return true; } #endregion } catch (Exception ex) { LogTextHelper.Error(ex); MessageDxUtil.ShowError(ex.Message); } return false; } /// <summary> /// 編輯狀態下的數據保存 /// </summary> /// <returns></returns> public async override Task<bool> SaveUpdated() { AuditLogDto info = await AuditLogApiCaller.Instance.Get(ID.ToInt64()); if (info != null) { SetInfo(info); try { #region 更新數據 tempInfo = await AuditLogApiCaller.Instance.Update(info); if (tempInfo != null) { //可添加其他關聯操作 return true; } #endregion } catch (Exception ex) { LogTextHelper.Error(ex); MessageDxUtil.ShowError(ex.Message); } } return false; }
我們可以根據實際的需要,對我們業務對象的窗體進行一定的改造即可。
3、復雜一點的WInform界面處理
例如對於前面的列表界面,一個比較復雜一點的列表展示內容,需要在查詢條件中綁定字典列表,並對列表記錄的一些狀態進行特殊展示等,以及需要考慮增加、導入、導出等功能按鈕,這些默認的列表生成界面就有的。
如下是對於產品信息的一個界面展示,也是基於ABP框架構建的服務進行數據展示的例子。

和前面介紹的例子一樣,也是基於分頁控件進行展示的,我們來看看狀態的處理吧。
由於狀態和用戶信息,我們在數據庫里面記錄的是整形的數據信息,也就是狀態為0,1的這樣,以及用戶ID等,我們如果需要轉義給客戶端使用,那么我們需要在對應的DTO里面增加一些字段進行承載,如下所示是產品信息的DTO對象,除了本身CreateProductDto必須有的字段外,我們另外增加了兩個屬性,如下代碼所示。

然后我們在應用服務接口的ConvertDto轉義函數里面增加自己的處理轉義邏輯即可,如下代碼所示。
/// <summary> /// 對記錄進行轉義 /// </summary> /// <param name="item">dto數據對象</param> /// <returns></returns> protected override void ConvertDto(ProductDto item) { //如需要轉義,則進行重寫 #region 參考代碼 //用戶名稱轉義 if (item.CreatorUserId.HasValue) { //需在ProductDto中增加CreatorUserName屬性 item.CreatorUserName = _userRepository.Get(item.CreatorUserId.Value).UserName; } if (item.Status.HasValue) { item.StatusDisplay = item.Status.Value == 0 ? "正常" : "停用"; } #endregion }
這樣客戶端就可以采用這兩個屬性展示信息了。

前面也介紹了,對於產品類型屬性,我們一般是一個字典信息的,因此我們可以集成綁定字典的處理,如下代碼所示。

這個BindDictItems是擴展函數,通過擴展函數,我們對控件類型的綁定字典操作進行處理即可,具體的邏輯代碼如下所示。
/// <summary> /// 擴展函數封裝 /// </summary> internal static class ExtensionMethod { /// <summary> /// 綁定下拉列表控件為指定的數據字典列表 /// </summary> /// <param name="control">下拉列表控件</param> /// <param name="dictTypeName">數據字典類型名稱</param> /// <param name="emptyFlag">是否添加空行</param> public static async Task BindDictItems(this ComboBoxEdit control, string dictTypeName, bool isCache = true, bool emptyFlag = true) { await BindDictItems(control, dictTypeName, null, isCache, emptyFlag); } /// <summary> /// 綁定下拉列表控件為指定的數據字典列表 /// </summary> /// <param name="control">下拉列表控件</param> /// <param name="dictTypeName">數據字典類型名稱</param> /// <param name="defaultValue">控件默認值</param> /// <param name="emptyFlag">是否添加空行</param> public static async Task BindDictItems(this ComboBoxEdit control, string dictTypeName, string defaultValue, bool isCache = true, bool emptyFlag = true) { var dict = await DictItemUtil.GetDictByDictType(dictTypeName, isCache); List<CListItem> itemList = new List<CListItem>(); foreach (string key in dict.Keys) { itemList.Add(new CListItem(key, dict[key])); } control.BindDictItems(itemList, defaultValue, emptyFlag); } ......
最后我們可以看到,字典列表的效果如下所示。

新增產品信息界面如下所示。

4、基於代碼工具的Winform界面快速生成
這些都是標准的Winform界面模板,因此可以利用代碼生成工具進行快速開發,利用代碼生成工具Database2Sharp快速生成來實現ABP優化框架類文件的生成,以及界面代碼的生成,然后進行一定的調整就是本項目的代碼了。
ABP框架的基礎代碼生成我們就不再這里介紹了,主要介紹下Winform展示界面和編輯界面的快速生成即可。
在生成Abp框架的Winform界面面板中,配置我們查詢條件、列表展示、編輯展示內容等信息后,就可以生成對應的界面,然后復制到項目中使用即可,整個過程是比較快速的,這些開發便利可是花了我很多反復核對和優化NVelocity模板的開發時間的。
如下是代碼生成工具Database2Sharp關於ABP框架的Winform界面配置。

設置好后直接生成,代碼工具就可以依照模板來生成所需要的WInform列表界面和編輯界面的內容了,如下是生成的界面代碼。

放到VS項目里面,就看到對應的窗體界面效果了。

生成界面后,進行一定的布局調整就可以實際用於生產環境了,省卻了很多時間。
