在《开源企业即时通讯和在线客服》中已介绍了Lesktop的桌面模式和Web模式,但是没有移动端。评论中 dotnetcms.org工作室 提到了LayIM,看了一下官网的演示和文档,如果用这套LayIM的移动端Lestop也可以轻松开发出移动端web版本。本文将说明如何接入LayIM移动端UI,同时对一些Lesktop的接口进行说明,作为接入其他前端UI的指引。
移动端功能展示:
源代码下载:https://files.cnblogs.com/files/lucc/IM3.1.zip
源代码Git: https://github.com/luchuncheng/Lesktop.git
在线演示
移动端:http://im.luchuncheng.com/mobile.aspx
注册用户&内部人员
Web版:http://im.luchuncheng.com
PC版下载:http://client.luchuncheng.com
客服平台(访客端)
Web版:http://service.luchuncheng.com
PC版:http://im.luchuncheng.com/client.ashx?chatwith=4&embedcode=1&createaccount=true
(embedcode=1表示显示ID=1的客服嵌入代码指定的客服人员,chatwith=4表示启动和ID=4的客服人员对话窗口)
1、登录
接入的第一个步骤就是登录,登录界面非常简单,就是两个文本框和一个登录按钮,服务单只需要调用ServerImpl.Instance.Login即可:
int userid = AccountImpl.Instance.GetUserID(user); // 仅验证不启动回话,重定向到default.aspx再启动回话 ServerImpl.Instance.Login("", Context, userid, false, null, false, 2);
最后第二个参数startSession=false,表示只是设置cookie不启动会话,移动端的login.aspx仅仅只是验证,登录后重定向到default.aspx再启动会话
最后一个参数device=2表示登录设备为移动端web版
2、初始化
LayIM初始化时需要好友,群组和分组等信息,因此登录完成后需要提供这些数据。在此之前先了解一下Lesktop的常用联系人功能:
如上图所示,Lesktop允许用户自己创建常用联系人分组,支持无限层级,用户可以将好友或内部人员添加到自建的任何层级的分组中。由于LayIM不支持多层次分组,所以在移动端中将所有常用联系人不分层级显示,如下图所示
除了常用联系人,还需要一个“我的好友”分组,用于显示已加自己为好友的注册用户。接下来需要了解几个和分组,好友和群组相关的接口:
(1) ServerImpl.Instance.CommonStorageImpl.GetCategories
GetCategories用于获取用户创建的所有分组,返回值是一个DataRowCollection,每一行包括5个列:
ID | 类别ID |
UserID | 添加到该列表的联系人ID |
Name | 分组名称 |
ParentID | 父级分组ID(移动端用不上) |
Type | 分组类别(1:联系人,2:群组,3:部门,移动端只用到联系人的) |
(2) ServerImpl.Instance.CommonStorageImpl.GetCategoryItems
GetCategoryItems获取和分组相关的所有联系人,群组和部门ID(移动端只用到联系人),返回值为DataRowCollection,每一行包括4个列:
UserID | 创建该分类的用户ID |
CategoryID | 类别ID |
ItemID | 联系人,群组或部门ID(移动端只用到联系人) |
CategoryType | 分组类别(1:联系人,2:群组,3:部门,移动端只用到联系人的) |
(3) Category_CH.GetAccountInfos
GetCategoryItems只能获取和分组相关的所有联系人的ID,还需要调用GetAccountInfos才能获取到联系人的全部详细信息,返回值为AccountInfo数组,AccountInfo属性如下:
ID | 用户ID |
Name | 登录名 |
Nickname | 昵称 |
Type | 类型,0-联系人,1-群组 |
SubType | 子类型:0-注册用户,1-管理员创建的内部人员 |
IsTemp | 是否为临时用户,即访客 |
IsDeleted | 是否已被删除 |
HeadIMG | 头像 |
(4) AccountImpl.Instance.GetVisibleUsersDetails
GetVisibleUsersDetails用于获取所有和指定用户相关的联系人和群组,包括所有由管理员创建的内部人员,已加自己为好友的注册用户,自己创建和加入的所有群组和自己创建或被拉进去的多人会话,这部分数据主要是为LayIM提供“我的好友”分组和群聊,返回值为一个Hashtable,每个项的值为AccountInfo。
(5)ServerImpl.Instance.GetCurrentUser
GetCurrentUser用户获取当前用户详细信息(AccountInfo)
以上5个接口已经获取到了所有LayIM初始化需要的数据,打包成json,“赋值”给页面的MobileInitParams全局变量即可:
namespace Core.Web { public class Mobile_Default : System.Web.UI.Page { string init_params_ = "{}"; protected void Page_Load(object sender, EventArgs e) { AccountInfo current_user = ServerImpl.Instance.GetCurrentUser(Context); if(current_user != null) { String sessionId = Guid.NewGuid().ToString().ToUpper(); ServerImpl.Instance.Login(sessionId, Context, current_user.ID, false, DateTime.Now.AddDays(7), true, 2); DataRowCollection categories = ServerImpl.Instance.CommonStorageImpl.GetCategories(current_user.ID); DataRowCollection items = ServerImpl.Instance.CommonStorageImpl.GetCategoryItems(current_user.ID); Hashtable users = Category_CH.GetAccountInfos(items); AccountInfo[] visible_users = AccountImpl.Instance.GetVisibleUsersDetails(current_user.Name); init_params_ = Utility.RenderHashJson( "Result", true, "IsLogin", true, "UserInfo", current_user.DetailsJson, "SessionID", sessionId, "CompanyInfo", ServerImpl.Instance.CommonStorageImpl.GetCompanyInfo(), "Categories", categories, "CategorieItems", items, "CategorieUsers", users, "VisibleUsers", visible_users ); } else { Response.Redirect("login.aspx"); } } public string InitParams { get { return init_params_; } } } }
页面加载后,LayIM_Init里面就可以通过MobileInitParams获取到这些数据,LayIM初始化参数请看官网文档,以下函数用于将Lesktop的数据转换成LayIM需要的格式:
// 获取分组和联系人 function GetFriends() { var friends = []; for (var i = 0; i < window.MobileInitParams.Categories.length; i++) { var category = window.MobileInitParams.Categories[i]; if (category.Type == 1) { // Type=1为常用联系人类别,将所有常用联系人类别(不分层次)显示为LayIM的分组 var groupid = category.ID + 10000; var group = { "groupname": category.Name, "id": groupid.toString(), "online": 0, "list": [] }; var user_count = 0; var online_count = 0; for (var j = 0; j < window.MobileInitParams.CategorieItems.length; j++) { // 从CategorieItems中获取该分组所有联系人ID var item = window.MobileInitParams.CategorieItems[j]; if (item.CategoryID == category.ID) { // 通过联系人ID从CategorieUsers中获取联系人详细信息 var friend_info = window.MobileInitParams.CategorieUsers[item.ItemID.toString()]; if(friend_info != undefined) { group.list.push({ "username": friend_info.Nickname, "id": friend_info.ID.toString(), "avatar": Core.CreateHeadImgUrl(friend_info.ID, 150, false, friend_info.HeadIMG), "sign": "" }); user_count++; if (friend_info.State == "Online") { online_count++; } } } } if (user_count > 0) { friends.push(group); } } } var grou_myfriend = { "groupname": "我的好友", "id": LayIMGroup_MyFriend, "online": 0, "list": [] } var current_user = window.MobileInitParams.UserInfo; // 获取所有好友并显示到好友分组 for (var i = 0; i < window.MobileInitParams.VisibleUsers.length; i++) { var user = window.MobileInitParams.VisibleUsers[i]; if (user.Type == 0 && ((current_user.SubType == 1 && user.SubType == 0) || current_user.SubType == 0)) { // 内部人员(SubType=1)显示注册用户并添加自己为好友的,不包括其他内部人员 // 注册用户(SubType=0)显示添加自己为好友的其他注册用户和内部用户 grou_myfriend.list.push({ "username": user.Nickname, "id": user.ID.toString(), "avatar": Core.CreateHeadImgUrl(user.ID, 150, false, user.HeadIMG), "sign": "" }); } } friends.push(grou_myfriend); friends.push({ "groupname": "其他联系人", "id": LayIMGroup_Other, "online": 0, "list": [] }); return friends; }
// 获取群聊 function GetGroups() { // 获取所有群组和多人会话 var groups = []; for (var i = 0; i < window.MobileInitParams.VisibleUsers.length; i++) { var user = window.MobileInitParams.VisibleUsers[i]; if(user.Type == 1) { groups.push({ "groupname": user.Nickname, "id": user.ID.toString(), "avatar": Core.CreateGroupImgUrl(user.HeadIMG, user.IsTemp) }); } } return groups; }
3、接收消息
此次为了接入LayIM,加了一个全局委托Core.OnNewMessage,每当收到新消息是会调用该委托,如果需要监听新消息,只需要附加一个处理函数即可
function LayIM_OnNewMessage(msg) { } // 监听新消息 Core.OnNewMessage.Attach(LayIM_OnNewMessage);
由于收到的消息可能是web或pc端发送的,包含LayIM消息面板不支持的富文本,因此需要先处理掉所有HTML tag,此外还需要处理文件标志([FILE:...])生成下载链接,完整代码如下:
function LayIM_ParseMsg(text) { var newText = text; try { // 处理掉HTML开始TAG newText = text.toString().replace( /<([a-zA-Z0-9]+)([\s]+)[^<>]*>/ig, function (html, tag) { if (tag.toLowerCase() == "img") { var filename = Core.GetFileNameFromImgTag(html); if (filename != "") { // Lesktop服务器上的文件,重新加上分辨率限制参数,改为下载缩略图,链接到原图 var url = Core.CreateDownloadUrl(filename); return String.format("a({0})[img[{0}&MaxWidth=450&MaxHeight=800]]", url); } else { // 外源图片,改成超链接,防止下载图片浪费流量 var src = Core.GetSrcFromImgTag(html); return String.format("a({0})[{1}]", src, " 图片 "); } } return ""; } ) .replace( /\x5BFILE:([^\x5B\x5D]+)\x5D/ig, function (filetag, filepath) { // 提取文件消息,改为视频,音频或文件 var path = unescape(filepath) var ext = Core.Path.GetFileExtension(path).toLowerCase(); if (ext == ".mp4" || ext == ".mov") { return String.format("video[{0}]", Core.CreateDownloadUrl(path), Core.Path.GetFileName(path)); } else if (ext == "mp3") { return String.format("audio[{0}]", Core.CreateDownloadUrl(path), Core.Path.GetFileName(path)); } else { return String.format("file({0})[{1}]", Core.CreateDownloadUrl(path), Core.Path.GetFileName(path)); } } ) .replace( /<([a-zA-Z0-9]+)[\x2F]{0,1}>/ig, function (html, tag) { // 清理<br/>等 return ""; } ) .replace( /<\/([a-zA-Z0-9]+)>/ig, function (html, tag) { // 清理HTML结束TAG return ""; } ); } catch(ex) { newText += " ERROR:"; newText += ex.message; } return newText; } function LayIM_OnNewMessage(msg) { // msg.Sender, msg.Receiver只包括最基本的ID,Name,需重新获取详细信息 var sender_info = Core.AccountData.GetAccountInfo(msg.Sender.ID); if (sender_info == null) sender_info = msg.Sender; var receiver_info = Core.AccountData.GetAccountInfo(msg.Receiver.ID); if (receiver_info == null) receiver_info = msg.Receiver; if (msg.Receiver.Type == 0) { // 私聊消息 if (!LayIM_UserExists(sender_info.ID.toString())) { // 分组列表中不包括消息发送者,将发送者加入到其他联系人分组 layim.addList({ type: ‘friend‘, "username": sender_info.Nickname, "id": sender_info.ID.toString(), "groupid": LayIMGroup_Other, "avatar": Core.CreateHeadImgUrl(sender_info.ID, 150, false, sender_info.HeadIMG), "sign": "" }); } // 显示到LayIM消息面板 layim.getMessage({ username: sender_info.Nickname, avatar: Core.CreateHeadImgUrl(msg.Sender.ID, 150, false, sender_info.HeadIMG), id: msg.Sender.ID.toString(), type: "friend", cid: msg.ID.toString(), content: LayIM_ParseMsg(msg.Content) }); } else if (msg.Receiver.Type == 1) { // 群消息 if (!LayIM_GroupExists(receiver_info.ID.toString())) { // 群聊列表中不包括该群,加入到群聊中 layim.addList({ "type": "group", "groupname": receiver_info.Nickname, "id": receiver_info.ID.toString(), "avatar": Core.CreateGroupImgUrl(receiver_info.HeadIMG, receiver_info.IsTemp) }); } // 显示到LayIM消息面板 layim.getMessage({ username: sender_info.Nickname, avatar: Core.CreateHeadImgUrl(msg.Sender.ID, 150, false, sender_info.HeadIMG), id: msg.Receiver.ID.toString(), type: "group", cid: msg.ID.toString(), content: LayIM_ParseMsg(msg.Content) }); } } // 监听新消息 Core.OnNewMessage.Attach(LayIM_OnNewMessage);
4、发送消息
发送消息只需要调用服务端的WebIM.NewMessage方法即可,发送前,需要对消息进行预处理,把LayIM的标志(图片,文件和表情)转换成HTML,还需要调用Core.TranslateMessage,该函数用于将消息中的图片(<img ...>),文件([FILE:...])转换成服务端可以处理的附件,具体代码如下:
function LayIM_SendMsg_GetFileName(fileurl) { var filename_regex = /FileName\=([^\s\x28\x29\x26]+)/ig; filename_regex.lastIndex = 0 var ret = filename_regex.exec(fileurl); if (ret == null || ret.length <= 1) { return ""; } return ret[1]; } function LayIM_SendMsg(data) { var msgdata = { Action: "NewMessage", Sender: parseInt(data.mine.id, 10), Receiver: parseInt(data.to.id, 10), DelTmpFile: 0, Content: "" }; var content = data.mine.content; // 转换图片消息 content = content.replace( /img\x5B([^\x5B\x5D]+)\x5D/ig, function(imgtext, src) { var filename = LayIM_SendMsg_GetFileName(src); return String.format(‘<img src="{0}">‘, Core.CreateDownloadUrl(filename)); } ); // 转换文件消息 content = content.replace( /file\x28([^\x28\x29]+)\x29\x5B([^\x5B\x5D]+)\x5D/ig, function (filetext, fileurl, ope) { var path = unescape(LayIM_SendMsg_GetFileName(fileurl)); return Core.CreateFileHtml([path]); } ); // 将消息中的图片(<img ...>),文件([FILE:...])转换成服务端可以处理的附件 content = Core.TranslateMessage(content, msgdata); // 转换表情 content = content.replace( /face\[([^\s\[\]]+?)\]/g, function (face, face_type) { var face_file = LayIM_FaceToFile[face_type]; if(face_file != undefined) { return String.format(‘<img src="{0}/{1}">‘, Core.GetUrl("layim/images/face"), face_file); } } ); msgdata.Content = content; Core.SendCommand( function (ret) { var message = ret; }, function (ex) { var errmsg = String.format("由于网络原因,消息\"{0}\"发送失败,错误信息:{1}", text, ex.Message); }, Core.Utility.RenderJson(msgdata), "Core.Web WebIM", false ); }
5、异常状态处理
Lesktop有以下几种异常状态:
(1) 在其他浏览器或客户端登录,此时会收到强制下线通知(GLOBAL:OFFLINE)
(2) 服务端已升级,为简化服务端开发,Lesktop服务端要求客户端和前端都用对应的最新版本,不兼容旧版本。服务端网站升级后,升级前未退出重新连接上的客户端和web端都会收到不兼容异常通知(IncompatibleException)。PC需要重启升级,WEB端需要重登陆(发布版所有静态资源都放在名称为版本号的文件夹中,重登陆不会读取到缓存的资源)
(3) 验证身份异常,服务端网站可能会因为某种原因重新启动,此时会重新生成cookie加密密钥,会导致已在线的客户端无法从cookie获取身份信息,此时客户端会收到验证异常通知(UnauthorizedException)
移动端处理异常方法很简单,收到异常通知后,立刻重定向到offline.aspx页面,显示异常消息和重新登录按钮,如下图所示:
至此,接入LayIM的工作就基本完成,前端代码基本都在mobile.js中。
因为LayIM不是开源的,因此git上不包括LayIM的源代码,需要自行购买,然后将src下的所有文件放到CurrentVersion/layim下: