公司組織機構是一個樹形架構,先前新加盟公司時都是總部直接添加在某個子公司下,因審計須要,要求經過下面公司申請,逐個角色處理來完成新公司的開通,開發任務最後落到我這裏,時間緊,任務重,先前也沒接觸多少審批流程的開發,好在咱們的系統是基於通用權限管理系統的底層來作的開發,角色,權限控制已沒什麼問題,並且底層也集成有一個審批流程引擎組件,只是先前沒多少人使用過,經過與吉日嘎拉老師的溝通,大體瞭解了這個組件的思想,就像其它系統調用權限功能同樣,我只須要完成業務功能的開發,實現審批流的接口便可,通過將近3周的開發,終於完成了新公司建立的審批流程。下面整理一下利用通用審批流程組件進行審批流開發的一些經驗:前端
根據全國省份劃分,每一個省份設有專人審批下級公司申請建立公司的單據,其中某些省份須要通過片區經理再審覈,以下圖java
注:其中的"網點管理員"是由系統默認的各個公司的在全部系統中具備最大權限的人員,由其提交新開其下級公司的申請,片區文員是各個省份的負責審批該省全部公司提交新開公司的申請單據的人員。web
從圖上能夠看到,新開一個公司根據告訴管理員所在省份的不一樣會有兩個流程中的其中一個處理(流程入口條件),所以咱們須要建立兩個審批流。數據庫
在審覈過程當中,審覈狀態有待審、經過、退回、完成四種狀態,這個是很好理解的。後端
經過分析流程,在通用審批組件中建立對應的流程:兩個審批流程。數組
已經建立好的審批流程,打開其中一個看看,以下圖:安全
下圖是其中一個流程的定義,能夠看到流程與表、程序集、類之間的關係,在具體實現時要繼承流程處理接口實現服務器
審覈步驟是須要先定義的:session
定義審覈步驟架構
審覈步驟先建立好,建立審覈流程時選擇其中的幾個審覈步驟構成一個流程,建立流程過程當中,可設置在流程處理過程當中可編輯的字段,這一塊我沒使用,字段控制我直接在權限中處理了。
根據上面的流程,我在系統中建立了對應的角色,由於直接是在通用權限系統中開發的,角色配置起來就很是容易了。
在子系統中建立的用於流程審批的角色
上面的角色建立好之後,就須要向角色中添加人員了,根據提供的人員配置到各個角色中,操做也是很簡單的
選中角色,點擊成員,向角色中添加成員便可。
經過對整個處理流程的分析,定義了以上與審批流程有關係的權限菜單。注意其中將所有要審覈的字段也做爲菜單項控制了,這樣在分配角色權限時處理方便,每一個角色可以修改哪些字段,其中菜單的編號也進行了一些處理,按照實體的名字來命名,這樣先後臺判斷權限時也很方便。
角色及菜單建立完畢後,能夠配置各個角色具備的菜單權限了,以下截圖:
如上圖,是配置片區審覈具備的菜單權限,這樣片區審覈這個角色裏的人進入系統就有了對應的權限,只需勾選上要分配的權限菜單便可。
===================================
至此,審批流程、角色、菜單、角色人員,角色菜單權限都配置完畢了,接下來開始具體審批業務功能的開發,能夠看到,有了這個通用權限及審批流程的底層管理系統,咱們只需關注業務功能便可。
===================================
上圖是公司管理員進入申請建立新公司的界面,能夠看到,在界面上控制了哪些項目是必填的,哪些項目是沒有權限填寫的。填寫完畢,能夠在「申請進度查詢」中看到流程進入哪一個階段。
上圖是其中一個角色進入審覈界面的顯示內容,點擊審覈,就能夠處理:經過或者退回
點擊上面的提交或者退回,就能夠對這個審批單據進行處理了
八、審批流程接口實現
底層中處理工做流的業務類
上圖是開始申請的處理流程:將申請單據保存起來,啓動審批流程,這是在數據庫中存儲的結果以下,能夠看到當前申請的流程要走的步驟:按ACTIVITYID的順序。
審批過程當中的拒絕操做:再也不保存修改記錄,退回到上一步。
審批過程當中經過審批的處理:能夠保存當前人員對單據的修改,同時提交到下一步來處理,若是是最後一步,審覈經過時,當前審覈單據的狀態將變爲完成。
能夠看到,在處理審批流程時,後臺審覈部分只須要調用底層接口便可,開發人員只需關注業務功能開發,下面把主要的底層接口提供出來,供參考:
權限判斷,實現先後端輸入項的驗證,前端若是有權限,對應的文本框處於可編輯狀態,不然不可編輯,後端也會再次驗證,有權限必須填寫的都須要通過後端再次驗證,這樣安全問題就能夠解決了。
using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using System.Net; using System.Text; using System.Web; using System.Web.Script.Serialization; namespace Infrastructure { using DotNet.Business; using DotNet.Model; using DotNet.Utilities; /// <summary> /// /// 修改紀錄 /// /// 2016-03-02 版本:1.0 宋彪 修改文件。 /// /// <author> /// <name>宋彪</name> /// <date>2016-03-02</date> /// </author> /// </summary> public static class WorkFlowBaseUserInfoExt { /// <summary> /// 存儲用戶權限菜單的sessionKey /// </summary> private static string permissionKey = "UserPermissionListSession"; /// <summary> /// 獲取存儲在session中的用戶權限菜單 /// </summary> /// <param name="userInfo"></param> /// <param name="userId"></param> /// <param name="systemCode"></param> /// <returns></returns> public static List<BaseModuleEntity> GetSessionPermissionList(this BaseUserInfo userInfo, string userId, string systemCode = "Base") { if (HttpContext.Current.Session[permissionKey] == null) { //List<BaseModuleEntity> list = PermissionUtilities.GetPermissionList(userInfo, userId, systemCode); List<BaseModuleEntity> returnResult = new List<BaseModuleEntity>(); try { string url = System.Configuration.ConfigurationManager.AppSettings["LogonService"] + "/PermissionService.ashx"; userInfo.Id = userId; userInfo.SystemCode = systemCode; // 忽略超級管理員 由於超級管理員有所有權限 userInfo.IsAdministrator = false; WebClient webClient = new WebClient(); NameValueCollection postValues = new NameValueCollection(); postValues.Add("Function", "GetPermissionList"); postValues.Add("UserInfo", userInfo.Serialize()); postValues.Add("SystemCode", systemCode); postValues.Add("fromCache", false.ToString()); // 向服務器發送POST數據 byte[] responseArray = webClient.UploadValues(url, postValues); string response = Encoding.UTF8.GetString(responseArray); if (!string.IsNullOrEmpty(response)) { JavaScriptSerializer javaScriptSerializer = new JavaScriptSerializer(); returnResult = javaScriptSerializer.Deserialize<List<BaseModuleEntity>>(response); returnResult = returnResult.OrderBy(t => t.SortCode).ToList(); HttpContext.Current.Session[permissionKey] = returnResult; } } catch (Exception ex) { Log.Write(ex.ToString()); } return returnResult; } return (List<BaseModuleEntity>)HttpContext.Current.Session[permissionKey]; } /// <summary> /// 移除session中的用戶權限 /// </summary> /// <param name="userInfo"></param> /// <param name="userId"></param> /// <param name="systemCode"></param> public static void RemoveSessionPermissionList(this BaseUserInfo userInfo, string userId, string systemCode = "Base") { if (HttpContext.Current.Session[permissionKey] != null) { HttpContext.Current.Session.Remove(permissionKey); } } /// <summary> /// 判斷用戶是否有某個菜單的權限,從存儲再session中的獲取菜單 /// </summary> /// <returns></returns> public static bool IsAuthorizedByCode(this BaseUserInfo userInfo, string code, string systemCode = "Base") { if (string.IsNullOrWhiteSpace(code)) { return false; } List<BaseModuleEntity> list = GetSessionPermissionList(userInfo, userInfo.Id, systemCode); if (list != null && list.Any()) { return list.Any(t => string.Equals(t.Code, code, StringComparison.OrdinalIgnoreCase)); } return false; } /// <summary> /// 經過權限判斷渲染用戶在前端是否能夠輸入 /// </summary> /// <param name="userInfo"></param> /// <param name="code"></param> /// <param name="validateInfo"></param> /// <param name="errorMsg"></param> /// <param name="quiFormStyle"></param> /// <param name="modelField"></param> /// <returns></returns> public static string GetAttributesByCode(this BaseUserInfo userInfo, string code, string validateInfo, string errorMsg, string quiFormStyle = null,string modelField=null) { string result = string.Empty; string classInfo = string.Empty; if (!string.IsNullOrWhiteSpace(quiFormStyle)) { classInfo += quiFormStyle; } if (userInfo.IsAuthorizedByCode(code)) { if (!string.IsNullOrWhiteSpace(validateInfo)) { classInfo += " " + validateInfo; } result = " class=\"" + classInfo + "\" "; ; if (!string.IsNullOrWhiteSpace(errorMsg)) { result += " error=\"" + errorMsg + "\" "; } } else { result = " class=\"" + classInfo + "\" "; result += " disabled=\"disabled\" "; ; } if (!string.IsNullOrWhiteSpace(modelField)) { code = modelField; } return result += " name=\"" + code + "\" "; ; } /// <summary> /// 經過權限判斷渲染用戶在前端是否能夠輸入 /// </summary> /// <param name="userInfo"></param> /// <param name="code"></param> /// <param name="required"></param> /// <param name="errorMsg"></param> /// <returns></returns> public static string GetAttributesByCode(this BaseUserInfo userInfo, string code, bool required, string errorMsg, string modelField = null) { string result = string.Empty; if (userInfo.IsAuthorizedByCode(code)) { if (required) { result += " class=\"validate[required]\" "; } if (required && !string.IsNullOrWhiteSpace(errorMsg)) { result += " error=\"" + errorMsg + "\" "; } } else { result += " disabled=\"disabled\" "; ; } if (!string.IsNullOrWhiteSpace(modelField)) { code = modelField; } return result += " name=\"" + code + "\" "; ; } } }
前臺調用權限判斷實現輸入項驗證
<input <%:Html.Raw(Utils.UserInfo.GetAttributesByCode("siteAudit.UserRealName",true,"請輸入真實姓名")) %> maxlength="20" type="text" style="width: 150px;" value="<%: siteAudit.UserRealName %>" />
若是沒有權限,輸入會增長disabled屬性。
單據處理過程當中調用的底層工做流的方法
//----------------------------------------------------------------- // All Rights Reserved , Copyright (C) 2016 , Hairihan TECH, Ltd. //----------------------------------------------------------------- using System; namespace DotNet.Business { using DotNet.IService; using DotNet.Model; using DotNet.Utilities; /// <summary> /// BaseWorkFlowCurrentManager /// 流程管理. /// /// 修改記錄 /// /// 2012.04.04 版本:1.0 JiRiGaLa 。 /// /// <author> /// <name>JiRiGaLa</name> /// <date>2012.04.04</date> /// </author> /// </summary> public partial class BaseWorkFlowCurrentManager : BaseManager, IBaseManager { /// <summary> /// (點批量經過時)當批量審覈經過時 /// </summary> /// <param name="currentIds">審批流當前主鍵數組</param> /// <param name="auditIdea">批示</param> /// <returns>成功失敗</returns> public int AutoAuditPass(string[] currentIds, string auditIdea) { int result = 0; for (int i = 0; i < currentIds.Length; i++) { result += this.AutoAuditPass(currentIds[i], auditIdea); } return result; } /// <summary> /// 審批經過 /// </summary> /// <param name="currentId"></param> /// <param name="auditIdea"></param> /// <returns></returns> public int AutoAuditPass(string currentId, string auditIdea) { IWorkFlowManager workFlowManager = this.GetWorkFlowManager(currentId); return AutoAuditPass(workFlowManager, currentId, auditIdea); } /// <summary> /// (點經過時)當審覈經過時 /// </summary> /// <param name="workFlowManager"></param> /// <param name="currentId">審批流當前主鍵</param> /// <param name="auditIdea">批示</param> /// <returns>成功失敗</returns> public int AutoAuditPass(IWorkFlowManager workFlowManager, string currentId, string auditIdea) { int result = 0; // 這裏要加鎖,防止併發提交 // 這裏用鎖的機制,提升併發控制能力 lock (WorkFlowCurrentLock) { // using (TransactionScope transactionScope = new TransactionScope()) //{ //try //{ // 1. 先得到如今的狀態?當前的工做流主鍵、當前的審覈步驟主鍵? BaseWorkFlowCurrentEntity workFlowCurrentEntity = this.GetObject(currentId); // 只有待審覈狀態的,才能夠經過,被退回的也能夠從新提交 if (!(workFlowCurrentEntity.AuditStatus.Equals(AuditStatus.StartAudit.ToString()) || workFlowCurrentEntity.AuditStatus.Equals(AuditStatus.AuditPass.ToString()) || workFlowCurrentEntity.AuditStatus.Equals(AuditStatus.WaitForAudit.ToString()) || workFlowCurrentEntity.AuditStatus.Equals(AuditStatus.AuditReject.ToString()) )) { return result; } // 是否是給當前人審覈的,或者當前人在委託的人? if (!string.IsNullOrEmpty(workFlowCurrentEntity.ToUserId)) { if (!(workFlowCurrentEntity.ToUserId.ToString().Equals(this.UserInfo.Id) || workFlowCurrentEntity.ToUserId.IndexOf(this.UserInfo.Id) >= 0 // || workFlowCurrentEntity.ToUserId.ToString().Equals(this.UserInfo.TargetUserId) )) { return result; } } // 獲取下一步是誰審覈。 BaseWorkFlowStepEntity workFlowStepEntity = this.GetNextWorkFlowStep(workFlowCurrentEntity); // 3. 進行下一步流轉?轉給角色?仍是傳給用戶? if (workFlowStepEntity == null || workFlowStepEntity.Id == null) { // 4. 若沒下一步了,那就得結束流程了?審覈結束了 result = this.AuditComplete(workFlowManager, currentId, auditIdea); } else { // 審覈進入下一步 // 當前是哪一個步驟? // 4. 是否已經在工做流裏了? // 5. 若已經在工做流裏了,那就進行更新操做? if (!string.IsNullOrEmpty(workFlowStepEntity.AuditUserId)) { // 如果任意人能夠審覈的,須要進行一次人工選任的工做 if (workFlowStepEntity.AuditUserId.Equals("Anyone")) { return result; } } // 按用戶審覈,審覈經過 result = AuditPass(workFlowManager, currentId, auditIdea, workFlowStepEntity); } //} //catch (System.Exception ex) //{ // 在本地記錄異常 // FileUtil.WriteException(UserInfo, ex); //} //finally //{ //} // transactionScope.Complete(); //} } return result; } #region public int AuditPass(IWorkFlowManager workFlowManager, string currentId, string auditIdea, BaseWorkFlowStepEntity workFlowStepEntity) 審覈經過 /// <summary> /// 審覈經過 /// </summary> /// <param name="id">當前主鍵</param> /// <param name="auditIdea">批示</param> /// <returns>影響行數</returns> public int AuditPass(IWorkFlowManager workFlowManager, string currentId, string auditIdea, BaseWorkFlowStepEntity workFlowStepEntity) { int result = 0; // 進行更新操做 result = this.StepAuditPass(currentId, auditIdea, workFlowStepEntity); if (result == 0) { // 數據可能被刪除 this.StatusCode = Status.ErrorDeleted.ToString(); } BaseWorkFlowCurrentEntity workFlowCurrentEntity = this.GetObject(currentId); // 發送提醒信息 if (workFlowManager != null) { if (!string.IsNullOrEmpty(workFlowStepEntity.AuditUserId)) { workFlowStepEntity.AuditDepartmentId = null; workFlowStepEntity.AuditRoleId = null; } workFlowManager.OnAutoAuditPass(workFlowCurrentEntity); workFlowManager.SendRemindMessage(workFlowCurrentEntity, AuditStatus.AuditPass, new string[] { workFlowCurrentEntity.CreateUserId, workFlowStepEntity.AuditUserId }, workFlowStepEntity.AuditDepartmentId, workFlowStepEntity.AuditRoleId); } this.StatusMessage = this.GetStateMessage(this.StatusCode); return result; } #endregion #region private int StepAuditPass(string currentId, string auditIdea, BaseWorkFlowStepEntity toStepEntity, BaseWorkFlowAuditInfo workFlowAuditInfo = null) 審覈經過(不須要再發給別人了是完成審批了) /// <summary> /// 審覈經過(不須要再發給別人了是完成審批了) /// </summary> /// <param name="currentId">當前主鍵</param> /// <param name="auditIdea">批示</param> /// <param name="toStepEntity">審覈到第幾步</param> /// <param name="workFlowAuditInfo">當前審覈人信息</param> /// <returns>影響行數</returns> private int StepAuditPass(string currentId, string auditIdea, BaseWorkFlowStepEntity toStepEntity, BaseWorkFlowAuditInfo workFlowAuditInfo = null) { BaseWorkFlowCurrentEntity workFlowCurrentEntity = this.GetObject(currentId); // 初始化審覈信息,這裏是顯示當前審覈人,能夠不是當前操做員的功能 if (workFlowAuditInfo == null) { workFlowAuditInfo = new BaseWorkFlowAuditInfo(this.UserInfo); workFlowAuditInfo.AuditIdea = auditIdea; workFlowAuditInfo.AuditDate = DateTime.Now; workFlowAuditInfo.AuditUserId = this.UserInfo.Id; workFlowAuditInfo.AuditUserRealName = this.UserInfo.RealName; workFlowAuditInfo.AuditStatus = AuditStatus.AuditPass.ToString(); workFlowAuditInfo.AuditStatusName = AuditStatus.AuditPass.ToDescription(); } else { workFlowAuditInfo.AuditIdea = auditIdea; } // 審覈意見是什麼? workFlowCurrentEntity.AuditIdea = workFlowAuditInfo.AuditIdea; // 1.記錄當前的審覈時間、審覈人信息 // 什麼時間審覈的? workFlowCurrentEntity.AuditDate = workFlowAuditInfo.AuditDate; // 審覈的用戶是誰? workFlowCurrentEntity.AuditUserId = workFlowAuditInfo.AuditUserId; // 審覈人的姓名是誰? workFlowCurrentEntity.AuditUserRealName = workFlowAuditInfo.AuditUserRealName; // 審覈狀態是什麼? workFlowCurrentEntity.AuditStatus = workFlowAuditInfo.AuditStatus; // 審覈狀態備註是什麼? workFlowCurrentEntity.AuditStatusName = workFlowAuditInfo.AuditStatusName; if (!string.IsNullOrEmpty(workFlowCurrentEntity.ActivityFullName)) { workFlowCurrentEntity.Description = string.Format("從{0}提交到{1}{2}", workFlowCurrentEntity.ActivityFullName, toStepEntity.FullName, !string.IsNullOrEmpty(toStepEntity.Description) ? "," + toStepEntity.Description : string.Empty); } workFlowCurrentEntity.ToUserId = toStepEntity.AuditUserId; workFlowCurrentEntity.ToUserRealName = toStepEntity.AuditUserRealName; workFlowCurrentEntity.ToRoleId = toStepEntity.AuditRoleId; workFlowCurrentEntity.ToRoleRealName = toStepEntity.AuditRoleRealName; workFlowCurrentEntity.ToDepartmentId = toStepEntity.AuditDepartmentId; workFlowCurrentEntity.ToDepartmentName = toStepEntity.AuditDepartmentName; // 2.記錄審覈日誌 this.AddHistory(workFlowCurrentEntity); // 3.上一個審覈結束了,新的審覈又開始了,更新待審覈狀況 workFlowCurrentEntity.ActivityId = toStepEntity.ActivityId; workFlowCurrentEntity.ActivityCode = toStepEntity.Code; workFlowCurrentEntity.ActivityFullName = toStepEntity.FullName; workFlowCurrentEntity.ActivityType = toStepEntity.ActivityType; workFlowCurrentEntity.SortCode = toStepEntity.SortCode; return this.UpdateObject(workFlowCurrentEntity); } #endregion } }
審覈須要實現的工做流接口
//----------------------------------------------------------------- // All Rights Reserved , Copyright (C) 2016 , Hairihan TECH, Ltd. //----------------------------------------------------------------- namespace DotNet.IService { using DotNet.Model; using DotNet.Utilities; /// <summary> /// IWorkFlowManager /// 可審批化的類接口定義 /// /// 修改記錄 /// /// 2011.09.06 版本:1.0 JiRiGaLa 建立文件。 /// /// <author> /// <name>JiRiGaLa</name> /// <date>2011.09.06</date> /// </author> /// </summary> public interface IWorkFlowManager { string CurrentTableName { get; set; } IDbHelper GetDbHelper(); BaseUserInfo GetUserInfo(); void SetUserInfo(BaseUserInfo userInfo); /// <summary> /// 獲取待審覈單據的網址 /// </summary> /// <param name="currentId">工做流當前主鍵</param> /// <returns>獲取網址</returns> string GetUrl(string currentId); /// <summary> /// 發送即時通信提醒 /// </summary> /// <param name="workFlowCurrentEntity">當前審覈流實體信息</param> /// <param name="auditStatus">審覈狀態</param> /// <param name="userIds">發送給用戶主鍵數組</param> /// <param name="organizeId">發送給部門主鍵數組</param> /// <param name="roleId">發送給角色主鍵數組</param> /// <returns>影響行數</returns> int SendRemindMessage(BaseWorkFlowCurrentEntity entity, AuditStatus auditStatus, string[] userIds, string organizeId, string roleId); /// <summary> /// 當工做流開始啓動前須要作的工做 /// </summary> /// <param name="workFlowAuditInfo">審覈信息</param> /// <returns>成功失敗</returns> bool BeforeAutoStatr(BaseWorkFlowAuditInfo workFlowAuditInfo); /// <summary> /// 當工做流開始啓動以後須要作的工做 /// </summary> /// <param name="workFlowAuditInfo">審覈信息</param> /// <returns>成功失敗</returns> bool AfterAutoStatr(BaseWorkFlowAuditInfo workFlowAuditInfo); /// <summary> /// (點經過時)當審覈經過時 /// </summary> /// <param name="entity">審批流當前信息</param> /// <returns>成功失敗</returns> bool OnAutoAuditPass(BaseWorkFlowCurrentEntity entity); /// <summary> /// (點退回時)當審覈退回時 /// </summary> /// <param name="entity">審批流當前信息</param> /// <returns>成功失敗</returns> bool OnAutoAuditReject(BaseWorkFlowCurrentEntity entity); /// <summary> /// (點完成時)當審覈完成時 /// </summary> /// <param name="entity">審批流當前信息</param> /// <returns>成功失敗</returns> bool OnAutoAuditComplete(BaseWorkFlowCurrentEntity entity); // ====================================== // // 下面是用戶本身提交單據審覈時發生的事件 // // ====================================== // /// <summary> /// 廢棄單據 /// (廢棄單據時)當廢棄審批流時須要作的事情 /// </summary> /// <param name="id">主鍵</param> /// <param name="auditIdea">批示</param> /// <returns>影響行數</returns> int AuditQuash(string id, string auditIdea); /// <summary> /// 批量廢棄單據 /// (批量廢棄單據時)當廢棄審批流時須要作的事情 /// </summary> /// <param name="ids">主鍵數組</param> /// <param name="auditIdea">批示</param> /// <returns>影響行數</returns> int AuditQuash(string[] ids, string auditIdea); // ====================================== // // 下面是用戶被審覈單據被審覈時發生的事件 // // ====================================== // /// <summary> /// (點退回時)當審覈退回時 /// </summary> /// <param name="entity">當前審批流程</param> /// <returns>成功失敗</returns> bool OnAuditReject(BaseWorkFlowCurrentEntity entity); /// <summary> /// 廢棄單據 /// (廢棄單據時)當廢棄審批流時須要作的事情 /// </summary> /// <param name="entity">當前審批流程</param> /// <returns>影響行數</returns> bool OnAuditQuash(BaseWorkFlowCurrentEntity entity); /// <summary> /// 流程完成時 /// 結束審覈時,須要回調寫入到表裏,調用相應的事件 /// 若成功能夠執行完成的處理 /// </summary> /// <param name="entity">當前審批流程</param> /// <returns>成功失敗</returns> bool OnAuditComplete(BaseWorkFlowCurrentEntity entity); // (退回到某一節點時) 被退回到某個節點 // 當有人評論時的功能實現 /// <summary> /// 重置單據 /// (單據發生錯誤時)緊急狀況下實用 /// </summary> /// <param name="entity">當前審批流程</param> /// <returns>影響行數</returns> bool OnReset(BaseWorkFlowCurrentEntity entity); } }
經過此次公司新開審批流程的開發,讓我比較全面的瞭解了審批業務流程開發的思路,瞭解到了其核心的實現原理,若是沒有權限底層及審批流組件的支持,這麼短期,沒有任何經驗的狀況下,完成審批功能的開發是不可想象的,從此我會逐步完善這個審批組件,實現B/S化,經過界面拖動完成審批流程的建立。