前天已发过文章分享了刚完成的一个主数据系统,受到了不少朋友的关注,这篇文章主要是对主数据权限设计方案的讲解,希望对大家有所帮助。源码下载与运行说明请查看 分享一个通用强大的主数据管理系统(架构设计讲解及源码下载)
权限管理一般为分授权、验权两大块,另外还有验权测试,这是在系统测试阶段要完成的工作。这里重点要讲的是授权,验权会讲一部分。
一、主要数据表设计
这是权限分组表,设计它是为了在管理权限时更加清晰,没其它特别的意义。
这是权限项表,这个表是重点,其中:
Code是对应系统中的权限码(例如删除用户:delete_user),最终验权的时候是权限这个权限码来获取权限值的。
DisplayStyle是权限项的显示样式,除了CheckBox是简单true/false权限外,TextBox、DropDownList、TreeView这三种都是自己定义数据型权限,也是难点,更是本设计方案的特色,当然还可以扩展其它类型的权限。
JsonDataUrl是支持远程权限值的初始化,JsonDataConst是支持静态权限值的初始化,Json数据格式如下:
下面看看添加权限项的界面:
然后看看授权的界面:
这是角色表,每个系统都有自己的多个角色,每个角色都有自己的权限,其中PermissionJsonData就是用于保存权限的。
这是用户个人的永久权限表,其中PermissionJsonData就是用于保存权限的。
这是用户个人的临时权限表,其中PermissionJsonData就是用于保存权限的,BeginDate和EndDate保存权限的有效日期。
这个表是用于保存用户与角色的关系的,一个用户可以拥有一个或多个角色。
二、受权的代码实现
首先,我们需要按需求来定义合理的数据模型(主要给系统的表示层使用的),其中DbModels里放的是和数据表对应的数据模型,ExtendedModels里放的是特别需求扩展的数据模型,JsonModels里放的是Json数据序列化需要的数据模型。其中PermissionDropDownListOption是为DropDownList类型权限设计的,PermissionTreeViewNode是为TreeView类型权限设计的,代码如下:
[DataContract]
[Serializable]
public class PermissionDropDownListOption
{
[DataMember]
public string text { get; set; }
[DataMember]
public string value { get; set; }
[DataMember]
public bool selected { get; set; }
public PermissionDropDownListOption()
: this(string.Empty, string.Empty, false)
{
}
public PermissionDropDownListOption(string text, string value)
: this(text, value, false)
{
}
public PermissionDropDownListOption(string text, string value, bool selected)
{
this.text = text;
this.value = value;
this.selected = selected;
}
}
[DataContract]
[Serializable]
public class PermissionTreeViewNode
{
[DataMember]
public string id { get; set; }
[DataMember]
public bool isParent { get; set; }
[DataMember]
public string name { get; set; }
[DataMember]
public bool @checked { get; set; }
[DataMember]
public string icon { get; set; }
[DataMember]
public string iconOpen { get; set; }
[DataMember]
public string iconClose { get; set; }
[DataMember]
public List<PermissionTreeViewNode> childs { get; set; }
public PermissionTreeViewNode()
: this(string.Empty, false, string.Empty)
{
}
public PermissionTreeViewNode(string id, bool isParent, string name)
{
this.id = id;
this.isParent = isParent;
this.name = name;
this.@checked = false;
this.icon = string.Empty;
this.iconOpen = string.Empty;
this.iconClose = string.Empty;
this.childs = new List<PermissionTreeViewNode>();
}
}
写到这,大家先看一下授权的页面,初始化控件和保存权限都算是一个难点,在显示权限控件方面,其实只要获取相应系统的所有权限项,根据类型来输出html就行了,难的是DropDownList和TreeView控件,下面两个方法是返回它们所需的Json数据:
///<summary>
/// 获取树视图Json数据
///</summary>
[Action]
public void GetTreeViewPermissionJsonData(Guid systemId, Guid permissionItemId, string[] checkedIds)
{
PermissionItemModel permissionItem = PermissionItemService.GetById(permissionItemId);
// 转为对象
PermissionTreeViewJsonData nodes = PermissionUtils.ParsePermissionControlJsonData<PermissionTreeViewJsonData>(this, systemId, permissionItem);
// 初始已选数据
PermissionUtils.InitTreeViewCheckedNodes(nodes, checkedIds);
JsonResult(nodes);
}
///<summary>
/// 获取下拉框Json数据
///</summary>
[Action]
public void GetDropDownListPermissionJsonData(Guid systemId, Guid permissionItemId, string selectedValue)
{
PermissionItemModel permissionItem = PermissionItemService.GetById(permissionItemId);
// 先转为对象
PermissionDropDownListJsonData options = PermissionUtils.ParsePermissionControlJsonData<PermissionDropDownListJsonData>(this, systemId, permissionItem);
// 初始已选数据
PermissionUtils.InitDropDownListSelectedOptions(options, selectedValue);
JsonResult(options);
}
重点工作都交给了PermissionUtils助手类来处理了,请看下面的代码
public static class PermissionUtils
{
///<summary>
/// 获取提交的权限
///</summary>
///<param name="formValues"></param>
///<param name="permissionGroupItems"></param>
///<returns></returns>
public static Dictionary<string, PermissionValueModel> GetPostedPermissionValues(NameValueCollection formValues, List<PermissionGroupItemsModel> permissionGroupItems)
{
Dictionary<string, PermissionValueModel> permissionValues = new Dictionary<string, PermissionValueModel>(StringComparer.OrdinalIgnoreCase);
foreach (var groupItem in permissionGroupItems)
{
foreach (var item in groupItem.Items)
{
string value = formValues.GetString(item.Code);
if (!string.IsNullOrEmpty(value))
{
if (item.DisplayStyle == PermissionItemDisplayStyle.CheckBox)
{
value = value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries)[0];
}
PermissionValueModel permissionValue = new PermissionValueModel
{
Code = item.Code,
DisplayName = item.DisplayName,
DisplayStyle = item.DisplayStyle,
Value = value
};
permissionValues.Add(permissionValue.Code, permissionValue);
}
}
}
return permissionValues;
}
public static void ServerModelsToPermissionTreeViewNodes(List<PermissionTreeViewNode> nodesToSave, Action<PermissionTreeViewNode> alterNode, IEnumerable<ServerModel> serversToConvert, Guid serverParentId)
{
// 过虑并排序
var sortedServers = serversToConvert.Where(s => s.ParentId == serverParentId).OrderBy(s => s.Order);
foreach (ServerModel server in sortedServers)
{
PermissionTreeViewNode node = new PermissionTreeViewNode(server.Id.ToString(), server.IsGroup, server.Name);
alterNode(node);
nodesToSave.Add(node);
if (server.IsGroup)
{
// 递归,继续初始化孩子节点
List<PermissionTreeViewNode> nodes = nodesToSave[nodesToSave.Count - 1].childs;
IEnumerable<ServerModel> servers = serversToConvert.Except(sortedServers);
ServerModelsToPermissionTreeViewNodes(nodes, alterNode, servers, server.Id);
}
}
}
#region 权限控件Json数据初始化
///<summary>
///
///</summary>
///<typeparam name="T"></typeparam>
///<param name="mode"></param>
///<param name="systemId"></param>
///<param name="permissionItem"></param>
///<returns></returns>
public static T ParsePermissionControlJsonData<T>(Mode mode, Guid systemId, PermissionItemModel permissionItem)
where T : class, new()
{
string jsonData = permissionItem.JsonDataConst;
// 如果设置了地址
string jsonDataUrl = permissionItem.JsonDataUrl.Trim().Replace("\\", "/");
if (!string.IsNullOrEmpty(jsonDataUrl))
{
// 如果是相对地址
if (jsonDataUrl.IndexOf("://") == -1)
{
// 确保为完整地址
jsonDataUrl = mode.Url.Content("~/" + jsonDataUrl.TrimStart('/'), true);
}
// 附加参数 systemId、permissionCode
string tail = string.Empty;
if (jsonDataUrl.IndexOf('#') > -1)
{
string[] array = jsonDataUrl.Split('#');
jsonDataUrl = array[0];
tail = array[1];
}
if (jsonDataUrl.IndexOf('?') == -1)
{
jsonDataUrl = string.Concat(jsonDataUrl, "?systemId=", systemId, "&permissionCode=", permissionItem.Code);
}
else
{
jsonDataUrl = string.Concat(jsonDataUrl.TrimEnd('&'), "&systemId=", systemId, "&permissionCode=", permissionItem.Code);
}
if (!string.IsNullOrEmpty(tail))
{
jsonDataUrl = string.Concat(jsonDataUrl, "#", tail);
}
// 获取远程Json数据
jsonData = WebRequestHelper.HttpGet(jsonDataUrl, mode.Request.Url.AbsoluteUri, Encoding.UTF8);
}
// 转为对象
return ObjectSerializer.ConvertFromJsonStringEx<T>(jsonData);
}
///<summary>
///
///</summary>
///<param name="options"></param>
///<param name="selectedValue"></param>
public static void InitDropDownListSelectedOptions(IEnumerable<PermissionDropDownListOption> options, string selectedValue)
{
// 先全不选
foreach (PermissionDropDownListOption option in options)
{
option.selected = false;
}
// 初始化已选
if (selectedValue != null)
{
foreach (PermissionDropDownListOption option in options)
{
option.selected = (option.value == selectedValue);
}
}
}
///<summary>
///
///</summary>
///<param name="nodes"></param>
///<param name="checkedIds"></param>
public static void InitTreeViewCheckedNodes(IEnumerable<PermissionTreeViewNode> nodes, string[] checkedIds)
{
// 先全不选
CheckTreeViewNodes(nodes, false);
if (checkedIds != null && checkedIds.Length > 0)
{
// 初始化已选节点
InitTreeViewCheckedNodesImpl(nodes, checkedIds);
// 如果父节点全选,要确保以后新增孩子节点时也是要是已选状态的
MakeSureTreeViewCheckAllNodes(nodes);
}
}
private static void CheckTreeViewNodes(IEnumerable<PermissionTreeViewNode> nodes, bool @checked)
{
foreach (var node in nodes)
{
node.@checked = @checked;
// 递归
CheckTreeViewNodes(node.childs, @checked);
}
}
private static void InitTreeViewCheckedNodesImpl(IEnumerable<PermissionTreeViewNode> nodes, string[] checkedIds)
{
if (checkedIds != null)
{
foreach (var node in nodes)
{
if (checkedIds.Any(id => id == node.id))
{
node.@checked = true;
}
// 递归
InitTreeViewCheckedNodesImpl(node.childs, checkedIds);
}
}
}
private static void MakeSureTreeViewCheckAllNodes(IEnumerable<PermissionTreeViewNode> nodes)
{
foreach (var node in nodes)
{
// 如果父节点全选,要确保以后新增孩子节点时也是要是已选状态的
if (node.isParent && node.@checked)
{
CheckTreeViewNodes(node.childs, true);
}
// 递归
MakeSureTreeViewCheckAllNodes(node.childs);
}
}
#endregion
}
显示控件处理完了,那保存权限值呢?获取权限值的实现也放在PermissionUtils助手类里了,第一个方法GetPostedPermissionValues就是获取提交的权限值。
三、验权的代码实现
每个用户有多个角色,又有永久和临时权限,那总要处理权限的合理继承吧,本方案是这样处理的:
1. 单选框 与 树视图 类型,在 角色权限、永久权限、临时权限(有效时期内) 中任何已勾选的权限都会一直会继承下去;
2. 文本框 与 下拉框 类型,是按 临时权限 -〉永久权限 -〉角色权限(按排序的顺序) 的顺序,前者的权限为空或没有选择时才会继承后者的权限值;
这些处理都在用户登录成功的时候处理的,并保存权限值到一个字典里,实现代码在MDMS.UserApiModule项目中的UserApiService类:
private Dictionary<string, PermissionValueModel> GetUserPermissions(Guid systemId, Guid userId, List<RoleModel> roles)
{
Dictionary<string, PermissionValueModel> permissions = new Dictionary<string, PermissionValueModel>(StringComparer.OrdinalIgnoreCase);
// 先初始化原始权限
List<PermissionItemModel> rowPermissionItems = DataProviderManager.Get<IPermissionItemProvider>().GetAllBySystemId(systemId);
foreach (PermissionItemModel item in rowPermissionItems)
{
permissions.Add(item.Code, new PermissionValueModel
{
Code = item.Code,
DisplayName = item.DisplayName,
DisplayStyle = item.DisplayStyle,
Value = (item.DisplayStyle == PermissionItemDisplayStyle.CheckBox ? "false" : string.Empty)
});
}
// 个人权限继承说明:
// 1. 单选框 与 树视图 类型,在 角色权限、永久权限、临时权限(有效时期) 中任何已勾选的权限都会一直会继承下去;
// 2. 文本框 与 下拉框 类型,是按 临时权限 -〉永久权限 -〉角色权限(按排序的顺序) 的顺序,前者的权限为空或没有选择时才会继承后者的权限值;
// 临时权限
UserTempPermissionModel userTempPermission = DataProviderManager.Get<IUserTempPermissionProvider>().GetByUserIdAndSystemId(userId, systemId);
if (userTempPermission.UserId == userId && userTempPermission.SystemId == systemId
&& userTempPermission.Enabled && userTempPermission.BeginDate <= DateTime.Now && DateTime.Now <= userTempPermission.EndDate)
{
// 存在且启用且在有效期内时才合并
UnionPermissions(permissions, userTempPermission.PermissionJsonData);
}
// 永久权限
UserPermissionModel userPermission = DataProviderManager.Get<IUserPermissionProvider>().GetByUserIdAndSystemId(userId, systemId);
if (userPermission.UserId == userId && userPermission.SystemId == systemId)
{
// 存在时才合并
UnionPermissions(permissions, userPermission.PermissionJsonData);
}
// 合并所有角色中的权限
foreach (RoleModel role in roles)
{
UnionPermissions(permissions, role.PermissionJsonData);
}
return permissions;
}
private void UnionPermissions(Dictionary<string, PermissionValueModel> to, string fromJsonData)
{
if (string.IsNullOrEmpty(fromJsonData))
{
return; // 没权限需要处理
}
Dictionary<string, PermissionValueModel> temp = ObjectSerializer.ConvertFromJsonStringEx<Dictionary<string, PermissionValueModel>>(fromJsonData);
foreach (KeyValuePair<string, PermissionValueModel> pair in temp)
{
PermissionValueModel value;
if (to.TryGetValue(pair.Key, out value))
{
// 已存在,更新权限值
switch (value.DisplayStyle)
{
case PermissionItemDisplayStyle.CheckBox:
// 没有权限就继承
if (Utils.StringToBool(value.Value, false) == false)
{
value.Value = pair.Value.Value;
}
break;
case PermissionItemDisplayStyle.TextBox:
case PermissionItemDisplayStyle.DropDownList:
// TextBox:如果为空则继承后者
// DropDownList:如果没选择则继承后者
if (value.Value.IsNullOrTrimedEmpty())
{
value.Value = pair.Value.Value;
}
break;
case PermissionItemDisplayStyle.TreeView:
// 合并不重复的权限
string tempString;
HashSet<string> toSave = new HashSet<string>();
HashSet<string> toUnion = new HashSet<string>();
value.Value.Split(',').ForEach(s =>
{
if ((tempString = s.Trim()) != string.Empty)
{
toSave.Add(tempString);
}
});
pair.Value.Value.Split(',').ForEach(s =>
{
if ((tempString = s.Trim()) != string.Empty)
{
toSave.Add(tempString);
}
});
// save the result unioned
value.Value = string.Join(",", toSave.Union(toUnion).ToArray());
break;
default:
throw new Exception("Unknown PermissionItemDisplayStyle.");
}
// 保存权限值
to[pair.Key] = value;
}
else
{
// 不存在,添加新权限值
to.Add(pair.Key, pair.Value);
}
}
}
要验证权限,那首先要做的就是获取权限值,在MDMS.UserApiModule项目中的ServiceContextBase类中实现所有类型的权限值获取方法,所以子系统只要继承这个类,就可以轻松的进行权限的验证了,请看获取权限值的方法实现:
#region Permission Services
private ReadFreeCache<string, object> PermissionValueCache
{
get
{
if (User.Profile.Id == Guid.Empty)
{
throw new Exception("No logined user.");
}
HttpContext context = HttpContext.Current;
if (context == null)
{
// 非 Web 系统
return permissionValueCache;
}
else
{
ReadFreeCache<string, object> cache = context.Session["___PermissionValueCache"] as ReadFreeCache<string, object>;
if (cache == null)
{
cache = ReadFreeCache<string, object>.Create(StringComparer.OrdinalIgnoreCase);
context.Session["___PermissionValueCache"] = cache;
}
return cache;
}
}
}
///<summary>
///
///</summary>
///<param name="permissionCode"></param>
///<returns></returns>
public PermissionValueModel GetPermissionValue(string permissionCode)
{
permissionCode.ThrowsIfNullOrEmpty("permissionCode");
PermissionValueModel permission;
if (User.Permissions.TryGetValue(permissionCode, out permission))
{
return permission;
}
throw new Exception("PermissionCode \"" + permissionCode + "\" does not exist.");
}
///<summary>
///
///</summary>
///<param name="permissionCode"></param>
///<returns></returns>
public bool GetCheckBoxPermissionValue(string permissionCode)
{
permissionCode.ThrowsIfNullOrEmpty("permissionCode");
PermissionValueModel permission = GetPermissionValue(permissionCode);
if (permission.DisplayStyle != PermissionItemDisplayStyle.CheckBox)
{
throw new Exception("DisplayStyle of permission code \"" + permissionCode + "\" is not CheckBox.");
}
return Utils.StringToBool(permission.Value, false);
}
///<summary>
///
///</summary>
///<typeparam name="T"></typeparam>
///<param name="permissionCode"></param>
///<returns></returns>
public T GetTextBoxPermissionValue<T>(string permissionCode)
{
return GetTextBoxPermissionValue(permissionCode, default(T));
}
///<summary>
///
///</summary>
///<typeparam name="T"></typeparam>
///<param name="permissionCode"></param>
///<param name="defaultValue"></param>
///<returns></returns>
public T GetTextBoxPermissionValue<T>(string permissionCode, T defaultValue)
{
permissionCode.ThrowsIfNullOrEmpty("permissionCode");
string permissionCodeKey = string.Format("{0}_{1}", User.Profile.UserName, permissionCode);
// TextBox权限使用缓存,不必每次处理数据转换
object permissionValue = PermissionValueCache.Get(permissionCodeKey, key =>
{
PermissionValueModel permission = GetPermissionValue(permissionCode);
if (permission.DisplayStyle != PermissionItemDisplayStyle.TextBox)
{
throw new Exception("DisplayStyle of permission code \"" + permissionCode + "\" is not TextBox.");
}
return Utils.StringTo<T>(permission.Value, defaultValue);
});
return (T)permissionValue;
}
///<summary>
///
///</summary>
///<typeparam name="T"></typeparam>
///<param name="permissionCode"></param>
///<returns></returns>
public T GetDropDownListPermissionValue<T>(string permissionCode)
{
return GetDropDownListPermissionValue(permissionCode, default(T));
}
///<summary>
///
///</summary>
///<typeparam name="T"></typeparam>
///<param name="permissionCode"></param>
///<param name="defaultValue"></param>
///<returns></returns>
public T GetDropDownListPermissionValue<T>(string permissionCode, T defaultValue)
{
permissionCode.ThrowsIfNullOrEmpty("permissionCode");
string permissionCodeKey = string.Format("{0}_{1}", User.Profile.UserName, permissionCode);
// DropDownList权限使用缓存,不必每次处理数据转换
object permissionValue = PermissionValueCache.Get(permissionCodeKey, key =>
{
PermissionValueModel permission = GetPermissionValue(permissionCode);
if (permission.DisplayStyle != PermissionItemDisplayStyle.DropDownList)
{
throw new Exception("DisplayStyle of permission code \"" + permissionCode + "\" is not DropDownList.");
}
return Utils.StringTo<T>(permission.Value, defaultValue);
});
return (T)permissionValue;
}
///<summary>
///
///</summary>
///<typeparam name="T"></typeparam>
///<param name="permissionCode"></param>
///<returns></returns>
public List<T> GetTreeViewPermissionValue<T>(string permissionCode)
{
return GetTreeViewPermissionValue(permissionCode, (Func<List<T>, List<T>>)null);
}
///<summary>
///
///</summary>
///<typeparam name="T"></typeparam>
///<param name="permissionCode"></param>
///<param name="rectifyValues">为防止父节点被选后,后来新增的子节点没有被加进来,请根据具体情况修正权限值列表</param>
///<returns></returns>
public List<T> GetTreeViewPermissionValue<T>(string permissionCode, Func<List<T>, List<T>> rectifyValues)
{
permissionCode.ThrowsIfNullOrEmpty("permissionCode");
string permissionCodeKey = string.Format("{0}_{1}_{2}",
User.Profile.UserName,
permissionCode,
(rectifyValues == null ? string.Empty : "rectifyValues"));
// TreeView权限使用缓存,不必每次处理数据转换
object permissionValue = PermissionValueCache.Get(permissionCodeKey, key =>
{
PermissionValueModel permission = GetPermissionValue(permissionCode);
if (permission.DisplayStyle != PermissionItemDisplayStyle.TreeView)
{
throw new Exception("DisplayStyle of permission code \"" + permissionCode + "\" is not TreeView.");
}
List<T> values = null;
if (string.IsNullOrEmpty(permission.Value))
{
values = new List<T>();
}
else
{
List<T> tempValues = new List<T>();
foreach (string value in permission.Value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
{
tempValues.Add(Utils.StringTo<T>(value));
}
if (rectifyValues == null)
{
rectifyValues = v => v;
}
values = rectifyValues(tempValues);
}
return values;
});
return (List<T>)permissionValue;
}
#endregion
另外值得说的是,GetTreeViewPermissionValue方法有个Func<List<T>, List<T>> rectifyValues参数,它的作用是,如果在授权时勾选了某个根节点,那表示此根节点下的所有权限都是有的,包括以后在该根节点新加的子节点,所以需要额外根据根节点获取所有的子节点,以确保获取的权限是完整的。
在主数据系统设计中,主数据系统也被当成自己的一个子系统来处理了,下面看看主数据页面基类是怎么获取权限的?
#region 验证权限
///<summary>
/// 选择框权限是否无效
///</summary>
///<param name="permissionCode"></param>
///<returns></returns>
protected bool IsCheckBoxPermissionInvalid(string permissionCode)
{
return (ServiceContext.Current.GetCheckBoxPermissionValue(permissionCode) == false);
}
///<summary>
/// 树视图权限是否无效
///</summary>
///<param name="permissionCode"></param>
///<param name="ids">需要验证的自定义数据</param>
///<returns></returns>
protected bool IsTreeViewPermissionInvalid(string permissionCode, params Guid[] ids)
{
permissionCode.ThrowsIfNullOrEmpty("permissionCode");
if (ids == null || ids.Length == 0 || ids[0] == Guid.Empty/* id为空时不用检验 */)
{
return false;
}
// 获取权限值
List<Guid> scopeList = ServiceContext.Current.GetTreeViewPermissionValue<Guid>(permissionCode, list =>
{
List<Guid> retList = list;
// 如果有已选数据,根据情况进行数据矫正
if (list.Count > 0)
{
switch (permissionCode.ToLower())
{
case "manage_role_scope":
if (list.Exists(id => id == Guid.Empty))
{
// 全选情况:返回所有ID,包括后来新增的数据
retList = (from p in SystemService.GetAll() select p.Id).ToList();
}
break;
case "manage_permission_scope":
if (list.Exists(id => id == Guid.Empty))
{
// 全选情况:返回所有ID,包括后来新增的数据
retList = (from p in SystemService.GetAll() select p.Id).ToList();
}
break;
default:
throw new Exception("Unknown permissionCode: " + permissionCode);
}
}
return retList;
});
// 验证权限
bool isInvalid = false;
if (scopeList.Count == 0)
{
// 没有选中数据
isInvalid = true;
}
else
{
// 是否有不在范围的?
foreach (Guid id in ids)
{
if (scopeList.Exists(i => i == id) == false)
{
// 没有权限,不再继续判断
isInvalid = true;
break;
}
}
}
return isInvalid;
}
#endregion
有了上面的页面基类,最后的权限验证就变得简洁方便了
写到这就算结束了,设计中的内容比较多,不好处处仔细的解说。本方案中,用json来保存权限使实现变得简单了很多,而且是采用key/value的字典格式,所以子系统在以后需要新增新的权限项或删除权限项,都不会影响到用户的权限(意思是不用解决权限版本问题,随意添加删除权限都可以正常动作),时间问题只能以这种方式来讲解了,需要深入了解本方案设计的朋友请下载整个系统的源码来看,如果本文对你有帮助,请点击推荐以示支持,谢谢。
在线demo: http://mdms.kudystudio.com/
用户/密码:test1/test1 test2/test2 (注:同一用户在另一浏览器登录,另一用户在session失效后会被逼下线)