审批控件中的外部选项 - 文档 - 企业微信开发者中心 (qq.com)
相关文档需要仔细阅读。
注意事项:
(1)signature时,url 的问题:如果设置的是明细中的选项时,企微自动加上的参数中key的值会有 中括号 [] , 我们要将这两个符号进行下转换,[ 转换成 %5B 、] 转换成 %5D,之后使用转换后的url 进行 signature
(2)签名按照官方文档
(3)access_token 、 ticket 要做本地缓存保存
(4)应用要设置可信域名、可信IP
1. JS-SDK 中 wx.config 、wx.agentConfig
wx.config({ beta: true,// 必须这么写,否则wx.invoke调用形式的jsapi会有问题 debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。 appId: resConfig.appId, // 必填,企业微信的corpID,必须是本企业的corpID,不允许跨企业使用 timestamp: resConfig.timestamp, // 必填,生成签名的时间戳 nonceStr: resConfig.noncestr, // 必填,生成签名的随机串 signature: resConfig.signature,// 必填,签名,见 附录-JS-SDK使用权限签名算法 jsApiList: ['selectExternalContact', 'saveApprovalSelectedItems', 'getApprovalSelectedItems', 'agentConfig'] // 必填,需要使用的JS接口列表,凡是要调用的接口都需要传进来 });
wx.agentConfig({ debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。 corpid: resConfigAgent.corpid, // 必填,企业微信的corpid,必须与当前登录的企业一致 agentid: resConfigAgent.agentid, // 必填,企业微信的应用id (e.g. 1000247) timestamp: resConfigAgent.timestamp, // 必填,生成签名的时间戳 nonceStr: resConfigAgent.noncestr, // 必填,生成签名的随机串 signature: resConfigAgent.signature,// 必填,签名,见附录-JS-SDK使用权限签名算法 jsApiList: ['selectExternalContact', 'saveApprovalSelectedItems', 'getApprovalSelectedItems'], //必填,传入需要使用的接口名称 success: function (res) { console.log("agentConfig调用成功。" + JSON.stringify(res)); }, fail: function (res) { if (res.errMsg.indexOf('function not exist') > -1) { alert('版本过低请升级') } } });
2. access_token 、 ticket 这写暂不做详细说明,可以自行参考API
3.采用 .NET MVC 开发 相关代码如下:
后端代码:
using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.Mvc; namespace Web.Controllers { public class ExternalOptionsController : Controller { public const string corpId = ""; //企业微信的企业ID public const string agentId = ""; //企业微信的应用的AgentId public const string corpSecret = ""; //企业微信的应用的Secret /// <summary> /// GET: ExternalOptions /// selectorType 、 key 参数是企业微信那边自动添加上的 /// <summary> /// <param name="dataType">调用什么数据,如采购单:Purchase、供应商:Supplier</param> /// <param name="selectorType">表示该选择控件是单选还是多选,单选 - single,多选 - multi。请注意根据此参数限制用户的选择行为:单选时,只能选择1个,选择多个将报错;多选时,最多选择 300 个,超过将报错。</param> /// <param name="key">调用下述接口时需要原值传入</param> /// <returns></returns> public ActionResult Index(string dataType, string selectorType, string key) { if (dataType == "Purchase") { ViewData["Title"] = "采购单列表"; } else if (dataType == "Supplier") { ViewData["Title"] = "供应商列表"; } //将要展示的选项数据放到list里面 List<selectedDataItem> list = new List<selectedDataItem>(); if (dataType == "Purchase") { list.Add(new selectedDataItem() { key = "1", value = "CG20240618151234563001" }); list.Add(new selectedDataItem() { key = "2", value = "CG20240618151234563002" }); list.Add(new selectedDataItem() { key = "3", value = "CG20240618151234563003" }); } else if (dataType == "Supplier") { list.Add(new selectedDataItem() { key = "1", value = "上海****公司" }); list.Add(new selectedDataItem() { key = "2", value = "广东****公司" }); list.Add(new selectedDataItem() { key = "3", value = "海南****公司" }); } ViewBag.ItemData = list; ViewBag.selectorType = selectorType; return View(); } [HttpGet] public JsonResult GetConfigSignature(string url) { string timestamp = GenerateTimeStamp(); string noncestr = GenerateNonceStr(); string access_token = GetToken(corpId, corpSecret); string jsapi_ticket = GetTicket(access_token, agentId); //采用排序的Dictionary的好处是方便对数据包进行签名,不用再签名之前再做一次排序 SortedDictionary<string, object> m_values_config = new SortedDictionary<string, object>(); m_values_config["jsapi_ticket"] = jsapi_ticket; m_values_config["noncestr"] = noncestr; m_values_config["timestamp"] = timestamp; //企微自动传过来的参数key的值要进行转换 [] url = url.Replace("[", "%5B").Replace("]", "%5D"); m_values_config["url"] = url; string signContent = GetSignContent(m_values_config); string signature = CalSHA1(signContent); object data = new { appId = appId, timestamp = timestamp, noncestr = noncestr, jsapi_ticket = jsapi_ticket, url = url, signature = signature }; return Json(data, JsonRequestBehavior.AllowGet); } [HttpGet] public JsonResult GetConfigAgentSignature(string url) { long timestamp= Convert.ToInt64(GenerateTimeStamp()); string nonceStrAgent = GenerateNonceStr(); string access_token = GetToken(corpId, corpSecret); string jsapi_ticket = GetTicket(access_token, agentId, "agent_config"); //采用排序的Dictionary的好处是方便对数据包进行签名,不用再签名之前再做一次排序 SortedDictionary<string, object> m_values_agentConfig = new SortedDictionary<string, object>(); m_values_agentConfig["jsapi_ticket"] = jsapi_ticket; m_values_agentConfig["noncestr"] = nonceStrAgent; m_values_agentConfig["timestamp"] = timestampAgent; //企微自动传过来的参数key的值要进行转换 [] url = url.Replace("[", "%5B").Replace("]", "%5D"); m_values_agentConfig["url"] = url; string signContent = GetSignContent(m_values_agentConfig); string signature = CalSHA1(signContent); object data = new { corpid = appId, agentid = agentId, timestamp = timestampAgent, noncestr = nonceStrAgent, jsapi_ticket = jsapi_ticket, url = url, signature = signature }; return Json(data, JsonRequestBehavior.AllowGet); } /// <summary> /// Get Sign Content /// </summary> /// <param name="parameters"></param> /// <returns></returns> public string GetSignContent(SortedDictionary<string, object> parameters) { System.Text.StringBuilder query = new System.Text.StringBuilder(); foreach (KeyValuePair<string, object> pair in parameters) { if (!string.IsNullOrEmpty(pair.Key)) { if (pair.Value != null) { query.Append(pair.Key).Append("=").Append(pair.Value.ToString()).Append("&"); } else { query.Append(pair.Key).Append("=").Append("").Append("&"); } } } return query.ToString().TrimEnd('&'); } /// <summary> /// SHA签名算法 /// </summary> /// <param name="str"></param> /// <returns></returns> public string CalSHA1(string str) { System.Security.Cryptography.SHA1 sha1 = new System.Security.Cryptography.SHA1CryptoServiceProvider(); byte[] bytesIn = System.Text.Encoding.UTF8.GetBytes(str); byte[] bytesOut = sha1.ComputeHash(bytesIn); sha1.Dispose(); string result = BitConverter.ToString(bytesOut); result = result.Replace("-", ""); return result; } /// <summary> /// 获取access_token是调用企业微信API接口的第一步,相当于创建了一个登录凭证,其它的业务API接口,都需要依赖于access_token来鉴权调用者身份。 /// 因此开发者,在使用业务接口前,要明确access_token的颁发来源,使用正确的access_token。 /// </summary> /// <param name="corpId">企业微信的企业ID</param> /// <param name="corpSecret">企业微信应用的Secret</param> /// <returns></returns> public string GetToken(string corpId, string corpSecret) { string access_token = ""; try { string reqUrl = string.Format("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={0}&corpsecret={1}", corpId, corpSecret); string resData = HttpGet(reqUrl); dynamic result = JsonConvert.DeserializeObject<dynamic>(resData); if (result.error == 0) { access_token = result.access_token; } } catch (Exception ex) { throw ex; } return access_token; } /// <summary> /// 获取调用微信JS接口的临时票据(企业微信) /// (JS-SDK使用权限签名算法 企业的jsapi_ticket、应用的jsapi_ticket) /// </summary> /// <param name="accessToken">接口调用凭证</param> /// <param name="agent">企业微信对应应用AgentId</param> /// <param name="type">默认为空时获取企业的jsapi_ticket,为 agent_config 时获取应用的jsapi_ticket</param> /// <returns></returns> public string GetTicket(string accessToken, string agent, string type = "") { string ticket = ""; try { string tempUrl = ""; if (!string.IsNullOrWhiteSpace(type)) { tempUrl = "ticket/get"; } else { tempUrl = "get_jsapi_ticket"; } string reqUrl = string.Format("https://qyapi.weixin.qq.com/cgi-bin/{0}?access_token={1}", tempUrl, accessToken); if (!string.IsNullOrWhiteSpace(type)) { reqUrl += string.Format("&type={0}", type); } string resData = HttpGet(reqUrl); dynamic result = JsonConvert.DeserializeObject<dynamic>(resData); if (result.error == 0) { ticket = result.ticket; } } catch (Exception ex) { throw ex; } return ticket; } /// <summary> /// 发送 Get 请求 /// </summary> /// <param name="url">请求的地址</param> /// <param name="timeout">超时时间 默认为30秒</param> /// <returns></returns> public string HttpGet(string url, int timeout = 30000) { string result = ""; System.Net.HttpWebRequest request = null; try { request = (System.Net.HttpWebRequest)System.Net.WebRequest.Create(url); request.Timeout = timeout; request.Method = "GET"; request.ContentType = "application/json;charset=UTF-8"; using (System.Net.HttpWebResponse response = (System.Net.HttpWebResponse)request.GetResponse()) { using (System.IO.Stream stream = response.GetResponseStream()) { //获取响应内容 using (System.IO.StreamReader reader = new System.IO.StreamReader(stream, System.Text.Encoding.UTF8)) { result = reader.ReadToEnd(); } } } } catch (Exception ex) { throw ex; } finally { if (request != null) { request.Abort(); } } return result; } /// <summary> /// 生成时间戳,标准北京时间,时区为东八区,自1970年1月1日 0点0分0秒以来的秒数 /// </summary> /// <returns>时间戳</returns> public string GenerateTimeStamp() { TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0); return Convert.ToInt64(ts.TotalSeconds).ToString(); } /// <summary> /// 生成随机串,随机串包含字母或数字 /// </summary> /// <returns>随机串</returns> public string GenerateNonceStr() { System.Security.Cryptography.RNGCryptoServiceProvider csp = new System.Security.Cryptography.RNGCryptoServiceProvider(); byte[] buffer = new byte[sizeof(uint)]; csp.GetBytes(buffer); return BitConverter.ToUInt32(buffer, 0).ToString(); } } public class selectedDataItem { public string key { set; get; } public string value { set; get; } } }
前端代码:
@{ Layout = null; } <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <title>@ViewBag.Title</title> <link rel="stylesheet" type="text/css" href="https://res.wx.qq.com/open/libs/weui/1.1.2/weui-for-work.min.css" /> </head> <body data-color-mode="dark"> <div class="page"> <div class="page__bd"> <div class="weui-cells"> <div class="weui-cell"> <div class="weui-cell__bd"> <input class="weui-input" type="text" placeholder="请输入搜索内容" id="searchInput" /> </div> </div> </div> <div class="weui-cells weui-cells_radio" id="item_list"> @foreach (var item in ViewBag.ItemData) { if (@ViewBag.selectorType == "single") { <label class="weui-cell weui-check__label" for="@item.value"> <div class="weui-cell__bd"> <p>@item.value</p> </div> <div class="weui-cell__ft"> <input type="radio" class="weui-check" name="radio1" id="@item.value" value="@item.key" onclick="SaveSelectedData()" /> <span class="weui-icon-checked"></span> </div> </label> } else if (@ViewBag.selectorType == "multi") { <label class="weui-cell weui-check__label" for="@item.value"> <div class="weui-cell__hd"> <input type="checkbox" class="weui-check" name="checkbox1" id="@item.value" value="@item.key" onclick="SaveSelectedData()" /> <i class="weui-icon-checked"></i> </div> <div class="weui-cell__bd"> <p>@item.value</p> </div> </label> } } </div> </div> </div> <script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script> <script src="https://open.work.weixin.qq.com/wwopen/js/jwxwork-1.0.0.js"></script> <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script> <script type="text/javascript"> var dataType = GetQueryString('dataType'); var selectorType = GetQueryString('selectorType'); var itemData = []; var oldSelectedData = []; var url = location.href.split('#')[0]; //console.log(encodeURIComponent(url)); $(document).ready(function () { $('#searchInput').on("input", function (e) { let search = $(this).val(); // 处理输入事件 console.log('输入的内容:', search); //alert(search); ShowItem(search); }); GetConfigSignature(url).then( function (resConfig) { console.log("wx.config", resConfig); wx.config({ beta: true,// 必须这么写,否则wx.invoke调用形式的jsapi会有问题 debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。 appId: resConfig.appId, // 必填,企业微信的corpID,必须是本企业的corpID,不允许跨企业使用 timestamp: resConfig.timestamp, // 必填,生成签名的时间戳 nonceStr: resConfig.noncestr, // 必填,生成签名的随机串 signature: resConfig.signature,// 必填,签名,见 附录-JS-SDK使用权限签名算法 jsApiList: ['selectExternalContact', 'saveApprovalSelectedItems', 'getApprovalSelectedItems', 'agentConfig'] // 必填,需要使用的JS接口列表,凡是要调用的接口都需要传进来 }); wx.ready(function () { GetConfigAgentSignature(url).then( function (resConfigAgent) { console.log("wx.agentConfig", resConfigAgent); // config信息验证后会执行ready方法,所有接口调用都必须在config接口获得结果之后,config是一个客户端的异步操作,所以如果需要在页面加载时就调用相关接口,则须把相关接口放在ready函数中调用来确保正确执行。对于用户触发时才调用的接口,则可以直接调用,不需要放在ready函数中。 wx.agentConfig({ debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。 corpid: resConfigAgent.corpid, // 必填,企业微信的corpid,必须与当前登录的企业一致 agentid: resConfigAgent.agentid, // 必填,企业微信的应用id (e.g. 1000247) timestamp: resConfigAgent.timestamp, // 必填,生成签名的时间戳 nonceStr: resConfigAgent.noncestr, // 必填,生成签名的随机串 signature: resConfigAgent.signature,// 必填,签名,见附录-JS-SDK使用权限签名算法 jsApiList: ['selectExternalContact', 'saveApprovalSelectedItems', 'getApprovalSelectedItems'], //必填,传入需要使用的接口名称 success: function (res) { console.log("agentConfig调用成功。" + JSON.stringify(res)); GetSelectedData().then( function (res) { if (res) { oldSelectedData = JSON.parse(res); var inputType = ""; if (selectorType == "single") { inputType = "radio"; } else if (selectorType == "multi") { inputType = "checkbox"; } oldSelectedData.forEach((item, index) => { $('input[type="' + inputType + '"][name="' + inputType + '1"][id="' + item.value + '"]').attr('checked', true); }); } }, function () { alert('获取 getApprovalSelectedItems 出错了'); } ); }, fail: function (res) { if (res.errMsg.indexOf('function not exist') > -1) { alert('版本过低请升级') } } }); }, function (err) { alert('获取 wx.agentConfig 出错了'); } ); }); }, function (err) { alert('获取 wx.config 出错了'); } ); wx.error(function (res) { // config信息验证失败会执行error函数,如签名过期导致验证失败,具体错误信息可以打开config的debug模式查看,也可以在返回的res参数中查看,对于SPA可以在这里更新签名。 alert(JSON.stringify(res)); }); }); function ShowItem(search) { if (typeof (search) !== "undefined" && search !== null && search !== "") { var inputType = ""; if (selectorType == "single") { inputType = "radio"; } else if (selectorType == "multi") { inputType = "checkbox"; } $('input[type="' + inputType + '"][name="' + inputType + '1"]').each(function () { var value = $(this).attr("id"); // 已选中的 增加 checked 没选中的 移除 checked let isExistsKey = ExistsKey(value); if (isExistsKey) { $(this).attr('checked', true); } else { $(this).removeAttr('checked'); } // 匹配到的显示 未匹配到的隐藏 if (value.indexOf(search) == -1) { $(this).parent().parent().hide(); } else { $(this).parent().parent().show(); } }); } } function GetSelectedData() { return new Promise((resolve, reject) => { wx.invoke('getApprovalSelectedItems', { "key": GetQueryString('key'), // 字符串,从 URL 中获取到的 key }, (res) => { console.log("原始选项值" + JSON.stringify(res)); if (res.err_msg === "getApprovalSelectedItems:ok") { // 获取成功,res.selectedData 为获取到的已选中选项的 JSON 字符串,注意可能为空。格式见下文。 resolve(res.selectedData); } else { reject(res) } }); }); } function SaveSelectedData() { var selectedData = GetRadioValue(); wx.invoke('saveApprovalSelectedItems', { "key": GetQueryString('key'), // 字符串,从 URL 中获取到的 key "selectedData": selectedData // 字符串,选中的选项格式化为 JSON 字符串,格式见下文 }, (res) => { console.log("选项保存成功。" + JSON.stringify(res) + " " + JSON.stringify(selectedData)); if (res.err_msg === 'saveApprovalSelectedItems:ok') { // 保存成功 } }); } function GetRadioValue() {; var inputType = ""; if (selectorType == "single") { inputType = "radio"; } else if (selectorType == "multi") { inputType = "checkbox"; } var selectData = []; $('input[type="' + inputType + '"][name="' + inputType + '1"]:checked').each(function () { let key = $(this).val(); let value = $(this).attr("id"); if (typeof (key) !== "undefined" && key !== null && key !== "") { selectData.push({ key: key, value: value }); } }); return selectData; } function ExistsKey(key) { oldSelectedData.forEach((item, index) => { if (item.key == key) { return true; } }); return false; } function GetQueryString(name) { var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i"); var r = window.location.search.substr(1).match(reg); //获取url中"?"符后的字符串并正则匹配 var context = ""; if (r != null) context = decodeURIComponent(r[2]); reg = null; r = null; return context == null || context == "" || context == "undefined" ? "" : context; } function GetConfigSignature(url) { return new Promise((resolve, reject) => { // 发起 Ajax 请求 $.ajax({ url: '@Url.Action("GetConfigSignature", "ExternalOptions")', // 控制器和动作的路径 type: 'GET', data: { url: url }, dataType: 'json', success: function (res) { //console.log(res); resolve(res); }, error: function (err) { // 错误处理 //console.log(err) reject(err); } }); }); } function GetConfigAgentSignature(url) { return new Promise((resolve, reject) => { // 发起 Ajax 请求 $.ajax({ url: '@Url.Action("GetConfigAgentSignature", "ExternalOptions")', // 控制器和动作的路径 type: 'GET', data: { url: url }, dataType: 'json', success: function (res) { //console.log(res); resolve(res); }, error: function (err) { // 错误处理 //console.log(err) reject(err); } }); }); } </script> </body> </html>
审批控件中的外部选项 页面地址 :https://域名/ExternalOptions?dataType=Purchase 就可以获取采购单列表数据 https://域名/ExternalOptions?dataType=Supplier 就可以获取供应商列表数据。
以上基本可以成功。
另外发现一个问题是:PC端企业微信Windows和苹果手机端IOS,选择选项后确定按钮还是灰色;安卓手机测试可以正常。