- 小程序JS也能做图像处理 - 会员卡主题色提取的方案解析
一、前言 主题色,简而言之,是界面设计中被用作基础配色方案的主要颜色。它是在设计中明确定义的一种核心色彩,用于塑造界面的整体氛围和风格。通过恰当选择和运用主题色,可以 提升界面的可视性和易用性,从而大幅地提升用户体验。 提到图像提取主题色,⼤家可能会想到 OpenCV 这种专业的计算机视觉库,其实Javascript 这种轻量级的 脚本语⾔也是可以被⽤来实现该功能。并且相较于其他语⾔,由于其实时性和动态性以及浏览器的原生支持,JS 具有更好的动态响应能⼒以及定制化能力。 二、背景 目前微盟 CRM 会员模块主要支持两种类型的会员卡,分别是等级会员卡以及权益会员卡。商户可以根据不同的应用场景去创建不同的会员方案(成长值等级类型、消费行为等级类型、权益卡以及付费会员),以及对它们配置、管理。满足丰富的业务场景,能帮助商家针对行业对会员进行个性化运营,帮助商家给会员提供更好的服务,提升会员的黏性和忠诚度。 [图片] 并且每⼀套等级/权益卡的卡面外观,包含了勋章图案、卡面背景、自定义图片、⽂字颜色和背景配色,商户可以对其进行自定义搭配用以塑造界面的整体氛围和风格,提升整体的视觉体验。 [图片] 等级卡装修页面 [图片] 等级卡背景色设置 [图片] 权益卡设置 当商家配置好背景/卡面图片后,其需要手动配置背景/卡面主题色,可能需要进行多次尝试才能找到最合适的配色方案。这无疑增加了配置成本。 此外,可供选择的主题色为固定的几个色板,这导致在设计页面时受到一定的局限性。为了解决这一问题,我们考虑在商户配置好图片后自动生成与其图片相匹配的主题色并自动应用,从而提升整体观感。 三、需求分析 解决该问题最重要的便是获取图片的主题色啦,那么我们该如何去获取到一张图片的主题色呢?市场上常用的提取主题色的方案主要为以下几种: 1.基于平均色的方案:这种方法计算图像中所有像素的RGB值的平均值,然后将这个平均值作为主题色。这种方法简单直接,但可能无法捕捉到图像中的主要色调。 2.颜色直方图:将图像的颜色分布表示为颜色直方图,然后选择直方图中的峰值作为主题色。这种方法对于多种颜色的图像效果较好,但总体效果较差 3.Opencv: 使用 OpenCV 这个强大的计算机视觉库来加载图像,然后使用颜色空间转换和阈值处理来提取特定颜色的部分。 但其实 Javascript 也可以被用来实现该功能,并且相较于其他方案,Js 处理的好处如下: 1.客户端控制:无需后端服务器支持,不用处理图像上传/下载等逻辑。 2.响应性:在处理尺寸较小的图片(CRM会员卡背景图片限制1M以内)时,具有更好的响应性。 3.交互性:可以直接在前端图像上实现交互,例如用户点击图片上的区域来提取特定颜色,或者通过滑块来调整颜色提取的敏感度。 四、需求分析 简而言之,我们的技术方案主要为以下几个步骤: 1.加载图像数据: 使用 Canvas 绘制图像并获取像素数据。 2.数据预处理: 对像素数据进行降噪/缩放/去皮等定制化处理。 3.生成主题色:采用切割算法(中位切分/八叉树)进行颜色提取。 [图片] 接下去我们会一步一步讲解具体的实现步骤。 1、加载图像数据 [图片] 前端开发者对于 Canvas 这个元素应该是再熟悉不过了,我们通过 Canvas 的像素渲染的特性,很轻松的就能获取到图片上的像素数据。但需要注意的是需要使用 IE8及之后版本哦。 首先我们需要通过创建一个 Canvas 元素并调用 Canvas 上下文的 DrawImage 方法将图片绘制到 Canvas 上,再通过 GetImageData 方法获取到图像数据,即ImageData 对象。 并且我们使用了 Web Workers 来对 Canvas 进行离屏渲染,使其在后台线程中处理图像,防止其阻塞主线程的执行。 现在我们已经获取到所有像素数据了,接下来我们便需要对其进行预处理,为我们之后的计算打好基础。 2、数据预处理 2.1 降噪 [图片] 这里的降噪不是指声音的降噪,而是图像处理中的一个专业术语,简单来说是为了去除图像中的干扰。常见的图像降噪处理有高斯滤波、均值滤波、中值滤波等方式。 我们使用高斯滤波(Gaussian Filter)来解释其工作原理,滤波器本质是一种对图像的卷积(image convolutions)。 什么?你说你不知道啥是卷积? 那你知道图像的模糊和锐化吧?那就是一个卷积。 用过P图软件的魔术棒抠图吗?没错那也是卷积。 用手机自拍开过美颜加过滤镜吗?恭喜你!这还是卷积。 卷积无处不在,而且也很好理解,用数学上的术语来表示卷积是对两个相同维度的矩阵进行逐元素相乘并求和。 如果把图片当做一个大矩阵,那么对图片进行模糊、锐化、边缘检测等处理的功能就是一个小矩阵我们称之为核(kernel),通常来说核的大小是MxM(M为奇数)如3x3,这样可以确保其有一个核心坐标(x, y)。 接着把核心坐标放在图像的起始坐标上,将他们重叠区域的元素进行逐个相乘后求和并将计算结果输出到和当前核心坐标相同的图像坐标上,按从左往右从上往下的顺序平滑核心坐标重复此步骤。 [图片] 弄明白卷积后我们回过头来讲高斯滤波就容易多了,万变不离其宗,高斯滤波其实就是创造一个符合高斯分布的卷积核并对图像进行卷积计算(对像素的R、G、B值分别计算得出结果),详细步骤不在这里过多阐述,来看下具体的运行效果。 [图片] 经过高斯滤波处理之后,图片看起来变模糊了。这就是像素被平滑处理之后的效果,为了减少之后计算的复杂度。 2.2 去皮 [图片] 经过降噪处理后,我们还需要剔除一部分边界颜色我们称之为去皮。因为有些配图是带有纯色背景色的比如白色,大面积的白色或黑色会影响统计结果,并且造成统计性能的下降。 在实际运行过程中会发现有些看似是白色的背景实际会有一定的色差。因此我们并不是简单的对颜色值为(255, 255, 255)的像素去除,而是选取一定的范围。这个范围不能过大,且尽量贴近黑、白色的边界。 如果我们选取RGB值小于(5, 5, 5)或大于(250, 250, 250)的像素进行去除的话,代码实现如下: function convertToPixelsArray(imgData) { const data = imgData.data; const pixels = []; const [min, max] = [5, 250]; //像素点RGB值不在此范围内的进行过滤 范围可选 for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; // rgb值都在[5, 250]范围内,则不过滤 if (!(Math.min(r, g, b) >= max || Math.max(r, g, b) <= min)) { // 过滤透明度(可选) pixels.push([data[i], data[i + 1], data[i + 2]]); } } return pixels; } 3、生成主题色 在我们的项目中,我们主要采用了两种算法:中位切分法和八叉树。这些算法被用于不同的情境,以满足不同的图像处理需求。 对于那些需要快速获取主题色,并且对图像细节不太关心的场景,我们选择了中位切分法。这个简单且高效的算法能够帮助用户快速批量地获取图片的主题色。在这种情况下,我们的重点在于迅速处理大量图像,而不需要过多关注图像的微小差异。 在那些对细致和真实主题色有更高要求的场景中,八叉树无疑是更好的选择。比如: 渐变背景色:八叉树能够创造出更加复杂多样的背景效果,从而使渐变过程更加平滑自然,为视觉体验增添层次感。 细致的颜色匹配:八叉树具备实现对图像中不同区域进行精细颜色匹配的能力,从而更好地将主题色与整体设计融合。 下面就来介绍一下怎么用 JS 实现这两种算法。 4.1 中位切分法 中位切分其实非常好理解。首先需要各位读者发挥下空间想象能力,将R、G、B想象成三维空间的X、Y、Z轴,这样我们就得到了一个边长为256的立方体盒子,接着把每个像素点当作一个无体积的小球根据其坐标(r,g,b)放入盒子中。 [图片] 中位切分法的核心思想就是不断对某一根轴上的中位数进行切割,与日常生活中的折纸类似(把一张纸对折42次就可以上月球了,由此可见该算法的强大)。 不同的是,我们每次会选择像素密度最大子空间的最长边上的中位数进行切割,以确保每次切割过后的子空间中的像素数量大致相等。并在完成每次切分后使用插值排序法进行排序,从而找出密度最大子空间(或质量最大子空间)以进行下一次切割。 [图片] 某图像颜色在三维空间中的分布示意,可以看到部分区域的像素点非常密集 [图片] [图片] 沿R轴像素中位数处切割成左右2个子空间,右边空间包含的像素数量更多 当子空间边长小于设定的阀值时,我们便将其存入结果数组中。算法终止条件为切割次数或结果数组达到要求,此时密度排序靠前的几个子空间中包含的颜色即为我们所寻找的主题色。 以下是 JavaScript 的实现: function medianCut(pixels) { const colorRange = getColorRange(pixels);// 获取该颜色盒子的RGB范围 const colorBox = new ColorBox(colorRange, pixels.length, pixels); const divisions = 8; // 最大切割次数,在demo中写死 实际项目中使用者可自定义 let [box1, box2] = cutBox(colorBox); // 第一次切割 return queueCut([box1, box2], divisions); } function queueCut(queue, num) { let isSmallBox = false; while (queue.length < num && !isSmallBox) { const denselyBox = queue.shift(); const resultBox = cutBox(denselyBox); // 只返回一个盒子时,说明最长边已经小于阀值 if (resultBox.length === 1) { isSmallBox = true; queue.unshift(resultBox); return; } queue = insertValueInSortedArray(queue, resultBox); // 插值排序 } return queue; } function cutBox(colorBox) { const { colorRange, total, rgbArr } = colorBox; const cutSide = colorBox.getCutSide(colorRange); if (cutSide === -1) return colorBox; // 当切割边为-1即切割范围小于阀值时 停止切割 const colorInCutSide = rgbArr.map((item) => item[cutSide]); // 统计出各个值的数量 let medianColor = getMedianColor(colorInCutSide, total); // 此处做简易处理,实际处理中需考虑中位数值的像素数量是否过大 if (medianColor === colorRange[cutSide][0]) { medianColor++; } // 防止空切 const newRange = getCutRange(colorRange, cutSide, medianColor); // 获取切割后的两个盒子的RGB范围 const dividedPixels = dividePixel(rgbArr, cutSide, medianColor); // 分割像素到两个盒子中并返回... return [boxOne, boxTwo]; } 看到这里是否对中位切分算法有了更深的理解?在实际运用中,要注意切割次数和最小子空间的设定会影响到结果的准确性。 3.2 八叉树 我知道有些同学看到这个名字的时候已经开始头疼了。但请先别急,听我慢慢讲解 先用一句话简单概况,八叉树(Octree)是一种用于描述三维空间的树状数据结构,它是一种递归的树形数据结构,可以将三维空间划分为八个等分的小立方体(子节点),每个空间可以进一步细分为八个子空间,以此类推。 八叉树怎么和RGB颜色盒子连接起来呢? 首先,要将三维空间划分成八个等分的小立方体,我们需要沿着每条轴的中点切割,确保每个子空间的边长相等,从而确保每个立方体的体积相等。 比如将一座葫芦山(完整颜色盒子)用八叉树切分后会得到八种不同颜色的葫芦娃; [图片](没办法葫芦娃只有七个人,拿蛤蟆凑个数) 但是其颜色,比如蓝色的范围太广泛了,其分为天蓝色、宝蓝色、钢蓝色、葫蓝色、蔚蓝色、紫蓝色等等,天蓝色又分为淡蓝色、浅蓝色、鲜蓝色、翠蓝色、深蓝色等等。那么我们就需要对这些葫芦娃进行递归切割以获取这些更精确的颜色。 那么我们要切割到第几层才能获取到具体的像素点(1*1*1的颜色盒子)呢?我们可以观察到,每对颜色盒子进行一次切割,会导致子节点继承的RGB范围减半,而256=2^8,这意味着在对一个完整的颜色盒子划分到第八层时,我们就可以获得单个像素点啦。 我们知道了八叉树对于颜色盒子的划分规则后,我们该怎么使用八叉树来提取图像主题色呢? 上文中提到八叉树是按照三条轴的中点对空间进行递归切割的,也就是说在八叉树第一层结构中,各轴会被分成[0, 127], [128, 255]两个区间,颜色盒子被均分成八个小盒子。那我们该如何将这些小盒子其与八叉树中的第一层节点对应起来呢? 与二分查找法类似,我们需要将像素的RGB值分别与其轴中点进行比较并将结果用0、1来表示(0-小于中点,1-大于中点),在第一层中得到的对应关系如下图所示。 [图片] 以此类推,我们最终得到以下对应关系。 [图片] 以此类推,我们可以得到各像素点在八叉树中所挂载的节点(如下图所示)。八叉树将颜色数据按照层级关系组织与管理起来,从而实现高效的存储和查找操作。 [图片] 知道了怎么将像素数据存入八叉树结构后,我们就可以开始提取主题色啦。 首先,在八叉树最后一层结构中,每个节点表示的是一个具体的RGB值,我们称其为叶子节点。与此同时,在最后一层中每个兄弟节点的RGB差值范围为1,也就是说该父节点的所有子节点的颜色都十分类似。我们可以发现,在八叉树较深层级中,兄弟节点的RGB差值范围较小,公式为diff=2^(8-n),其中diff为RGB差值,n为层数。 那么我们可以利用这个特性来合并相似元素从而更有效地表示数据,当一个颜色盒子中存有过多小盒子(子节点)与葫芦娃(像素)的时候,丢弃其中所有小盒子,并保留所有葫芦娃至父盒子中,从而进行空间压缩,减少空间复杂度。 为什么要进行合并呢?在介绍中位切分法的篇幅中,我们提到,提取主题色其实就是找到密度最集中的几个簇,被合并的盒子的像素都为那些像素集中且密度较大的盒子。这便是我们所需找到的簇,也就是主题色。 如果将所有像素全都储存在八叉树结构后,才进行合并叶子节点的步骤的话,这无疑极大的增加了空间复杂度,所以我们设定了一个阀值N(可自定义),一般来说,N越大,得到的结果越精确,但时间复杂度与空间复杂也会同步上升。 在将像素插入八叉树结构的过程中,当叶子节点数量超过N时,我们便会将挂有最多叶子节点的父节点进行合并操作。合并后,将该父节点设为叶子节点,且之后所有在该分支上的像素均挂载至该父节点上。 当所有的像素数据遍历结束后,我们就可以得到N个叶子节点了。 我们需要根据每个叶子节点上的像素数量对其进行排序,排名前列的便是我们所需要的主题色啦。 以上介绍了中值切分法和八叉树两种算法的实现 中位切分法适用于资源有限且需要快速提取图像主题色的场景,尤其适合实时或大规模处理需求。它的简单性和计算效率使其在这些场景下成为很好的选择,尤其是当主要目标是从图像中快速捕捉主题颜色而不需要过多考虑细节和渐变色时。比如当商户只需要快速批量地获取图片的主题色,且不关心主题色的精度时 而八叉树在动态适应性和处理颜色渐变方面具有优势,适用于需要保持细节不和平滑度的图像颜色提取任务,但其算法复杂度较高,内存消耗较大。主要应用场景如下: 并且该两种算法我们都会放在 Web Worker 中处理,以提高页面性能和用户体验,同时保持主线程的渲染进程不受影响。 五、成果 以上就是这篇文章的总体内容了,通过 Canvas,可以在 Web 前端对图像进行操作和渲染。结合切割算法,可以实现类似的图像颜色提取功能。这种方法尤其适合需要在用户界面中实时显示颜色提取结果的交互式应用。并且通过对于 Web Worker的一个使用,避免了对主线程渲染的影响,从而提升了用户体验。 接下来我们看一下会员卡接入该功能之后的一个整体效果吧。 会员权益卡接入该功能后,当用户上传完卡面图片,便会自动提取其中的主题色。这样,用户可以直接选择提取出的色彩作为主题色应用,以确保卡面图片和主题色的协调性。具体效果如下所示: [图片] 权益卡效果展示 会员等级卡接入该功能后的效果图如下所示: [图片] 等级卡效果展示 我们还可以利用该工具,当加载一些比较耗时的大图片时使用提取出的主题色进行渐变填充,实现一种"模糊渐变加载"的过渡效果。这种过渡场景可以增加页面加载的视觉吸引力和平滑性。如下图所示: [图片]
02-21 - css新世界笔记
CSS新世界是知名博主张鑫旭CSS世界三部曲的最后⼀部,主要讲述CSS3及其之后很多实⽤ 的新特性,介绍了很多潜藏的特性和有⽤的细节。CSS世界介绍的是CSS2.1规范及其之前内 容,CSS选择器世界介绍的是CSS LV1-LV4的选择器知识,CSS新世界介绍的是CSS3及其之 后的知识。 CSS基础知识 CSS全局关键字属性值 inherit 继承关键字 实现属性继承,如 input { font-family:inherit;height:inherit } initial 初始关键字 适合⽤在需要重置某些CSS样式,但⼜不记得初始值的场景,如 ul { font-size: 14px } ul li:last-child { font-size: initial } unset 不固定值关键字 只有配合all属性使⽤才有意义,批量重置内置样式,如dialog { all: unset } revert 恢复关键字 让当前元素的样式还原成浏览器内置的样式,如 ol { list-style-type: repeat } @supports规则⽤来检测当前浏览器是否⽀持某个CSS新特性 <style> /* ⽀持⽹格布局 */ @supports (display:grid) { .item { background:red } } /* 不⽀持⽹格布局 */ @supports not (display:grid) { .item { background:red } } /* 同时⽀持弹性布局和⽹格布局 */ @supports (display:flex) and (display:grid) { .item { background:red } } /* ⽀持弹性布局或⽹格布局 */ @supports (display:flex) or (display:grid) { .item { background:red } } /* ⽀持弹性布局 但不⽀持⽹格布局 */ @supports (display:flex) and (not (display:grid)) { .item { background:red } } /* @supports规则的⼤括号⾥ 可以包含其他@规则*/ @supports (display:flex) { @media screen and (max-width: 750px) {} @supports (animation: none) { .box { animation: skip }} @keyframes skip {} } </style> <!-- 浏览器还提供了CSS.supports()⽅法,⽤来在javascript代码中检测 当前浏览器是否⽀持某个CSS特性--> <script> // CSS.supports(propertyName, value) if (window.CSS || CSS.supports || CSS.supports('position', 'sticky')){ document.getElementById('box').style.position = 'sticky' } </script> 增强的CSS属性 fit-content关键字 实现元素尺⼨的⾃适应<!-- 设置元素的width或height为fit-content关键字后,元素的尺⼨就是⾥⾯内容的尺 ⼨ --> <!-- 使⽤width:fit-content加margin:0 auto实现单⾏⽂字居中,多⾏⽂字居左对⻬ --> <div class="box">单⾏⽂字居中,多⾏⽂字居左对⻬</div> <style> .box { width:fit-content; margin:0 auto; } /* fit-content关键字的兼容性处理 IE浏览器、Edge79之前不⽀持 但可以⽤在移动端 */ .ex { width:-webkit-fit-content; width:-moz-fit-content;width:fit- content; } </style> position:sticky 黏性定位1.黏性定位:当元素在屏幕内时,随屏幕滚动;当元素滚出屏幕时,元素变成固定定位 2.黏性定位过去都是使⽤javascript实现,在现代浏览器可以使⽤position: sticky实现 3.在使⽤position: sticky时,务必保证黏性定位元素的祖先元素没有可滚动元素 currentColor 关键字<!-- currentColor 表示当前元素所使⽤的color属性的计算值 --> <div class="box">currentColor 使⽤当前color相同的颜⾊</div> <style> .box { color: red; border: 1px solid currentColor; /* 边框的颜⾊和⽂字 颜⾊⼀致*/ } </style> zoom 缩放 除Firefox外其他浏览器都⽀持语法 zoom: normal | reset | <number> | <percentage> zoom 和 scale 的区别 1. zoom属性缩放的中⼼坐标是元素的左上⻆,且不能修改。transform中的scale缩放默 认的中⼼坐标是元素的中⼼点 2. zoom属性缩放会改变元素占据的尺⼨空间,transform中的scale缩放不会改变元素占 据的尺⼨ 3. zoom属性不会改变元素的层级,不会影响元素的fixed定位 backface-visiblility 元素背⾯是否可⻅backface-visiblility: visible 默认值 元素背⾯是可⻅的 backface-visiblility: hidden 元素背⾯是不可⻅的 常⽤于transform变换中,是否要隐藏元素的背⾯,使变换效果更好 justify-content: space-evenly 每个flex⼦项空⽩间距相等ul{ display: flex; justify-content: space-evenly; } prefers-color-scheme ⽤来检测当前⽹⻚是否处于深⾊(⿊暗)模式的媒体属性<style> /* ⽀持三个属性: dark 系统倾向于使⽤深⾊模式 light 系统倾向于使⽤浅⾊模式 no-perference 系统未告知⽤户使⽤的颜⾊⽅案 */ /* 深⾊模式 */ @media (prefers-color-scheme: light) { body{ color:#333; background-color: #fff; } } /* 浅⾊模式 */ @media (prefers-color-scheme: dark) { body{ color:#fff; background-color: #000; } } /* 快速对现有⽹⻚进⾏深⾊模式改造的技巧 */ body { /* 使⽤filter:invert(1)滤镜对⽂字颜⾊和背景⾊等进⾏反相 */ filter: invert(1) hue-rotate(180deg); background-color: #000; } /* 对图⽚进⾏再次反相将图⽚还原成真实颜⾊ 避免应body反相后 图⽚颜⾊出现异常*/ img{ filter: invert(1) hue-rotate(180deg); } </style> <script> // 判断当前⼿机浏览器是否⽀持深⾊或浅⾊模式 // 是否⽀持 prefers-color-scheme 属性 console.log(window.matchMedia('(prefers-color-scheme)').matches) // true // 是否是浅⾊模式 prefers-color-scheme: light console.log(window.matchMedia('(prefers-color-scheme: light)').matches) // true // 是否是深⾊模式 prefers-color-scheme: dark console.log(window.matchMedia('(prefers-color-scheme: dark)').matches) // false </script> touch-action: manipulation 取消移动端点击事件300ms延迟和双击⾏为/* touch-action: manipulation 只允许浏览器进⾏滚动和持续缩放操作 */ html { touch-action: manipulation; } scroll-behavior: smooth 平滑滚动 safari不⽀持.box{ scroll-behavior: smooth; } /* 平滑滚动 */ overscroll-behavior: contain 滚动嵌套时,终⽌外层滚动 safari不⽀持/* 局部滚动的滚动条滚动到底部边缘时,再继续滚动时,外部容器不会再跟滚动 */ .box{ overscroll-behavior: contain;-ms-overscroll-behavior: contain; } pointer-events: none 元素⽆法点击caret-color: red 更改输⼊光标的颜⾊ /* 设置输⼊光标的颜⾊ 也可以⽤于设置了 contenteditable的html标签 */ input{ caret-color: red; } 简单实⽤的css函数 calc()函数的使⽤⽀持“+”、“-”、“*” 和 “/”四则运算; 可以使⽤百分⽐、px、em、rem等单位,不能使⽤当前CSS属性不⽀持的单位,可以混合使⽤ 各种单位进⾏计算; 表达式中有“+”和“-”时,其前后必须要有空格,如"widht: calc(12%+5em)"这种没有空 格的写法是错误的; 表达式中有“*”和“/”时,其前后可以没有空格,但建议留有空格。 .elm { width: calc(100% - 20px + 5px*2)) } min()函数的使⽤min(expression [, expression]) ⽀持⼀个或多个表达式,使⽤逗号分隔,将最⼩表 达式的值作为返回结果 实现⽹⻚在⼤于等于1024px的PC浏览器显示宽度为1024px,在⼩于1024px时显示宽度为 100% el { width: 1024px; max-width:100% } 使⽤min()函数 el { min(1024px, 100%) } min()⽤来限制最⼤值(即最⼤宽度值为1024px),IE和其他低版本浏览器不⽀持 max()函数的使⽤max(expression [, expression]) ⽀持⼀个或多个表达式,使⽤逗号分隔,将最⼤表 达式的值作为返回结果 实现⽹⻚在⼩于等于1024px的PC浏览器显示宽度为1024px,在⼤于1024px时显示宽度为 100% 使⽤max()函数 el { max(1024px, 100%) } max()⽤来限制最⼩值(即最⼩宽度值为1024px),IE和其他低版本浏览器不⽀持 clamp()函数的使⽤clamp()函数的作⽤是返回⼀个区间范围的值,语法:clamp(MIN, VAL, MAX),MIN表示 最⼩值,MAX表示最⼤值, VAL表示⾸选值,如果VAL在MIN~MAX范围内,则将VAL作为返回值,如果VAL⼤于MAX则将 MAX作为返回值,如果VAL ⼩于MIN,则将MIN作为返回值。clamp(MIN, VAL, MAX)等同于max(MIN, min(VAL, MAX)) IE和其他低版本浏览器不⽀持 <button>宽度⾃适应变化</button> button{ width: clamp(100px, 50%, 600px) } 不断改变浏览器视⼝的宽度,按钮宽度在100px~600px范围内变化,当屏幕宽度的⼀半⼤于 600px时,宽度为600px 当屏幕宽度的⼀半⼩于600px时,宽度在200px~600px之间,当屏幕宽度的⼀半⼩于200px 时,宽度为200px。 env() 环境变量函数环境变量env()规范的制定是源于解决‘刘海屏’和‘底部触摸⿊条’的移动设备的出现 在使⽤safe-area-inset-*等env的属性时,⼀定要设置meta信息<meta name="viewport" content="viewport-fit="cover""> 使⽤4个安全内边距值, 同时设置兜底尺⼨,⽆法使⽤safe-area-inset-*时,会使⽤兜 底的值 env(safe-area-inset-top, 20px) 距离顶部 设置顶部刘海区域安全距离 env(safe-area-inset-right, 1em) 距离右边 env(safe-area-inset-bottom, 20px) 距离底部 env(safe-area-inset-left, 20px) 距离左边 设置底部⼩⿊条安全距离 Css变量和⾃定义属性 变量名:值 声明css变量属性:var(--变量名) 使⽤css变量 <div class="box">深浅⾊模式</div> <style> :root{ --primary-color: pink; } /* 声明css变量 */ .box{ color: var(--primary-color)} /* 使⽤css变量 */ .box{ color: var(--primary-color, red)} /* 使⽤ var(css变量, 备⽤值) 当css变量⽆效时 使⽤备⽤值 */ </style> <script> // 在js中设置和获取 css⾃定义属性 // 只能借助 xx.style.setProperty 在js中设置 css变量 document.documentElement.style.setProperty('--myColor', '#0000ff') // 使⽤ getComputedStyle(document.querySelector('.box')).getPropertyValue(' --myColor') 获取css变量 console.log(getComputedStyle(document.querySelector('.box')).getPro pertyValue('--myColor')) // #0000ff </script>
2022-11-08 - 小程序获取不到用户头像和昵称返回微信用户问题解决,即小程序授权获取用户头像规则调整的最新解决方案
最近好多同学在学习石头哥小程序课程的时候,遇到了下面这样的问题,在小程序授权获取用户头像和昵称时,获取到的是下面这样的。 [图片] 到底是什么原因导致的呢,去小程序官方文档一看,又是官方改规则了。 [图片] 点进去一看,原来小程序官方,在2022年11月8日以后,又把获取用户头像的接口回收了 [图片] 再看看网友的评论,真是骂声一片啊。 [图片] 真是我的地盘我做主啊,我说怎么样就怎么样啊。有点店大欺客的嫌疑了。。。 但是呢,作为我们苦命的小程序开发者,官方虐我千百遍,我待官方如初恋。没办法啊,我们还是得用小程序不是吗。。。。 所以石头哥这里给大家提供几种解决方案。 一,临时解决方案,降低基础库 其实官方又说一句话,对于低于2.27.1版本的小程序,还是可以使用授权接口的,也就是说,只要我们的基础库低于2.27.1,就可以接着获取用户头像的。 [图片] 带着试一试的心态,石头哥就去把基础库调低为2.27.0 [图片] 调低后,再试下获取头像功能,果然还是可以获取到的。 [图片] 这里要注意一点,调低后,要记得清空下缓存 [图片] 虽然这个方法可以解决,但是只是一时的,因为小程序官方一直有bug,所以官方会一直升级基础库的,如果我们使用这个方法太久,就会导致基础库版本落后太多,这样的话,后续就会因为基础库太低,导致一些官方新功能无法使用。所以这个方案只是临时解决方案。 二,(不推荐)官方方案,头像昵称填写能力 官方为了补偿我们呢,给我们提供了一个新的方案。 [图片] 虽然这个方案可以获取到头像和昵称,但是呢。。。。。 [图片] 我们这里是可以获取到用户头像,但是官方给我们返回的这个头像是一个临时的链接。 [图片] 既然是临时链接,就意味着这个链接用不了太久就会失效了 [图片] 如果我们想用这个头像作为商品评论里的头像,那么就不能用这个临时链接了。所以官方出的这个有点鸡肋,基本上没有什么大用。 有用的也就是这个获取昵称。 [图片] 就是在填写昵称的时候,给input设置一个属性,就可以选用微信昵称或者自己输入新的昵称。 [图片] 所以如果你只需要使用用户昵称不使用头像,可以用这个方法。 三,(推荐)自己存储头像和昵称 既然官方老是变来变去,还不给我们可以长久使用的头像链接,那么我们就来自己存储用户头像,让这个头像是长久可以用的链接。所以我们这里需要自己开发后台存储头像。有以下几种方式 1,用Java或者php开发后台,存储头像和昵称 2,用云开发的云存储存储头像生成永久链接 我这里推荐大家使用云开发,因为云开发比较简单,当然大家如果会Java或者php开发,可以自己开发后台用于头像的上传和存储。 获取昵称和头像 首先看下效果图 [图片] 可以看到这里可以获取到昵称,并且可以自己自定义头像,这个自定义的头像存到云存储里,返回的是一个永久的可以使用的链接。 [图片] 这样我们后面再使用这个头像和昵称,就是永久的了,并且也不用再管小程序官方是不是又改规则了。 我把对应的代码,完整的贴出来给到大家 wxml [代码]<view class="item"> <view class="tip">编辑用户昵称</view> <input type="nickname" bindinput="getName" /> </view> <view class="line"></view> <view class="item" bindtap="chooseImage"> <view class="tip">点击修改头像</view> <image src="{{avatarUrl}}" /> </view> [代码] wxss样式 [代码].item { display: flex; align-items: center; justify-content: space-between; margin: 15rpx; border-bottom: 1rpx solid gray; padding-bottom: 20rpx; } .tip { font-size: 44rpx; margin: 20rpx; color: red; } .item image { width: 200rpx; height: 200rpx; border-radius: 10rpx; margin: 10rpx 30rpx; } .item input { flex: 1; border: 1px solid gray; border-radius: 20rpx; padding: 5rpx 15rpx; } [代码] js逻辑代码 [代码]const app = getApp() //这里要注意,初始化云开发,下面env要换成你自己的 wx.cloud.init({ env: 'cloud1-3g3xyg1a9ff9d8fe' }) const db = wx.cloud.database() Page({ getName(e) { this.setData({ name: e.detail.value }) }, //选择图片 chooseImage() { wx.chooseMedia({ count: 1, mediaType: ['image'], sizeType: ['compressed'], //可以指定是原图还是压缩图,这里用压缩 sourceType: ['album', 'camera'], //从相册选择 success: (res) => { console.log("选择图片成功", res) let avatarUrl = res.tempFiles[0].tempFilePath this.setData({ avatarUrl: avatarUrl }) wx.cloud.uploadFile({ cloudPath: new Date().getTime() + '.png', filePath: avatarUrl, // 文件路径 }).then(res => { let fileID = res.fileID console.log("上传返回的头像永久链接", fileID) }).catch(error => { console.log("上传失败", error) }) } }) }, }) [代码] 当然这里是基于云开发的,如果大家对云开发不了解,可以去看石头哥录得云开发视频:https://www.bilibili.com/video/BV1x54y1s7pk 然后关于最新版的获取头像和昵称,我也在二手商城小程序的视频里有做详细讲解:https://www.bilibili.com/video/BV1WA411M7Dp [图片] 后面会分享更多小程序相关的知识给到大家,欢迎点赞留言加关注。
2022-11-15 - 微信云开发成长笔记
33.如何批量下载云储存文件 参考地址 32.联表查询生成消费记录统计,用户数量和消费次数 联表生成的数据 [图片] 想要的数据统计(统计当天总消费次数,和当天访问总人数) [图片] [代码] const storeId = '0ab5303b62cc290d0e0799a838ca9158' const createTimeStart = '2022-06-15' const createTimeEnd = '2022-09-01' db.collection('deal').aggregate().match({ _storeId: storeId }).sort({ createTime: -1 }).lookup({ from: 'users', localField: '_userId', foreignField: '_id', as: 'userList', }) .replaceRoot({ newRoot: $.mergeObjects([ $.arrayElemAt(['$userList', 0]), { count: '$count', createTime: $.dateToString({ //将日期对象格式化为 年月日 date: $.dateFromString({ //将日期字符串转化为日期对象 dateString: '$createTime' }), format: '%Y-%m-%d' }), type: '$type', mobile: "$mobile" } ]) }) .match({ type: _.or([_.eq(2), _.eq(4)]), //筛选只要扣除的类型 createTime: _.and(_.gte(createTimeStart), _.lte(createTimeEnd)) //查询的时间区间 }) .group({ _id: '$createTime', //根据日期进行分组 consumeCount: $.sum('$count'), userCount: $.addToSet('$mobile'), //向数组中添加值,如果数组中已存在该值 }).project({ _id: 0, createTime: '$_id', consumeCount: 1, userCount: $.size('$userCount'), }) .sort({ createTime: -1 }) .end() [代码] 31.web本地上传文件 cloud.uploadFile cloud.uploadFile ,建议配合 Cloud.getTempFileURL 转换为 [代码]https://[代码]进行访问 [图片] 你可能会遇到跨域问题,则需要再腾讯云中配置本地测试的安全域名,一般默认的 [代码]localhost:8080端口[代码]时可用的,其它要自行配置 [图片] [图片] [图片] [图片] [图片] [图片] 30.查询条件根据时间区间筛选 createTimeStart <= createTime <= createTimeEnd [代码]createTimeStart 可以为字符串格式 "2022-08-17 9:50:00"[代码] [代码]createTime:_.and(_.get(createTimeStart),_.lte(createTimeEnd)) [代码] 29.静态网站访问云储存图片路径 小程序端一般上传图片后,会生成一条云路径地址 [代码]cloud://xxxxxx[代码],但事实上外部(web)访问这个路径是行不通的。 但是可以通过 Cloud.getTempFileURL 转换为 [代码]https://[代码]进行访问 28.web端如何实现连接云数据库 参考文章 27.联表查询 Aggregate().lookup() Aggregate().lookup() .project() $.arrayElemAt [代码]//联和 store_users和users,将store_users中的_userId与users中的_id匹配,把匹配符合条件的存放到userInfo数组中 db.collection('store_users').aggregate() .lookup({ from: 'users', localField: '_userId', foreignField: '_id', as: 'userInfo', //聚合匹配出的数组 }).project({ // 取匹配数组的第0个对象 userInfo:$.arrayElemAt(['$userInfo', 0]) }) .end() [代码] 26.如何实现扫码处理成功后,实时监听获取结果 .watch() Collection.watch 如果非本人操作处理扫码结果,需要对应开启表权限 [图片] 25.如何删除所有记录 [代码]_.neq(0) 不等于0的值 [代码] [图片] 24.云开发发送手机验证码 cloudbase.sendSms 微信小程序cloudbase.sendSms中template_id怎么获取? [代码]你可能会遇到发送成功但是一直收不到验证码的问题[代码] [图片] 或者在云开发控制台,查看 [代码]短信监控[代码] 日志 [图片] 23.云开发获取当前微信用户绑定的手机号 1.用户点击授权按钮获取code 2.接口通过code获取手机号 [代码]<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"></button> ... Page({ getPhoneNumber (e) { console.log(e.detail.code) } }) [代码] [代码]//云函数 config.json 中配置 { "permissions": { "openapi": [ "phonenumber.getPhoneNumber" ] } } ... //云函数调用 const result = await cloud.openapi.phonenumber.getPhoneNumber({ code: '获取的code' }); return { success: true, data: result, message: '获取成功' }; [代码] [代码]你可能会遇到 获取不到code的问题,建议更新手机微信版本或者开发工具版本。[代码] 确认当前基础库版本是否为 2.21.2 及以上。 正常情况下,基础库 2.21.2 对应的是客户端版本号为 8.0.16,如果你的版本低于 8.0.16 并且是通过开发者工具向客户端推送的 2.21.2 基础库,大几率不会返回 code 参数,请手动更新微信版本。 22.如何实现刷新当前页面 网上有很多方法,我这边采用的是 [代码]this.onLoad()[代码],不过这种方式需要你重新初始化一遍[代码]data[代码]里面的数据 简单实现一个倒计时,结束时刷新页面 [代码]let timer = null const time = 120 //有效时间120,超时自动刷新 Page({ data: { validTime: time }, onLoad: function (options) { this.validTimeInit() }, onUnload: function () { clearInterval(timer) }, validTimeInit() { let _this = this timer = setInterval(() => { let validTime = this.data.validTime validTime-- if (validTime < 0) { clearInterval(timer) _this.setData({ validTime: time }) _this.onLoad() } else { this.setData({ validTime: validTime }) } }, 1000); } }) [代码] 21._.or()查询多个符合条件的数据 云开发查询or的用法 _.eq() 查询筛选条件,表示字段等于某个值 [代码] const stores = data.map(s => { return _.eq(s._storeId) }) || [] const { data: storesInfo } = await db.collection('hyc_stores').where({_id: _.or(stores) }).get() [代码] 20…add()数据时返回值是res .add() 类似[代码].where,.update[代码]返回的都是 [代码]res.data[代码],而[代码].add[代码]返回的是[代码]res[代码],且只有添加成功的[代码]_id[代码] [图片] 19.moment 格式化时间不匹配 不清楚具体原因,但是网上说是 时区问题 加上 [代码].utcOffset(480)[代码] 就可以了 [代码] moment().utcOffset(480).format('YYYY-MM-DD HH:mm:ss') [代码] 18.onShow(options) 参数拿不到 onShow和onLoad的区别 Page页面级 [代码]onShow[代码] 没有 [代码]options[代码],全局的App里面的 [代码]onShow[代码]才有,想获取到参数尽量用 [代码]onLoad[代码] https://developers.weixin.qq.com/community/develop/doc/0008005f68c300c912070098b56800 17.常用云函数指令 [代码]const _ = db.command[代码] _.or :满足其中一个条件即可 跨字段的或操作; 查询更新数组/对象 [代码]//数组 arr 第 index 下标对象的 id [`arr.${index}.id`]: id [代码] 联表查询 16.父子组件通信 使用微信小程序全局变量 小程序的全局变量存储在文件 app.js 的globalData对象中 [代码]// app.js 中定义 App({ globalData: { hasLogin: false, openid: null }, }) // 其他文件中读取使用 const app = getApp(); console.log(app.globalData.hasLogin) [代码] 使用微信小程序的 数据缓存 [代码] wx.setStorageSync('userInfo', data) wx.getStorageSync('userInfo') [代码] 使用父子组件间的属性 父组件传值给子组件,子组件可以在 properties 中接收并使用 子组件传值给父组件,可以通过方法触发 triggerEvent [代码]//子组件 this.triggerEvent('myEvent',传参) //父组件 <子组件 bind:myEvent="传递方法" /> [代码] 15.input修改数组对象的某一个值 解决方案 14.微信map地图组件闪退问题 安卓手机在切换tab的时候,频繁切换时,小程序会闪退 解决方案 13.日期格式化: [代码]<wxs src="../../utils/filter.wxs" module="filter"></wxs> <text class="time"> {{filter.format(newsMsg.createAt,'YYYY-MM-DD')}}</text> [代码] 利用[代码].wxs[代码]方法,在页面上引入 utils/filter.wxs [代码]function format(date, fmt) { let ret; const opt = { "Y+": date.getFullYear().toString(), // 年 "m+": (date.getMonth() + 1).toString(), // 月 "d+": date.getDate().toString(), // 日 "H+": date.getHours().toString(), // 时 "M+": date.getMinutes().toString(), // 分 "S+": date.getSeconds().toString() // 秒 // 有其他格式化字符需求可以继续添加,必须转化成字符串 }; for (let k in opt) { ret = new RegExp("(" + k + ")").exec(fmt); if (ret) { fmt = fmt.replace(ret[1], (ret[1].length == 1) ? (opt[k]) : (opt[k].padStart(ret[1].length, "0"))) }; }; return fmt; } function formatNumber(n) { n = n.toString() return n[1] ? n : '0' + n } function getWeek(n) { switch (n) { case 1: return '星期一' case 2: return '星期二' case 3: return '星期三' case 4: return '星期四' case 5: return '星期五' case 6: return '星期六' case 7: return '星期日' } } module.exports.format = format [代码] 12.下拉加载更多: .json [代码]"enablePullDownRefresh":true, [代码] .wxml [代码] /** * 页面相关事件处理函数--监听用户下拉动作 */ onPullDownRefresh: function () { this.setData({ pages: 0, list: [], 'queryModel.current': 1, 'queryModel.size': 10, 'queryModel.totalCount': 0 }) // 接口加载数据 this.getNewsPage() // 停止加载,下拉返回 wx.stopPullDownRefresh(); }, [代码] 11.空格,’ ’问题 👉 复 制 吧 👈 微信小程序中的多个空格怎么打? | 微信开放社区 10.text文本属性 如果属于纯文本,切属于 <textarea>输出的内容,尽量用 <text>组件标签 [代码]<text>{{details.intro}}</text> [代码] 9.rich-text 富文本图片和换行问题 rich-text [代码] <rich-text nodes="{{details.content}}"></rich-text> [代码] 图片百分比 [代码]//rich-text富文本图片自适应处理 // content 为富文本内容 content.replace(/\<img/gi, '<img style="max-width:100%;height:auto" ') [代码] 不换行问题/app.wxss [代码]/* 富文本样式,解决富文本不换行问题 */ rich-text { word-break: break-all; white-space: pre-wrap; } [代码] 8.对象深拷贝 [代码]//对象深拷贝 const deepClone = function (obj, newObj) { var newObj = newObj || {}; for (let key in obj) { if (typeof obj[key] == 'object') { let isArray = Array.isArray(obj[key]); //判断是否数组 newObj[key] = (isArray == true ? [] : {}) deepClone(obj[key], newObj[key]); } else { newObj[key] = obj[key] } } return newObj; } module.exports = { deepClone } [代码] 7.request请求封装 utils/request.js [代码]const baseURL = '服务器地址'; function request(method, url, data) { return new Promise(function (resolve, reject) { let header = { 'content-type': 'application/json', }; wx.request({ url: baseURL + url, method: method, data: method === 'POST' ? JSON.stringify(data) : data, header: header, success(res) { //请求成功 //判断状态码---errCode状态根据后端定义来判断 if (res.data.code == 200) { resolve(res.data); } else { //其他异常 reject('运行时错误,请稍后再试'); } }, fail(err) { //请求失败 reject(err) } }) }) } export default request [代码] api/index.js使用 [代码]// 小程序接口 import request from '../utils/request' const API = { getFlightPage: (data) => request('POST', '/flight/page', data), // 查询各模块的在线生活服务 lifeServeList: (moduleId) => request('GET', `/module/life/service/list?module=${moduleId}`), } module.exports = { API: API } [代码] app.js入口引入 [代码]//引入api接口 const $api = require('./api/index').API ... App({ $api, ... }) [代码] .wxml [代码]const app = getApp() ... //接口调用 app.$api.newsDetails().then(res => {}) [代码] 6.修改页面背景色 .wxss [代码]page{ background-color:'每个页面都可以自定义背景色' } [代码] 5.<image>mode属性 <image>组件 4.IOS<video>黑屏问题 [代码]custom-cache="{{false}}"[代码] [代码]一般缓存为一级缓存、二级缓存、和自定义缓存,换而言之custom-cache="{{false}}"就是不使用自定义缓存的意思。为什么用了视频组件会卡loading加载不出来呢,可能是因为微信设置的自定义缓存的位置有容量限制,因此将它关闭了以后会自动使用系统缓存,可能就没有这个问题了。[代码] [代码]<video autoplay="true" src="{{url}}" controls custom-cache="{{false}}"></video> [代码] 3.微信小程序暗黑模式 暗黑模式详解文章 2.微信小程序escape转码 例如你要传递一个URL链接参数到下一个页面,你可能发现微信会自动给你截取一部分参数,因此可以通过此种方式避免。 [代码]//转码 escape() //解码 unescape() [代码] 1.微信小程序生成二维码 weapp-qrcode.js [代码]//引入 const QRCode = require('../../utils/weapp-qrcode.js') [代码] [代码]//使用 new QRCode('myQrcode', { text: `xxx链接`, width: 200, height: 200, padding: 12, // 生成二维码四周自动留边宽度,不传入默认为0 correctLevel: QRCode.CorrectLevel.L, // 二维码可辨识度 callback: (res) => { console.log(res.path) // 接下来就可以直接调用微信小程序的api } }) [代码] [代码]<canvas class="canvas-code"canvas-id="myQrcode" style="background:#fff;width: 200px;height: 200px;"/> [代码] [代码]//调用扫一扫获取结果 // 只允许从相机扫码 wx.scanCode({ onlyFromCamera: true, success(res) { wx.navigateTo({ url: res.result //xxx链接 }) } }) [代码]
2022-09-21 - “视频号+”的机遇之社群篇(三):如何借助视频号激活微信社群
“视频号+”的机遇之社群篇(三)微信视频号的出现,彻底地将微信生态的商业系统打通了,进一步提升了微信社群的商业价值,视频号成为社群主打造IP、社群用户互动的新型可视化社交载体,没有视频号,社群用户的信任力打造、内容运营、用户互动等效率会大大降低;没有社群,视频号的涨粉、评论、点赞、直播带货也会变得艰难,可以说视频号和社群是微信社群商业的新机遇,视频号轻创业适合每一个平凡的个体和企业。 如何借助视频号激活微信社群 相信很多社群新手在刚刚建立社群的时候,都会遇到这样的问题:不知道社群顶层设计怎么做,群建好了不知道怎么和用户沟通、投放什么内容,害怕透支人脉等。或者是经营了很久的群普遍半死不活,又不想放弃,想通过一些运营方法激活它。你是否遇到过这些情况呢?有没有一些标准化的方法激活我们的社群呢?答案是标准的模式有,但每个群的运营以及活跃程度完全取决于运营者的性格和用心,同一个方法在不同人的手上会呈现出不同的效果。我在这里提供一些运营经验供大家参考。 1、激活社群的五种玩法在视频号还没有出来之前,我们社群的运营压力其实很大,毕竟用户的注意力都被抖音、电商、实体店、生活琐事、工作等分散得很厉害,要想让用户把注意力放在你的社群里,就得使用一些方法了,比如持续输出价值、有重磅大咖驻场、付费入群、群内有相互认识的好友等都是决定社群后续能否活跃的关键。过去我们经常会用一些方法来带动社群的活跃度,以下五种玩法总有一种适合你: 方法一:轮值当群主 社群的线上运营,要保证活跃度,有三个关键: 一是有持续输出价值的内容;二是有信任你的用户;三是社群的人群经过了精准筛选。 这三个基本条件符合后,我们再来根据自己的情况,看看使用哪一种运营策略才能带动更多人参与。很显然轮值当群主是一个不错的选择,那些没有持续内容输出能力的群主,做好一件事就可以了,那就是用户关系的连接。把用户当作社群里的IP来打造,群主以及团队在幕后服务他,这样既带动了群友的积极性,也增加了大量的UGC(用户生成内容),还能帮助轮值的群主对接资源,一举多得。 但这种玩法也有弊端,比如不是每个群友都具备内容输出的能力,大部分的人只会讲产品,很容易变成一次广告宣传,这对于群的运营极其不利,作为群主必须防范这类事件的发生。当值群主输出内容前,我们需要提前3天和他沟通,并做好内容的审核、话术框架的标准化。这样就确保了那些有内容的人能讲出特别打动人心的故事,也增加了社群的温度感,不至于因为不同轮值群主的输出,导致社群变成了广告群。另外一点要特别注意的是,尽管这个方法适合做社群促活,但我们必须把好关以及提前沟通,通过“接龙+一对一私信沟通”的方式分批确定参与轮值群主的人选,把优质的群友筛选出来,而没有输出能力的人,尽量不要给予其做轮值群主的机会。 方法二:签到打卡 一些消费类、教育类的企业社群,通常会用到这种玩法。直接面对消费者的社群,基本上是以成交为目的的,但是我们又不能拉群直接做转化,需要有一个培养用户习惯的过程。这个时候签到打卡就成了消费类社群必然的选择。具体的玩法是这样的:用户进群后,群助手会引导用户签到打卡,并告知签到得到多少积分可以兑换什么产品福利;驱动用户邀约3个新人,邀约后获得第一个礼包;如果想加入进阶群,那就需要连续打卡7天积累一定的积分之后,才给予更高的福利和加入进阶群的机会。 这种玩法纯微信群是玩不转的,一般会结合第三方打卡工具,如微打卡、小打卡等小程序来协助群主,通过技术手段引导用户打卡、分享海报等。这么做的好处是:能坚持7天的用户,证明他对于我们设置的奖励或进阶群是非常感兴趣的,也逐步培养了用户的习惯,为后续产品变现建立了用户信任关系。 方法三:群接龙+红包 微信群中有一个“群接龙”的功能,我相信很多人都看到过。但是如何使用“群接龙”增加社群的活跃度呢?这里就要设计使用场景,第一种是销售场景,比如我们常见的社区团购、课程拼团、产品发售等都会用到接龙功能;第二种是学习场景,尤其是结合训练营的学习场景,我们会在交作业环节使用接龙刺激那些懒惰的学习者积极提交作业,在答疑环节引导大家接龙提出问题和导师互动,这样也增加了社群的互动性和价值感。 群接龙虽然能带动群的活跃度,但也容易让部分用户养成惰性,习惯性潜水,为了判断有多少人在关注这个群,我们有时候也会用发红包的方式来测试,用抢红包的速度、人数来判断这个群的关注度,发红包是社群运营一个非常重要的检测手段。 方法四:晨读打卡 近几年,学习型的社群是最火的,尤其是各种读书会、早读营等。晨读不在于读了什么,而在于养成一种早起的习惯,和一群优秀的人在一起,勉励自己不能偷懒,刻意练习让自己变得更优秀。 了解了“晨读打卡”的核心使命之后,你就应该知道这种玩法对于社群主的核心价值了。最重要的两个价值就是筛选优质的用户以及寻找潜在消费者。为了让晨读打卡做得更有仪式感,一般都会分组进行,每个组都会安排一个组长带头在群里领读,其他人可以跟随学习和参与互动,这样就把整个社群的氛围带起来了。 方法五:训练营 训练营是一种促活手段,也是社群常见的一种变现模式,比如你一定参与或听说过类似“7天英语学习营”“14天社群实战营”等。训练营更像是一个学习场景,比如一起减肥、一起实操社群裂变、一起学英语等,它会在短时间内形成一个聚合的场景,能激发用户的积极性。通常这类训练营都采取付费入群的方式,所以不用担心人群不精准的问题,关键是要做好训练营的流程设计、积分体系、奖励机制、小组分工、仪式感的营造,等等。 上述五种方法是比较常见的,这些方法在未来的社群运营中依然会发挥很大的价值。而视频号的出现,能更加真实地代入场景感,更加深度地连接真实用户,更有利于社群用户信任力的打造。 2、“视频号+社群运营”的五种玩法在视频号出现的这段时间里,我们已经尝试开启了“12天视频号社群裂变营”“视频号就是冲锋号”等社群,在实践过程中也有一些心得,在这里一并分享出来。 方法一:视频号+早读+训练营 微信社群的封闭性,导致我们无法做到让运维、引流和转化三者形成合力,这也是为什么我们很多群主在使用“早读训练营”模式的时候通过海报的方式引导用户转发,目的就是想借助用户的朋友圈做社交引流,而早读本身也是一件封闭的事情,很多用户不太喜欢被要求分享到朋友圈,只愿意在群里打卡。 视频号的出现解决了上述几个问题,我们不需要刻意让用户去转发海报了,直接把早读分享的方式切换成拍视频,这样做有什么好处呢?用户拍完视频号短视频后,按照打卡要求使用“#话题”、点赞、收藏、评论等视频号功能,就已经实现了传播的效果。用户拍完视频后再转发到群里,群里的用户就成了他的潜在用户,同时也帮助群主做了传播,这是多赢的局面。这个方法为社群引流增长、社群促活提供了新的思路。 方法二:视频号+资源推荐 用户进入一个社群,他的需求可能比较多,主要有学习、交友、获取资源、卖货等,而获取资源是很多社群新人最想要的。以前我们只能在几个环节嵌入资源对接,比如新人入群会安排做自我介绍,提供一个标准化的介绍模板,方便其他人快速了解他并产生链接;另一种情况是,我们会安排新人在固定时间来分享自己的故事,让其他群友能更加具体地了解分享者。但这些信息如果不做二次内容加工,很有可能被后续信息流淹没。这些内容其实价值巨大,属于这个社群曾经发生过的故事,必须通过二次加工将内容整理好发布到公众号或者其他内容平台,甚至有的社群做得更细致,会将这些内容整理成一个“群友资源录”,更加真实地让新人感受到社群的价值。 现在视频号如何解决资源对接的问题? 我们自己在“12天视频号社群裂变营”就使用过视频号社群推荐的方法,让参与者拍一段视频为社群代言,并带上同一个话题“#社群行业内容联盟”,这样做既能建立社群的长尾流量,同时也为社群记录下精彩的瞬间。我们也看到一些视频号博主做视频号大咖推荐、视频号直播推荐等都是结合社群来做的。这些方式都可以增加用户参与度和提升社群主的IP影响力。 方法三:视频号+微课分享 你现在在微信群里做分享,是否还在用“图文+语音”,或者用小鹅通、千聊?如果你现在还在用这些方式,那就要抓紧时间改变一下了,之前的那些方式不是不能再用了,是信息的触达率非常低了。视频号出来以后,我们在做社群运营的时候,已经逐渐摆脱了“微信群+私聊”的单一方式。我们在做“12天视频号社群裂变营”的时候,视频号成了很好的内容输出阵地,我们会提前录制好一分钟的短视频讲述要分享的内容大纲,让学员提前知道能学到什么,然后使用视频号直播的方式和学员分享,这个流程走过几遍以后,发现体验非常好。视频号直播可以最小化,学员可以一边在群里互动一边听直播,整体的互动性比过去好很多倍。再加上视频号连麦功能,用户互动会更有效率。即使是不同行业的用户,我们也可以把公开课形式的内容输出用视频号结合微信群的方式来做。 方法四:视频号+话题接龙 社群的运营一定要围绕高效率的信息触达来做,我们觉得一个信息发出去用户就会看到,可实际情况是用户可能延后两三天才知道,有很多用户不会看群里的信息,信息就永远错过了。为了提高用户的活跃度,我们会采用群接龙的方式来造势,让那些潜水围观的用户也主动站出来,这有利于社群的氛围营造。视频号怎么接龙?就是群主发起一个话题,引发群友围绕这个话题讨论,然后接龙,比如针对“#视频号就是冲锋号”这个话题,大家可以用视频号作品来发表自己的看法,并在视频号作品的“描述”里添加这个话题。很多人一起使用“同一个话题”输出内容,在视频号的生态里声浪就会比较大,传播就能做得更好。类似的玩法还有“视频号+荐书”“视频号+隔空对话”“视频号+在线答疑”等,都是新的社群促活手段,我们不妨多去实践,总有一种可以帮助你做好社群促活。视频号给社群的内容运营、关系运营、线上活动运营提供了无限可能,能够深帮助我们连接一群人一起来做一件事,逐步形成聚变效应,让更多认同群主的人融入我们的社群里。 方法五:视频号直播+私域社群+红包抽奖 视频号直播最大的好处是用户可以看到群主真人,真人出镜更有利于产生信任。通过视频号直播,还能获得“关注”流量、“朋友在看”流量、“附近”流量等公域流量,通过直播间直播的方式+评论区+投放二维码等形式,可以引导新用户添加个人微信号和加入群,形成一个流量互通的商业闭环。 在社群端,为了进一步扩大流量半径和促活,我们会邀请多个赞助商作为社群联办方,同时设计一个引流策略,引导用户全员转发直播间到他的社群或朋友圈。在群里还可以做红包抽奖,凡是“关注+回复666”,并且在群里抢红包手气最佳的,就能获得我们的产品福利,这样一来又可以获得新一轮的社交流量,同时也让社群互动变得非常活跃。视频号直播的重点是和用户多互动,内容大于互动,除了这些运营方法,还可以在群里收集问题,然后群主在直播间专门解答这些话题,进一步增加社群的活跃度。 下期分享内容:“视频号+”的机遇之社群篇(四):如何借助视频号倍增社群势能
2022-08-10 - 新版交易组件接入的指引与Q&A(本文不在更新,看文章内新地址)
本文不在更新,请看新版自定义交易组件接入指引 看帖不点赞,bug千千万 需要先申请开通“交易组件场景专用商户号”才可以完成新版交易组件场景接入(申请场景经营商户号这是必要条件),进行接入时一定要按照文档流程顺序进行接入,不要新旧接口混合调用,否则无法正常跑通完整流程,切记!切记!切记! 先配个图证明新版接入已完成 [图片] 有新问题可以留言,有准确答案(方案)后补充更新 一、升级版自定义交易组件接入说明 1、组件介绍 若商家此前已经完成视频号接入小程序,在小程序中调用升级版自定义交易组件组件后,可在保留原有的界面、功能及交易链路的情况下接入微信视频号场景。通过调用商品上传、订单生成、状态同步等接口,实现在视频号场景中交易资金流、售后、交易纠纷、客服等能力的标准化。 2、功能特点 可在视频号场景实现商品展示和带货等功能 未来可支持更多直播营销玩法(券、 秒杀、预售等) 支持小程序客服组件,商家能更方便收到用户的客服咨询 订单中心显示更完善的订单信息,用户可自行查看订单状态 支持用户在视频号订单中心继续付款、发起售后 3、上线案例 升级版自定义交易组件为商户提供保障用户体验的直播电商全链路能力: 可以使用微信支付商户号,资金结算更规范。 小程序和视频号的订单进行了双向打通,用户可以任选在小程序或视频号订单中心处理订单,例如重新发起支付、确认收货等,大大提升用户体验。 通过打通小程序客服组件,增强了商家处理商品咨询的能力。 [图片][图片][图片] 4、接入流程及官方文档 注意:整个接入流程需要15-30个工作日不等,建议提前准备商品的品牌、资质、类目信息,与开发调试并行,避免延误直播带货计划。 详情见:接入视频号指引 5、关键流程逻辑 注意“橙色”为新加入部分: [图片] [图片] 二、接入过程中常见问题 有新问题可以留言,有准确答案(方案)后补充更新 Q1:新版交易组件需要重新申请商户号吗?是否可以使用原有商户号? A1:不可以,新版交易组件必须要申请开通场景专用商户号 Q2:新版场景专用商户号费率是多少,是否有优惠,结算周期是多久? A2:商户号费率为0.6%,无费率优惠,结算周期为7+7日,即用户收货后7天后结算。 Q3:申请新商户号时,最后一步签约遇到“微信实名信息与管理员信息不一致”是什么原因? A3:申请新的场景专用商户号时,“超级管理员”这一项不支持修改,默认为小程序“超级管理员”实名信息,如需修改,需要为该用户前往成员管理为小程序绑定超级管理员。 Q4:申请新的商户号时,为什么不能修改主体信息? A4:“当前主体”这一项不支持修改,因为商户号主体必须和该小程序注册主体保持一致。 Q5:通过新版自定义交易组件申请的场景专用商户号是否对跨境类小程序(自助报关)有影响? A5:会,二级商户当前暂不支持自助清关接口调用,留意后续更新通知 Q6:自定义交易组件“升级版”跟升级前的自定义交易组件有什么区别,哪些接口需要升级? A6: 新支付接口,必须走新商户号。 取消订单, 小程序(小程序内以及发现-小程序我的订单)和视频号双向可取消,之前只可以在小程序上取消,然后同步给视频号状态。 申请退款,小程序和视频号双向可申请退款。 申请退货退款,小程序和视频号双向可申请退货退款,之前只有小程序上操作。 未付款订单,小程序和视频号 可在各自订单中心重新支付,同步状态。 确认收货,小程序和视频号双向可确认收货。 同步发货状态接口更新。 Q7:自定义交易组件验收流程走完后, 在MP后台点击完成依旧提示"检测到你未完成此项步骤, 请确认后重试"是什么原因? A7:需要通过调用新接口进行验收才可以通过。 Q8:调用自定义交易组件创建售后接口ecaftersale/add时报47001错误{“errcode”:47001,“errmsg”:"data format error "} A8:请检查“product_info”字段,注意对应类型为“object”。 Q9:调用自定义交易组件创建售后接口ecaftersale/add时报错2747002,参数错误{“errcode”:2747002,“errmsg”:"参数错误 "} A9:1.请检查“orderamt”参数,传参金额应不含邮费。 2.新旧接口不可混合调用,新接口不支持对旧接口生成的订单创建售后。 3.一个商品仅可以有一笔在流程的售后单,已创建或售后完结也会报次错误。 Q10:调用自定义交易组件“同意退货”接口ecaftersale/acceptreturn时报错“同意退货失败没有默认退货地址,需要在接口中传入” {“errcode”:9700210,“errmsg”:“errmsg” =>”同意退货失败没有默认退货地址,需要在接口中传入"} A10:需要调用“更新商家信息”接口,补充默认退货地址 Q11:调用自定义交易组件“添加商品”接口shop/spu/add时报错“该账号客服方式必须包含微信客服/小程序客服” {“errcode”:1040042,“errmsg”:"该账号客服方式必须包含微信客服/小程序客服”} A11:需要在MP后台配置微信客服/小程序客服后,然后通过“更新商家信息”接口更新商家信息[图片] 调用“获取商家信息”接口应返回一下内容才为成功,“service_agent_type”字段需要同时包含0,1,2三个值 [图片] Q12:调用自定义交易组件“创建订单”接口shop/order/add时报错“不支持的发货方式” {“errcode”:1010036,“errmsg”:"不支持的发货方式“} A12:视频号场景当前只支持“正常快递”方式,其他请留意后续更新。 Q13:自定义交易组件“创建售后单”接口中“refund_reason_type”字段 定义见枚举值定义 “emAfterSalesReason ”,“emAfterSalesReason”对应枚举值是什么? A13:INCORRECT_SELECTION = 1; // 拍错/多拍 NO_LONGER_WANT = 2; // 不想要了 NO_EXPRESS_INFO = 3; // 无快递信息 EMPTY_PACKAGE = 4; // 包裹为空 REJECT_RECEIVE_PACKAGE = 5; // 已拒签包裹 NOT_DELIVERED_TOO_LONG = 6; // 快递长时间未送达 NOT_MATCH_PRODUCT_DESC = 7; // 与商品描述不符 QUALITY_ISSUE = 8; // 质量问题 SEND_WRONG_GOODS = 9; // 卖家发错货 THREE_NO_PRODUCT = 10; // 三无产品 FAKE_PRODUCT = 11; // 假冒产品 OTHERS = 12; // 其它 Q14:自定义交易组件“获取售后单详情”接口中“status”字段 定义见枚举值定义 “AfterSalesState ”,“AfterSalesState”对应枚举值是什么? A14:AFTERSALESTATUS_INVALID = 0; USER_CANCELD = 1; // 用户取消申请 MERCHANT_PROCESSING = 2; // 商家受理中 MERCHANT_REJECT_REFUND = 4; // 商家拒绝退款 MERCHANT_REJECT_RETURN = 5; // 商家拒绝退货退款 USER_WAIT_RETURN = 6; // 待买家退货 RETURN_CLOSED = 7; // 退货退款关闭 MERCHANT_WAIT_RECEIPT = 8; // 待商家收货 MERCHANT_OVERDUE_REFUND = 12; // 商家逾期未退款 MERCHANT_REFUND_SUCCESS = 13; // 退款完成 MERCHANT_RETURN_SUCCESS = 14; // 退货退款完成 PLATFORM_REFUNDING = 15; // 平台退款中 PLATFORM_REFUND_FAIL = 16; // 平台退款失败 USER_WAIT_CONFIRM = 17; // 待用户确认 MERCHANT_REFUND_RETRY_FAIL = 18; // 商家打款失败,客服关闭售后 MERCHANT_FAIL = 19; // 售后关闭 Q15:自定义交易组件申请视频号专用商户号后,唤起支付报错: “商户号该产品权限未开通” A15:需要先调用“生成订单”接口,然后调用“生成支付参数”接口获取调取支付所需参数,不要调用微信支付统一下单接口获取调用支付参数 Q16:调用自定义交易组件“同意退款”接口shop/ecaftersale/acceptrefund时报错“同意退款失败” {“errcode”:9700209,“errmsg”:"同意退款失败 退款失败“} A:该问题是订单流转状态不对导致,请严格按照文档流程进行操作调用;新旧接口混合调用也会报此错误。 Q17:二级商户号订单支付流程与原有订单支付流程有什么区别? A17:主要区别是:二级商户号订单调起支付所需参数是通过“生成支付参数”获取,无需同步支付结果;原流程调起支付是需要通过微信支付统一下单获取,需要同步支付结果。 Q18:调用自定义交易组件售后相关接口:“创建售后单”、“用户取消售后单”、“用户上传物流信息”、“获取售后单列表”、“获取售后单详情”、“同意退款“、”同意退货“、“拒绝售后”、“上传退款凭证”、“更新售后单”等接口时报错{“errcode”: 48001,“errmsg”: “api unauthorized”} A18:未开通视频号场景经营商户号,需要先开通场景经营商户号才可以调用。 Q19:自定义交易组件二级商户单调起支付时报错“JSAPI缺少参数total_fee” A19:生成支付参数失败,没返回正确的预支付 ID,重新调用生成支付参数接口获取新的支付参数即可 Q20:调用自定义交易组件接口报错{“errcode”:61007,“errmsg”:“api is unauthorized to component”} A20:没有完成服务商授权。 Q21:已经开通了自定义交易组件,调用接口还是报错48001 A21:接口鉴权有本地缓存,一般最多10分钟,请稍后再试。 Q22:调用自定义组件接口报错“json异常” A22:结构体比较复杂,请检查字段层级。划重点: json不支持注释!!!json不支持注释!!!json不支持注释!!! Q23:调用自定义组件接口报错{“errcode”:1000000,“errmsg”:“订单状态流转异常”} A23: 订单严格按照:创建、支付、发货、收货的事件流转,如果已经取消,则不能继续流转。 Q24:调用自定义组件上传图片接口报错{“errcode”:1070008,“errmsg”:"获取图片失败,请使用流式上传 "} A24:一般是图片url在微信侧获取不刀,可能为图片cdn设置了白名单或者cdn服务商把微信出口ip 给“ban”了 Q25:调用自定义组件上传图片接口报错{“errcode”:1070001,“errmsg”:"文件/图片为空 "} A25:检查请求报文协议,需[代码]Content-Type: multipart/form-data[代码] Q26:调用自定义组件上传图片接口报错{“errcode”:1000035,“errmsg”:"无效链接 "} A26:请检查图片链接是否为有效链接 Q27:自定义交易组件接入后没有收到事件回调消息 A27:使用公众平台调试工具确保回调链路正常。事件消息如下 [图片] Q28:视频号橱窗管理获取不到对应小程序 A28:1、检查是否开通视频号场景;2、检查是否绑定了推广员(非小程序超管需要绑定推广员) 持续更新中~~~
2022-04-14 - 如何使用微信小程序·云开发的Node.js云函数生成Word文档(2021-10-15更新)
编者按 近期一个云开发项目有生成Word文档的需求,经过搜索,发现并没有小程序·云开发有关生成word文档的案例,因为本人还是本科生且非科班出身,一路摸着石头过河,遇到了不少困难,期间还试图向社区的大佬们求助;花了两天时间才搞定这一百行代码,现在分享给大家。 代码有些糙,希望大佬们不要嫌弃。 一、安装云函数依赖officegen、fs 工欲善其事必先利其器,我们知道云函数代码运行在云端Node.js环境中,因此,理论上来说,Node.js能做的事情,小程序·云开发的云函数基本上也能做到。officegen是Github上一款生成微软Office文档的工具,包括.docx、.xlsx、.pptx三种文件,由于我只用了.docx,本文将以Word文件为例。 https://github.com/Ziv-Barber/officegen [图片] 1. 首先我们在微信开发者工具中 新建一个云函数 => 右键云函数名 => 在终端中打开 [图片] 2. npm安装依赖officegen和fs,为了方便本地调试云函数,我们这里也安装wx-server-sdk。 [图片] 代码如下,请逐个安装,如果安装有问题,可以自行搜索“npm”或“npm taobao 镜像” ;这里不再赘述。 npm i officegen npm i fs npm i wx-server-sdk 3. 在云函数index.js开头写下以下代码,引用我们刚刚安装的包。 const cloud = require('wx-server-sdk') const officegen = require('officegen'); const fs = require('fs'); const docx = officegen('docx'); 二、创建Word文档的内容 文档地址: https://github.com/Ziv-Barber/officegen/blob/master/manual/docx/README.md 1. 首先我们根据文档定义(Ctrl CV)两个函数 //文档生成完成后调用,后来其实发现没啥用 // Officegen calling this function after finishing to generate the docx document: docx.on('finalize', async function (written) { console.log('Finish to create a Microsoft Word document.') }) //生成文档出现问题时调用 // Officegen calling this function to report errors: docx.on('error', function (err) { console.log(err) }) 2. 创建段落API: docx.createP(options) //声明一个创建段落的变量p0bj let pObj = docx.createP(options) //创建一个段落并插入文本 pObj = docx.createP({ align: 'center' //文字对齐方式,center、justify、right;默认为left indentLeft = 1440; // 段落缩进 Indent left 1 inch indentFirstLine = 440; // 首行缩进 }) pObj.addText('你要插入的文字,这里可以时变量', { bold: true, //是否加粗,默认false font_face: 'KaiTi', //字体,这里以“楷体为例”,如果填写了打开文档的电脑没有安装的字体名称,将使用默认字体。能不能用中文,我没试过。 font_size: 19, //字号 color: '595959' //文字颜色 }); 上述例子外,还可以添加下划线、设置斜体、超链接、分页等;还可以编辑页眉和页脚、插入图片等。详见后续代码示例或officegen文档。 3. 插入图片 这里以插入小程序码为例,直接上代码。 要注意的是officegen似乎不支持以buffer形式插入图片,因此要先将图片保存。 //首先定义一个用于保存小程序码图片的函数 //save QR saveFile = function (filePath, fileData) { return new Promise((resolve, reject) => { const wstream = fs.createWriteStream(filePath); wstream.on('open', () => { const blockSize = 128; const nbBlocks = Math.ceil(fileData.length / (blockSize)); for (let i = 0; i < nbBlocks; i += 1) { const currentBlock = fileData.slice( blockSize * i, Math.min(blockSize * (i + 1), fileData.length), ); wstream.write(currentBlock); } wstream.end(); }); wstream.on('error', (err) => { reject(err); }); wstream.on('finish', () => { resolve(true); }); }); } //要获取小程序码,首先要修改云函数config.json文件中的云调用权限 { "permissions": { "openapi": [ "wxacode.getUnlimited" ] } } //在云函数main中获取小程序码 //https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/qr-code/wxacode.get.html const result = await cloud.openapi.wxacode.getUnlimited({ page: 'pages/check/check', //小程序页面地址,必须是线上版本中存在的页面的完整地址 scene: '', //小程序码参数 width: 240, //小程序码的宽度(是个正方形) }) const QRcode = result.buffer await saveFile('/tmp/qr.jpg', QRcode); // 这里的fileData是Buffer类型,关于路径会在第三部分生成Word文件中解释。 //将图片插入到文档中 pObj = docx.createP() //创建段落 pObj.options.indentFirstLine = 440; //首行缩进 pObj.addImage('/tmp/qr.jpg', { //图片文件路径 cx: 140, //长度 cy: 140 //宽度 }); 三、生成Word文件 文档内容完成后,就可以生成文档了。officegen似乎只能生成文件,没有文件buffer的接口,而要上传到小程序·云开发的云存储中,只能使用Buffer或fs.ReadStream,怎么办呢?先把文件保存下来再读取呗。 首先提一下云函数运行环境 https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/functions/mechanism.html 云函数运行在云端 Linux 环境中,一个云函数在处理并发请求的时候会创建多个云函数实例,每个云函数实例之间相互隔离,没有公用的内存或硬盘空间。云函数实例的创建、管理、销毁等操作由平台自动完成。每个云函数实例都在 [代码]/tmp[代码] 目录下提供了一块 [代码]512MB[代码] 的临时磁盘空间用于处理单次云函数执行过程中的临时文件读写需求,需特别注意的是,这块临时磁盘空间在函数执行完毕后可能被销毁,不应依赖和假设在磁盘空间存储的临时文件会一直存在。如果需要持久化的存储,请使用云存储功能。因此,我们将文件保存在/tmp路径下,文件名随便起,这里我取为exampl.docx。生成文档的代码如下: // Let's generate the Word document into a file: let out = fs.createWriteStream('/tmp/example.docx') // Async call to generate the output file: docx.generate(out) 理论上来说,我们文档生成完毕后,通过fs.ReadFileStream读取文件调用cloud.uploadFile()即可上传到云存储 const fileStream = fs.createReadStream('/tmp/example.docx') return await cloud.uploadFile({ cloudPath: '/tmp/example.docx', fileContent: fileStream, }) 而在测试过程中我发现,云端测试时,云函数调用超时。而后使用本地调试查看问题出在何处。 云函数本地调试的方法不再赘述,看这里即可。https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/functions/local-debug.html 通过本地调试,发现cloud.uplodaFile()的网络请求始终时挂起(pending)状态,没有传输数据。 [图片] 经过一天的调试,通过监听文件,发现officegen生成文件完成,执行了我们开头复制粘贴的生成文档后执行的docx.on("finalize",)函数,打印文档生成成功的日志后,仍有文件变动,也就是说,文件并没有生成完毕。这就导致了后续步骤的失败。 当时调试的界面我没有保存,就贴一下fs监听文件的代码吧。 let watcherObj = '/tmp/example.docx' //eventType 可以是 'rename' 或 'change'; 当改名或出现或消失的时候触发rename; recursive:是否监听到内层子目录,默认false; try { let myWatcher = fs.watch(watcherObj,{encoding:'utf8',recursive:true},(event,filename) => { if(event == 'change'){ console.log("触发change事件") } console.log(event) //encoding:文件名编码格式,buffer、默认:utf8等;filename有可能为空 if(filename){ console.log('filename: ' + filename) } }) //change 事件会触发多次 myWatcher.on('change',function(err,filename){ console.log(filename + '发生变化'); }); //50秒后 关闭监视 setTimeout(function(){ myWatcher.close() },5000); } catch (error) { console.log('文件不存在!!') } 为解决这一问题,我最先想到了await,结果发现await对officegen生成文档的接口并不起作用;最终我用了最原始的笨办法:用setTimeout等一会儿再读取文件,大佬们有更好的解决方案还请赐教。 return new Promise((resolve, reject) => { setTimeout(async function () { let data = fs.readFileSync('/tmp/example.docx'); let bufferData = new Buffer.from(data, 'base64'); console.log(bufferData); setTimeout(async function () { resolve(await cloud.uploadFile({ cloudPath: varpath, fileContent: bufferData, })); }, 1000); //等文件再读1秒 }, 6300); //等文件再写一会儿。根据自己的需求调试后确定等待时长,要预留出一定时间确保文档完全生成完毕。 }) //最终返回内容为文件云存储中的CloudID。 四、完整核心代码 const cloud = require('wx-server-sdk') const officegen = require('officegen'); const fs = require('fs'); const docx = officegen('docx'); cloud.init({ env: '这里填入你的云环境' }) // Officegen calling this function after finishing to generate the docx document: docx.on('finalize', async function (written) { console.log('Finish to create a Microsoft Word document.') }) // Officegen calling this function to report errors: docx.on('error', function (err) { console.log(err) }) //save QR saveFile = function (filePath, fileData) { return new Promise((resolve, reject) => { const wstream = fs.createWriteStream(filePath); wstream.on('open', () => { const blockSize = 128; const nbBlocks = Math.ceil(fileData.length / (blockSize)); for (let i = 0; i < nbBlocks; i += 1) { const currentBlock = fileData.slice( blockSize * i, Math.min(blockSize * (i + 1), fileData.length), ); wstream.write(currentBlock); } wstream.end(); }); wstream.on('error', (err) => { reject(err); }); wstream.on('finish', () => { resolve(true); }); }); } // 云函数入口函数 exports.main = async (event, context) => { var time = new Date() var filePath = 'exportVoluntaryData' var fileName = "zyzm" + Date.parse(new Date()) + '.docx' var varpath = filePath + '/' + fileName //get QRcode const result = await cloud.openapi.wxacode.getUnlimited({ page: 'pages/check/check', scene: item._id, width: 240, }) const QRcode = result.buffer await saveFile('/tmp/qr.jpg', QRcode); // Add a Footer: var footer = docx.getFooter().createP(); footer.addText('XXXX证明_' + item._id, { font_size: 10 }); footer = docx.getFooter().createP(); footer.addText(time.toString(), { font_size: 10 }); //下方开始文档每一页的循环 for (var i in item.volunteerInfo) { //标题 let pObj = docx.createP({ align: 'center' }) pObj.addText('XXX证明', { bold: true,XXX font_face: 'KaiTi', font_size: 19, color: '595959' }); //此处省略了一些正文内容 pObj = docx.createP() pObj.options.indentFirstLine = 440; pObj.addText('微信扫描下方小程序码,可核验此证明。', { font_face: 'FangSong', font_size: 12, color: '595959', italic: true, }); pObj = docx.createP() pObj.options.indentFirstLine = 440; pObj.addImage('/tmp/qr.jpg', { cx: 140, cy: 140 }); pObj = docx.createP() pObj = docx.createP({ align: 'right' }) pObj.addText('落款', { font_face: 'FangSong', font_size: 15, color: '595959' }); if (i != ((item.volunteerInfo).length - 1)){ docx.putPageBreak() //分页 } } // Let's generate the Word document into a file: let out = fs.createWriteStream('/tmp/example.docx') // Async call to generate the output file: docx.generate(out) return new Promise((resolve, reject) => { setTimeout(async function () { let data = fs.readFileSync('/tmp/example.docx'); let bufferData = new Buffer.from(data, 'base64'); console.log(bufferData); setTimeout(async function () { resolve(await cloud.uploadFile({ cloudPath: varpath, fileContent: bufferData, })); }, 1000); }, 6300); }) } 本人非计算机相关专业本科生,且本文大部分内容为手打,难免会有差错和疏漏,还请各位指教。 希望本文对你有所帮助。 Soochow University. HaoChen. 2020年2月 ======= 2021-10-15更新 ======= 经过一段时间的使用,上述内容主要存在两点问题:(1)难以判断文件何时生成完毕;(2)连续调用生成文档时,若上一个云函数实例未被销毁,会出现文件内容重复和错乱的问题。 前一段时间进行了更新,因为工作学习忙碌,此次暂不做详解,代码如下。 入口文件index.js// 云函数入口文件 delete require.cache[require.resolve('officegen')]; const cloud = require('wx-server-sdk') var office = require('office.js'); //https://github.com/Ziv-Barber/officegen/blob/master/manual/docx/README.md cloud.init({ env: 'sudaxmt1900' }) const db = cloud.database() const _ = db.command // 云函数入口函数 exports.main = async (event, context) => { return await office.genWord(event); } office.jsconst cloud = require('wx-server-sdk') const fs = require('fs'); function delDir(path) { console.log("delete Dir") let files = []; if (fs.existsSync(path)) { files = fs.readdirSync(path); files.forEach((file, index) => { let curPath = path + "/" + file; if (fs.statSync(curPath).isDirectory()) { delDir(curPath); //递归删除文件夹 } else { fs.unlinkSync(curPath); //删除文件 } }); // fs.rmdirSync(path); // 删除文件夹自身 } } readDocx_fs = function (path) { return new Promise((resolve, reject) => { fs.readFile(path,(err,data)=>{ resolve(data); reject(err); }) }) } //save QR saveFile = function (filePath, fileData) { return new Promise((resolve, reject) => { const wstream = fs.createWriteStream(filePath); wstream.on('open', () => { const blockSize = 128; const nbBlocks = Math.ceil(fileData.length / (blockSize)); for (let i = 0; i < nbBlocks; i += 1) { const currentBlock = fileData.slice( blockSize * i, Math.min(blockSize * (i + 1), fileData.length), ); wstream.write(currentBlock); } wstream.end(); }); wstream.on('error', (err) => { reject(err); }); wstream.on('finish', () => { resolve(true); }); }); } exports.genWord = async (event) => { let officegen = require('officegen'); let fs = require('fs'); let docx = officegen('docx'); //ini delDir('/tmp') var item = event.item var filePath = 'exportVoluntaryData' var fileName = "21zyzm" + Date.parse(new Date()) + '.docx' var varpath = filePath + '/' + fileName //=========以下建构文档内容========== //get QRcode const result = await cloud.openapi.wxacode.getUnlimited({ page: 'pages/check/check', scene: item.id, width: 140, }) const QRcode = result.buffer await saveFile('/tmp/qr.jpg', QRcode); // 这里的fileData是Buffer类型 timeBottom = time.getFullYear() + '年' + (time.getMonth() + 1) + '月' + time.getDate() + '日' for (var i in item.volunteerInfo) { let pObj = docx.createP({ align: 'center' }) pObj = docx.createP({ align: 'center' }) pObj.addText('志愿服务时间证明', { bold: true, font_face: 'KaiTi', font_size: 19, color: '595959' }); pObj = docx.createP() pObj = docx.createP({ align: 'justify' }) pObj.options.indentFirstLine = 440; if (item.volunteerInfo[i].academy && item.volunteerInfo[i].major && item.volunteerInfo[i].grade) { var txt = item.volunteerInfo[i].academy + ' ' + item.volunteerInfo[i].major + '专业 ' + item.volunteerInfo[i].grade + ' ' + item.volunteerInfo[i].name + ' 同学(学号 ' + item.volunteerInfo[i].idnum + '),于 ' + date + '参加 ' + item.title + ' 工作,志愿服务时间达到 ' + item.hours + ' 小时。' } else { var txt = item.volunteerInfo[i].name + ' 同学(学号 ' + item.volunteerInfo[i].idnum + '),于 ' + date + '参加 ' + item.title + ' 工作,志愿服务时间达到 ' + item.hours + ' 小时。' } pObj.addText(txt, { font_face: 'FangSong', font_size: 15, color: '595959' }); pObj = docx.createP() pObj.options.indentFirstLine = 440; pObj.addText('特此证明。', { font_face: 'FangSong', font_size: 15, color: '595959' }); pObj = docx.createP() pObj.options.indentFirstLine = 440; pObj.addText('证明人:' + event.tea_info.name + ' ' + event.tea_info.phone, { font_face: 'FangSong', font_size: 15, color: '595959' }); pObj = docx.createP() pObj = docx.createP() pObj.options.indentFirstLine = 440; pObj.addText('微信扫描下方小程序码,可核验此证明。核验信息与此证明一致时,此证明不加盖公章仍然有效;若不一致,则以加盖公章的证明为准。', { font_face: 'FangSong', font_size: 12, color: '595959', italic: true, }); pObj = docx.createP() pObj.options.indentFirstLine = 440; pObj.addImage('/tmp/qr.jpg', { cx: 140, cy: 140 }); pObj = docx.createP() pObj = docx.createP() pObj = docx.createP({ align: 'right' }) pObj.addText('XXXXX', { font_face: 'FangSong', font_size: 15, color: '595959' }); pObj = docx.createP({ align: 'right' }) pObj.addText(timeBottom, { font_face: 'FangSong', font_size: 15, color: '595959' }); // Add a Footer: pObj = docx.createP() pObj = docx.createP() pObj = docx.createP() pObj.addText('XXXXX证明_' + item._id, { font_face: 'FangSong', font_size: 10, color: '808080' }); pObj = docx.createP() pObj.addText(time.toString(), { font_face: 'FangSong', font_size: 10, color: '808080' }); if (i != ((item.volunteerInfo).length - 1)) { docx.putPageBreak() } } //=======================建构文档内容结束========================= // Let's generate the Word document into a file: let out = fs.createWriteStream('/tmp/' + fileName) return new Promise((resolve, reject) => { docx.generate(out); out.on('close', async function(){ console.log("文件已被关闭,总共写入字节", out.bytesWritten) // console.log('写入的文件路径是'+ out.path); var fileBuf = await readDocx_fs(out.path); var upd = await cloud.uploadFile({ cloudPath: varpath, fileContent: fileBuf, }); console.log(docx) resolve({ event, upd, size: Math.floor(100*out.bytesWritten/1024)/100 + "KB" }) }); out.on('error', (err) => { console.error(err); reject({ errMsg: err }) }); }) }
2021-10-15 - 常用正则表达式
一、校验数字的表达式数字:^[0-9]*$n位的数字:^\d{n}$至少n位的数字:^\d{n,}$m-n位的数字:^\d{m,n}$零和非零开头的数字:^(0|[1-9][0-9]*)$非零开头的最多带两位小数的数字:^([1-9][0-9]*)+(\.[0-9]{1,2})?$带1-2位小数的正数或负数:^(\-)?\d+(\.\d{1,2})$正数、负数、和小数:^(\-|\+)?\d+(\.\d+)?$有两位小数的正实数:^[0-9]+(\.[0-9]{2})?$有1~3位小数的正实数:^[0-9]+(\.[0-9]{1,3})?$非零的正整数:^[1-9]\d*$ 或 ^([1-9][0-9]*){1,3}$ 或 ^\+?[1-9][0-9]*$非零的负整数:^\-[1-9][]0-9"*$ 或 ^-[1-9]\d*$非负整数:^\d+$ 或 ^[1-9]\d*|0$非正整数:^-[1-9]\d*|0$ 或 ^((-\d+)|(0+))$非负浮点数:^\d+(\.\d+)?$ 或 ^[1-9]\d*\.\d*|0\.\d*[1-9]\d*|0?\.0+|0$非正浮点数:^((-\d+(\.\d+)?)|(0+(\.0+)?))$ 或 ^(-([1-9]\d*\.\d*|0\.\d*[1-9]\d*))|0?\.0+|0$正浮点数:^[1-9]\d*\.\d*|0\.\d*[1-9]\d*$ 或 ^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$负浮点数:^-([1-9]\d*\.\d*|0\.\d*[1-9]\d*)$ 或 ^(-(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*)))$浮点数:^(-?\d+)(\.\d+)?$ 或 ^-?([1-9]\d*\.\d*|0\.\d*[1-9]\d*|0?\.0+|0)$二、校验字符的表达式汉字:^[\u4e00-\u9fa5]{0,}$英文和数字:^[A-Za-z0-9]+$ 或 ^[A-Za-z0-9]{4,40}$长度为3-20的所有字符:^.{3,20}$由26个英文字母组成的字符串:^[A-Za-z]+$由26个大写英文字母组成的字符串:^[A-Z]+$由26个小写英文字母组成的字符串:^[a-z]+$由数字和26个英文字母组成的字符串:^[A-Za-z0-9]+$由数字、26个英文字母或者下划线组成的字符串:^\w+$ 或 ^\w{3,20}$中文、英文、数字包括下划线:^[\u4E00-\u9FA5A-Za-z0-9_]+$中文、英文、数字但不包括下划线等符号:^[\u4E00-\u9FA5A-Za-z0-9]+$ 或 ^[\u4E00-\u9FA5A-Za-z0-9]{2,20}$可以输入含有^%&',;=?$\"等字符:[^%&',;=?$\x22]+禁止输入含有~的字符:[^~\x22]+三、特殊需求表达式Email地址:^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$域名:[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(/.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+/.?InternetURL:[a-zA-z]+://[^\s]* 或 ^http://([\w-]+\.)+[\w-]+(/[\w-./?%&=]*)?$手机号码:^(13[0-9]|14[5|7]|15[0|1|2|3|5|6|7|8|9]|18[0|1|2|3|5|6|7|8|9])\d{8}$电话号码("XXX-XXXXXXX"、"XXXX-XXXXXXXX"、"XXX-XXXXXXX"、"XXX-XXXXXXXX"、"XXXXXXX"和"XXXXXXXX):^(\(\d{3,4}-)|\d{3.4}-)?\d{7,8}$国内电话号码(0511-4405222、021-87888822):\d{3}-\d{8}|\d{4}-\d{7}电话号码正则表达式(支持手机号码,3-4位区号,7-8位直播号码,1-4位分机号): ((\d{11})|^((\d{7,8})|(\d{4}|\d{3})-(\d{7,8})|(\d{4}|\d{3})-(\d{7,8})-(\d{4}|\d{3}|\d{2}|\d{1})|(\d{7,8})-(\d{4}|\d{3}|\d{2}|\d{1}))$)身份证号(15位、18位数字),最后一位是校验位,可能为数字或字符X:(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)帐号是否合法(字母开头,允许5-16字节,允许字母数字下划线):^[a-zA-Z][a-zA-Z0-9_]{4,15}$密码(以字母开头,长度在6~18之间,只能包含字母、数字和下划线):^[a-zA-Z]\w{5,17}$强密码(必须包含大小写字母和数字的组合,不能使用特殊字符,长度在8-10之间):^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,10}$日期格式:^\d{4}-\d{1,2}-\d{1,2}一年的12个月(01~09和1~12):^(0?[1-9]|1[0-2])$一个月的31天(01~09和1~31):^((0?[1-9])|((1|2)[0-9])|30|31)$钱的输入格式:有四种钱的表示形式我们可以接受:"10000.00" 和 "10,000.00", 和没有 "分" 的 "10000" 和 "10,000":^[1-9][0-9]*$这表示任意一个不以0开头的数字,但是,这也意味着一个字符"0"不通过,所以我们采用下面的形式:^(0|[1-9][0-9]*)$一个0或者一个不以0开头的数字.我们还可以允许开头有一个负号:^(0|-?[1-9][0-9]*)$这表示一个0或者一个可能为负的开头不为0的数字.让用户以0开头好了.把负号的也去掉,因为钱总不能是负的吧。下面我们要加的是说明可能的小数部分:^[0-9]+(.[0-9]+)?$必须说明的是,小数点后面至少应该有1位数,所以"10."是不通过的,但是 "10" 和 "10.2" 是通过的:^[0-9]+(.[0-9]{2})?$这样我们规定小数点后面必须有两位,如果你认为太苛刻了,可以这样:^[0-9]+(.[0-9]{1,2})?$这样就允许用户只写一位小数.下面我们该考虑数字中的逗号了,我们可以这样:^[0-9]{1,3}(,[0-9]{3})*(.[0-9]{1,2})?$1到3个数字,后面跟着任意个 逗号+3个数字,逗号成为可选,而不是必须:^([0-9]+|[0-9]{1,3}(,[0-9]{3})*)(.[0-9]{1,2})?$备注:这就是最终结果了,别忘了"+"可以用"*"替代如果你觉得空字符串也可以接受的话(奇怪,为什么?)最后,别忘了在用函数时去掉去掉那个反斜杠,一般的错误都在这里xml文件:^([a-zA-Z]+-?)+[a-zA-Z0-9]+\\.[x|X][m|M][l|L]$中文字符的正则表达式:[\u4e00-\u9fa5]双字节字符:[^\x00-\xff] (包括汉字在内,可以用来计算字符串的长度(一个双字节字符长度计2,ASCII字符计1))空白行的正则表达式:\n\s*\r (可以用来删除空白行)HTML标记的正则表达式:<(\S*?)[^>]*>.*? |<.*? /> ( 首尾空白字符的正则表达式:^\s*|\s*$或(^\s*)|(\s*$) (可以用来删除行首行尾的空白字符(包括空格、制表符、换页符等等),非常有用的表达式)腾讯QQ号:[1-9][0-9]{4,} (腾讯QQ号从10000开始)中国邮政编码:[1-9]\d{5}(?!\d) (中国邮政编码为6位数字)IP地址:((?:(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d))原文地址:https://www.qjson.cn/Tools/js_expression/
2022-04-06 - 小程序读取excel表格数据,并存储到云数据库
最近一直比较忙,答应大家的小程序解析excel一直没有写出来,今天终于忙里偷闲,有机会把这篇文章写出来给大家了。 老规矩先看效果图 [图片] 效果其实很简单,就是把excel里的数据解析出来,然后存到云数据库里。说起来很简单。但是真的做起来的时候,发现其中要用到的东西还是很多的。不信。。。。 那来看下流程图 流程图 [图片] 通过流程图,我看看到我们这里使用了云函数,云存储,云数据库。 流程图主要实现下面几个步骤 1,使用wx.chooseMessageFile选择要解析的excel表格 2,通过wx.cloud.uploadFile上传excel文件到云存储 3,云存储返回一个fileid 给我们 4,定义一个excel云函数 5,把第3步返回的fileid传递给excel云函数 6,在excel云函数里解析excel,并把数据添加到云数据库。 可以看到最神秘,最重要的就是我们的excel云函数。 所以我们先把前5步实现了,后面重点讲解下我们的excel云函数。 一,选择并上传excel表格文件到云存储 这里我们使用到了云开发,使用云开发必须要先注册一个小程序,并给自己的小程序开通云开发功能。这个知识点我讲过很多遍了,还不知道怎么开通并使用云开发的同学,去翻下我前面的文章,或者看下我录的讲解视频《5小时入门小程序云开发》 1,先定义我们的页面 页面很简单,就是一个按钮如下图,点击按钮时调用chooseExcel方法,选择excel [图片] 对应的wxml代码如下 [图片] 2,编写文件选择和文件上传方法 [图片] 上图的chooseExcel就是我们的excel文件选择方法。 uploadExcel就是我们的文件上传方法,上传成功以后会返回一个fildID。我们把fildID传递给我们的jiexi方法,jiexi方法如下 3 把fildID传递给云函数 [图片] 二,解下来就是定义我们的云函数了。 1,首先我们要新建云函数 [图片] 如果你还不知道如何新建云函数,可以翻看下我之前写的文章,也可以看我录的视频《5小时入门小程序云开发》 如下图所示的excel就是我们创建的云函数 [图片] 2,安装node-xlsx依赖库 [图片] 如上图所示,右键excel,然后点击在终端中打开。 打开终端后, 输入 npm install node-xlsx 安装依赖。可以看到下图安装中的进度条 [图片] 这一步需要你电脑上安装过node.js并配置npm命令。 3,安装node-xlsx依赖库完成 [图片] 三,编写云函数 我把完整的代码贴出来给大家 [代码]const cloud = require('wx-server-sdk') cloud.init() var xlsx = require('node-xlsx'); const db = cloud.database() exports.main = async(event, context) => { let { fileID } = event //1,通过fileID下载云存储里的excel文件 const res = await cloud.downloadFile({ fileID: fileID, }) const buffer = res.fileContent const tasks = [] //用来存储所有的添加数据操作 //2,解析excel文件里的数据 var sheets = xlsx.parse(buffer); //获取到所有sheets sheets.forEach(function(sheet) { console.log(sheet['name']); for (var rowId in sheet['data']) { console.log(rowId); var row = sheet['data'][rowId]; //第几行数据 if (rowId > 0 && row) { //第一行是表格标题,所有我们要从第2行开始读 //3,把解析到的数据存到excelList数据表里 const promise = db.collection('users') .add({ data: { name: row[0], //姓名 age: row[1], //年龄 address: row[2], //地址 wechat: row[3] //wechat } }) tasks.push(promise) } } }); // 等待所有数据添加完成 let result = await Promise.all(tasks).then(res => { return res }).catch(function(err) { return err }) return result } [代码] 上面代码里注释的很清楚了,我这里就不在啰嗦了。 有几点注意的给大家说下 1,要先创建数据表 [图片] 2,有时候如果老是解析失败,可能是有的电脑需要在云函数里也要初始化云开发环境 [图片] 四,解析并上传成功 如我的表格里有下面三条数据 [图片] 点击上传按钮,并选择我们的表格文件 [图片] 上传成功的返回如下,可以看出我们添加了3条数据到数据库 [图片] 添加成功效果图如下 [图片] 到这里我们就完整的实现了小程序上传excel数据到数据库的功能了。 再来带大家看下流程图 [图片] 如果你有遇到问题,可以在底部留言,我看到后会及时解答。后面我会写更多小程序云开发实战的文章出来。也会录制本节的视频出来,敬请关注。
2019-11-12 - JavaScript对象、数组、日期操作方法封装
1.获取对象属性个数 objLength(obj) { var count = 0; for(var i in obj) { if(obj.hasOwnProperty(i)) { count++; } } return count; }, 2.数组排序 sortArr(arr,s){ // s:true 升序 false 降序 var s = '' || s; arr.sort(function (a, b) { if (s) {//从小到大排序 return a - b; }else{//从大到小排序 return b - a; } }); return arr;//返回已经排序的数组 }, 3.根据数组对象中的某个属性值进行排序的方法 sortBy(arr,attr,rev){ /*** arr 需要排序的数组 * attr 排序的属性 如number属性 * rev true表示升序排列,false降序排序 * */ //第二个参数没有传递 默认升序排列 if(rev == undefined){ rev = 1; }else{ rev = (rev) ? 1 : -1; } return arr.sort(function(a,b){ a = a[attr]; b = b[attr]; if(a < b){ return rev * -1; } if(a > b){ return rev * 1; } return 0; }) }, 4.数组去重 unique(arr) { return Array.from(new Set(arr)) }, 5.根据数组对象的某个属性进行去重 uniqueObj(arr1,from) { // arr1:要去重的数组 from:属性 const res = new Map(); return arr1.filter((a) => !res.has(a[from]) && res.set(a[from], 1)) }, 6.对象数组去重并且保留最后的对象 arrayUnique2(arr, name) { var hash = {}; return arr.reduce(function (acc, cru,index) { if (!hash[cru[name]]) { hash[cru[name]] = {index:index} acc.push(cru) }else{ acc.splice(hash[cru[name]]['index'],1,cru) } return acc; }, []); }, 7.删除数组中小于某个值的元素 handleArr(arr,smail){ // arr:数组 small:需要比这个值小 var newArr = arr.filter(item => item > smail); return newArr; }, 8.将一个数组等分成若干个数组,每个数组里有n条数据 bisectionArr(arr,n){ /**arr:数据 n每个数组里保留几条数据 * 用法 * app.bisectionArr(this.data.Arr,5); **/ var n=Number(n); var newarr = []; var len = arr.length / n; len = Math.ceil(len); for (var j = 1; j <= len; j++) { newarr[j - 1] = []; for (var i = (n * j - n); i < n * j; i++) { if (arr[i] != undefined) { newarr[j - 1].push(arr[i]); } } } return newarr; }, 9.根据身份证号获取出生年月日 getBirthdayFromIdCard(idCard) { var birthday = ""; if (idCard != null && idCard != "" && checkIdcard(idCard)) { if (idCard.length == 15) { birthday = "19" + idCard.substr(6, 6); } else if (idCard.length == 18) { birthday = idCard.substr(6, 8); } birthday = birthday.replace(/(.{4})(.{2})/, "$1-$2-"); } return birthday; }, 10.只能输入金额 onlyMoney(val){ var regStrs = [ ['^0(\\d+)$', '$1'], //禁止录入整数部分两位以上,但首位为0 ['[^\\d\\.]+$', ''], //禁止录入任何非数字和点 ['\\.(\\d?)\\.+', '.$1'], //禁止录入两个以上的点 ['^(\\d+\\.\\d{2}).+', '$1'] //禁止录入小数点后两位以上 ]; for(var i=0; i<regStrs.length; i++){ var reg = new RegExp(regStrs[i][0]); val = val.replace(reg, regStrs[i][1]); } return val; }, 11.根据身份证号返回所在省 getProvinceFromIdCard(idCard) { var aCity={11:"北京",12:"天津",13:"河北",14:"山西",15:"内蒙古",21:"辽宁",22:"吉林",23:"黑龙江 ",31:"上海",32:"江苏",33:"浙江",34:"安徽",35:"福建",36:"江西",37:"山东",41:"河南",42:"湖北 ",43:"湖南",44:"广东",45:"广西",46:"海南",50:"重庆",51:"四川",52:"贵州",53:"云南",54:"西藏 ",61:"陕西",62:"甘肃",63:"青海",64:"宁夏",65:"新疆",71:"台湾",81:"香港",82:"澳门",91:"国外"}; return aCity[idCard.substr(0,2)]; }, 12.获取当前日期 getNowDate(){ var date=new Date(); var year=date.getFullYear(); var month=date.getMonth()+1; var day=date.getDate(); var hh=date.getHours(); var mm=date.getMinutes(); var ss=date.getSeconds(); var arr=['日','一','二','三','四','五','六']; var week=arr[date.getDay()]; function add0(val){ return (val<10? '0'+val : val); } month=add0(month);day=add0(day);hh=add0(hh);mm=add0(mm);ss=add0(ss); return { data:year+'-'+month+'-'+day+' '+hh+':'+mm+':'+ss, year:year, month:month, day:day, hh:hh, mm:mm, ss:ss, week:week, }; }, 13.日期转时间戳 dateToChuo(riqi){ //riqi格式 2020-01-01 00:00:00 riqi=riqi.replace(/-/g,"/"); var date = new Date(riqi);//兼容ios var time = parseInt(date.getTime()/1000);//除以1000为10位时间戳 不除为13位 return time; }, 14.时间戳转日期 chuoToDate(timestamp){ if(!timestamp){ return false; } timestamp=timestamp.toString().length<13? (timestamp * 1000):timestamp; var date = new Date(timestamp);//时间戳为10位需*1000,时间戳为13位的话不需乘1000 var Y = date.getFullYear(); var M = (date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1); var D = (date.getDate()<10? '0'+date.getDate() : date.getDate()); var h = (date.getHours()<10? '0'+date.getHours() : date.getHours()); var m = (date.getMinutes()<10? '0'+date.getMinutes() : date.getMinutes()); var s = (date.getSeconds()<10? '0'+date.getSeconds() : date.getSeconds()); // + h + m + s return { data:Y+'-'+M+'-'+D+' '+h+':'+m+':'+s, year:Y, month:M, day:D, hh:h, mm:m, ss:s } }, 15.获取当前时间的后20分钟、后一小时(add是正数)、前一天(add是负数)等等相对时间 setDefaultTime(add,danwei){ // 调用方法:app.setDefaultTime(20,'天');20天以后的时间 var danwei=danwei || '秒';//默认单位是秒 if(danwei=='秒'){ add=add*1000;// add 单位是秒 }else if(danwei=='分'){ add=add*1000*60;// add 单位是分 }else if(danwei=='时'){ add=add*1000*60*60;// add 单位是时 }else if(danwei=='天'){ add=add*1000*60*60*24;// add 单位是天 } let t = new Date().getTime() + add; let d = new Date(t); let theMonth = d.getMonth() + 1; let theDate = d.getDate(); let theHours = d.getHours(); let theMinutes = d.getMinutes(); let getSeconds=d.getSeconds(); function add0(val){ return (val<10? '0'+val : val); } theMonth=add0(theMonth); theDate=add0(theDate); theHours=add0(theHours); theMinutes=add0(theMinutes); getSeconds=add0(getSeconds); let date = d.getFullYear() + '-' + theMonth + '-' + theDate; let time = theHours + ':' + theMinutes + ':' + getSeconds; let Spare = date + ' ' + time; return { datas:d.getFullYear()+'-'+theMonth+'-'+theDate, data:Spare, year:d.getFullYear(), month:theMonth, day:theDate, hh:theHours, mm:theMinutes, ss:getSeconds } }, 16.已知开始日期和结束日期 计算出中间的所有日期 getAllDate(start, end){ // start:2020-07-14 end:2020-07-20 let dateArr = [] let startArr = start.split('-') let endArr = end.split('-') let db = new Date() db.setUTCFullYear(startArr[0], startArr[1] - 1, startArr[2]) let de = new Date() de.setUTCFullYear(endArr[0], endArr[1] - 1, endArr[2]) let unixDb = db.getTime() let unixDe = de.getTime() let stamp const oneDay = 24 * 60 * 60 * 1000; for (stamp = unixDb; stamp <= unixDe;) { // parseInt(stamp) 13位的时间戳 dateArr.push(this.format(new Date(parseInt(stamp)))) stamp = stamp + oneDay } return dateArr }, format(time){ // time=new Date(13位的时间戳) let ymd = '' let mouth = (time.getMonth() + 1) >= 10 ? (time.getMonth() + 1) : ('0' + (time.getMonth() + 1)) let day = time.getDate() >= 10 ? time.getDate() : ('0' + time.getDate()) ymd += time.getFullYear() + '-' // 获取年份。 ymd += mouth + '-' // 获取月份。 ymd += day // 获取日。 return ymd // 返回日期。2020-07-14 }, 17.获取上个月的年月 getLastMonth(riqi){ riqi=riqi.replace(/-/g,"/"); var date = new Date(riqi); var year = date.getFullYear(); var month = date.getMonth(); if(month == 0){ year = year -1; month = 12; } return { year:year, month:month<10? '0'+month : month }; }, 18.获取指定日期的星期 getWeek(riqi){ riqi=riqi.replace(/-/g,"/"); var date = new Date(riqi);//兼容ios var arr=['日','一','二','三','四','五','六']; var week=arr[date.getDay()]; return week; }, 19.当前日期是今年的第几周 getYearWeek(year,month,date){ /* app.getYearWeek(2019,4,19) dateNow是当前日期 dateFirst是当年第一天 dataNumber是当前日期是今年第多少天 用dataNumber + 当前年的第一天的周差距的和在除以7就是本年第几周 */ let dateNow = new Date(year, parseInt(month) - 1, date); let dateFirst = new Date(year, 0, 1); let dataNumber = Math.round((dateNow.valueOf() - dateFirst.valueOf()) / 86400000); return Math.ceil((dataNumber + ((dateFirst.getDay() + 1) - 1)) / 7); }, 20.当前日期是当月的第几周 getMonthWeek(year,month,date){ /* app.getMonthWeek(2019,4,19) month = 6 - w = 当前周的还有几天过完(不算今天) year + month 的和在除以7 就是当天是当前月份的第几周 */ let dateNow = new Date(year, parseInt(month) - 1, date); let w = dateNow.getDay();//星期数 let d = dateNow.getDate(); return Math.ceil((d + 6 - w) / 7); }, 21.判断某年某月有多少天 getCountDays(ym) { var curDate = new Date(ym.replace(/-/g,"/")); /* 获取当前月份 */ var curMonth = curDate.getMonth(); /* 生成实际的月份: 由于curMonth会比实际月份小1, 故需加1 */ curDate.setMonth(curMonth + 1); /* 将日期设置为0 */ curDate.setDate(0); /* 返回当月的天数 */ return curDate.getDate(); }, 22.获取指定日期的第几个月 getHowMonth(date,num) { // date 格式为yyyy-mm-dd的日期,如:2014-01-25 // num 第几个月 下一个月 1 下两个月 2 上一个月-1 上两个月-2 以此类推 date=date.replace(/-/g,"/"); var dt=new Date(date); return this.chuoToDate(dt.setMonth(dt.getMonth() + Number(num))); }, 23.计算两个日期之间相差的年月日 monthDayDiff(startDate,endDate) { let flag = [1, 3, 5, 7, 8, 10, 12, 4, 6, 9, 11, 2]; let start = new Date(startDate); let end = new Date(endDate); let year = end.getFullYear() - start.getFullYear(); let month = end.getMonth() - start.getMonth(); let day = end.getDate() - start.getDate(); if (month < 0) { year--; month = end.getMonth() + (12 - start.getMonth()); } if (day < 0) { month--; let index = flag.findIndex((temp) => { return temp === start.getMonth() + 1 }); let monthLength; if (index <= 6) { monthLength = 31; } else if (index > 6 && index <= 10) { monthLength = 30; } else { monthLength = 28; } day = end.getDate() + (monthLength - start.getDate()); } return { year,month,day }; }, 24.计算两个时间之间的差 diffTime(startDate,endDate) { //用法 diffTime('2017-03-02 09:10:10','2017-03-17 04:10:12') startDate=startDate.replace(/-/g,'/');//ios兼容 endDate=endDate.replace(/-/g,'/');//ios兼容 startDate= new Date(startDate); endDate = new Date(endDate); var diff=endDate.getTime() - startDate.getTime();//时间差的毫秒数 //计算出相差天数 var days=Math.floor(diff/(24*3600*1000)); //计算出小时数 var leave1=diff%(24*3600*1000);//计算天数后剩余的毫秒数 var hours=Math.floor(leave1/(3600*1000)); //计算相差分钟数 var leave2=leave1%(3600*1000);//计算小时数后剩余的毫秒数 var minutes=Math.floor(leave2/(60*1000)); //计算相差秒数 var leave3=leave2%(60*1000);//计算分钟数后剩余的毫秒数 var seconds=Math.round(leave3/1000); var returnStr = seconds + "秒"; if(minutes>0){ returnStr = minutes + "分" + returnStr;} if(hours>0){returnStr = hours + "小时" + returnStr;} if(days>0){returnStr = days + "天" + returnStr;} return { data:returnStr, day:days,hh:hours,mm:minutes,ss:seconds }; }, 25.倒计时 countDown(jssj,success,times){ /*用法:app.countDown("2020-08-24 07:23:00",function(res){ console.log(res) },1000) jssj:设置结束时间 2020-08-10 12:12:12 times:设置倒计时的时间间隔 */ fun(); var timer=setInterval(function(){ fun(); },times); function fun(){ var lefttime = parseInt((new Date(jssj.replace(/-/g,"/")).getTime() - new Date().getTime())); if(lefttime <= 0) { success({day:"00",hour:"00",min:"00",sec:"00"}); clearInterval(timer); return; } var d = parseInt(lefttime /1000 /3600 /24); //天数 var h = parseInt(lefttime / 1000 / 3600 % 24); //小时 var m = parseInt(lefttime / 1000 / 60 % 60); //分钟 var s = parseInt(lefttime / 1000 % 60); //当前的秒 d < 10 ? d = "0" + d : d; h < 10 ? h = "0" + h : h; m < 10 ? m = "0" + m : m; s < 10 ? s = "0" + s : s; success({ day: d, hour: h, min: m, sec:s }) } }, 26.多长时间之前 timeago(stringTime){ var minute = 1000 * 60; var hour = minute * 60; var day = hour * 24; var week = day * 7; var month = day * 30; var time1 = new Date().getTime();//当前的时间戳 var time2 = Date.parse(new Date(stringTime));//指定时间的时间戳 var time = time1 - time2; var result = "刚刚"; if (time < 0) { console.log("设置的时间不能早于当前时间!"); } else if (time / month >= 1) { // result = "" + parseInt(time / month) + "月前"; result=stringTime.slice(0,10);//大于等于1个月的时候显示具体日期 } else if (time / week >= 1) { result=stringTime.slice(0,10);//大于等于1周的时候显示具体日期 // result = "" + parseInt(time / week) + "周前"; } else if (time / day >= 1) { result = "" + parseInt(time / day) + "天前"; } else if (time / hour >= 1) { result = "" + parseInt(time / hour) + "小时前"; } else if (time / minute >= 1) { result = "" + parseInt(time / minute) + "分钟前"; } else { result = "刚刚"; } return result; }, 27.获取n到m之间的所有数 getNDMnumber(n,m){ // n,m是整数 且n<m; var n=Number(n); var m=Number(m); var arr=[]; var i=n; while(i<=m){ arr.push(i); i++; } return arr; },
2022-02-23 - 服务端后台接口开发实战
5.1学习对象+课程目的+推荐工具 [视频] 5.2网络请求基本知识 [视频] 5.3Mysql基本概念入门 [视频] 5.4手把手1:树洞数据库设计 [视频] 5.5phpmyadmin可视化操作入门及树洞数据库实现(1) [视频] 5.5phpmyadmin可视化操作入门及树洞数据库实现(2) [视频] 5.5phpmyadmin可视化操作入门及树洞数据库实现(3) [视频] 5.6PHP基本概念入门(1) [视频] 5.6PHP基本概念入门(2) [视频] 5.6PHP基本概念入门(3) [视频] 5.7Thinkphp基本概念及操作-框架介绍(1) [视频] 5.7Thinkphp基本概念及操作-框架介绍(2) [视频] 5.8Thinkphp基本概念及操作-路由(1) [视频] 5.8Thinkphp基本概念及操作-路由(2) [视频] 5.9Thinkphp基本概念及操作-新增一条数据(1) [视频] 5.9Thinkphp基本概念及操作-新增一条数据(2) [视频] 5.10Thinkphp基本概念及操作-查询多条数据(1) [视频] 5.10Thinkphp基本概念及操作-查询多条数据(2) [视频] 5.11Thinkphp基本概念及操作-查询一条数据(1) [视频] 5.11Thinkphp基本概念及操作-查询一条数据(2) [视频] 5.12Thinkphp基本概念及操作-保存修改(1) [视频] 5.12Thinkphp基本概念及操作-保存修改(2) [视频] 5.13Thinkphp基本概念及操作-删除数据(1) [视频] 5.13Thinkphp基本概念及操作-删除数据(2) [视频] 5.14手把手3:用户的注册接口(1) [视频] 5.14手把手3:用户的注册接口(2) [视频] 5.15手把手4:用户的登录接口(1) [视频] 5.15手把手4:用户的登录接口(2) [视频] 5.16手把手5:发布新树洞接口(1) [视频] 5.16手把手5:发布新树洞接口(2) [视频] 5.17手把手6:获取所有树洞接口(1) [视频] 5.17手把手6:获取所有树洞接口(2) [视频] 5.18自主实操1:获取指定用户的所有树洞接口 [视频] 5.19手把手7:点赞接口 (1) [视频] 5.19手把手7:点赞接口 (2) [视频] 5.20自主实操2:删除指定树洞接口 [视频] 5.21手把手8:代码部署云服务器(1) [视频] 5.21手把手8:代码部署云服务器(2)(3)(4) [视频] 5.22手把手9:SVN进行代码的版本控制(MAC)(1) [视频] 5.22手把手9:SVN进行代码的版本控制(MAC)(2) [视频] 5.23手把手9:SVN进行代码的版本控制(Windows)(1) [视频] 5.23手把手9:SVN进行代码的版本控制(Windows)(2) [视频] 5.24课后小练 [视频]
2021-09-15 - 云开发日期型字段的比较
不要直接对比字符串,应该将时间字段转换成字符串类型再进行对比。 比如: db.collection("rideRecords") .aggregate() .match({ 'record.subLineId': 'l1', creationDate: _.gte('2022-01-30 00:00:00').and(_.lte('2022-01-30 23:59:59')), 'record.people._id': "381d149061ac0a5a00921a680d1281fe" }) .lookup({ from: "sublineRecords", localField: "_id", foreignField: "rideRecords", as: "sublineRecords" }) .lookup({ from: "driverRecords", localField: "sublineRecords.driverRecordID", foreignField: "_id", as: "driverRecords" }) .end() 上面语句,执行时返回空。 改成: db.collection("rideRecords") .aggregate() .addFields({ formatDate: $.dateToString({ date:'$creationDate', format:'%Y-%m-%d %H:%M:%S', timezone:'Asia/Shanghai' }) }) .match({ 'record.subLineId': 's4', formatDate:_.gte('2022-01-30 00:00:00').and(_.lte('2022-01-30 23:59:59')), }) .end() 关键点:把日期型通过格式转化:dateToString,转成字符类型再做比较 dateToString 相关文档: https://developers.weixin.qq.com/miniprogram/dev/wxcloud/reference-sdk-api/database/command/aggregate/AggregateCommand.dateToString.html 参考文档:https://www.jianshu.com/p/8da04042ffdd
2022-01-30 - 云函数设置TZ 为 Asia/Shanghai无效的解决方案(云函数时区问题)
[图片] 官方文档表明Node10以及Node15+版本可用环境变量:TZ 为 Asia/Shanghai,方式获取北京时间。其他Node版本需要借助第三方包,现给出第三方包方案。 云函数: //1.安装 moment-timezone npm install moment-timezone //2.编写云函数 const cloud = require('wx-server-sdk') const moment = require("moment-timezone") exports.main = async (event, context) => { let formatTime=moment().tz("Asia/Shanghai").format()//北京时间 2022-01-24T14:14:50+08:00 let hour=moment().tz("Asia/Shanghai").get('hour') //获取北京时间小时数 let minute=moment().tz("Asia/Shanghai").get('minute') //获取北京时间分数 let second=moment().tz("Asia/Shanghai").get('second') //获取北京时间秒数 return formatTime //result:{} 小程序中以result为载体 } //构建完毕重新部署上传云函数 js: //调用云函数 wx.cloud.callFunction({ name: "云函数名称", }).then(res => { //res 是云函数调用成功后return出来的值 console.log('云函数调用', res) }).catch(res => { console.log(res) })
2022-01-24 - 小程序生成酷炫二维码
0.引言 在小程序的业务中会有一些需要展示二维码的场景。静态二维码可以直接存放在本地,使用图片方式展示,但不适合根据用户相关信息生成动态的二维码。本文将介绍根据小程序的canvas能力绘制二维码。 1.方式一:通过wx-qr直接生成1.0 样例微信开发者工具打开 [图片] [图片] [图片] 1.1 安装# 通过 npm 安装 npm i wx-qr -S # 通过 yarn 安装 yarn add wx-qr 1.2 使用组件首先在你所开发的小程序根目录 [代码]app.json[代码] 或需要使用该组件的 [代码]xxx.json[代码] 中引用组件 (注意:请不要将组件命名为 [代码]wx-xxx[代码] 开头,可能会导致微信小程序解析 tag 失败 ) { "usingComponents": { "qr-container": "wx-qr" } } 之后就可以在 wxml 中直接使用组件 WXML <qr-container text="{{qrTxt}}" size="750"></qr-container> js Page({ data: { qrTxt: 'https://github.com/liuxdi/wx-qr', }, }); 当然,还可以支持很多种配置,详见github 或者 码云代码。 2.方式二:基于QRCode.js结合canvas绘制2.1 引入二维码数据生成库复制qrcode.js至你的小程序相应目录。 2.2 小程序中建立canvas标签,并给canvas设置长宽<canvas id="qr" type="2d" style="height: 750rpx;width: 750rpx;"></canvas> 2.3获取canvas实例及上下文const query = this.createSelectorQuery(); let dpr = wx.getSystemInfoSync().pixelRatio; query.select('#qr').fields({ node: true, size: true, id: true }) .exec((res) => { let { node: canvas, height, width } = res[0]; let ctx = canvas.getContext('2d'); canvas.width = width * dpr canvas.height = height * dpr ctx.scale(dpr, dpr); }) 2.4 定义一些变量及绘制二维码的数据区其中QRCodeModel是从qrCode.js中导入的 // 二维码的颜色 const colorDark = '#000'; // 获取二维码的大小,因css设置的为750rpx,将其转为px const rawViewportSize = getPxFromRpx(750); // 二维码容错率{ L: 1, M: 0, Q: 3, H: 2 } const correctLevel = 0; // 创建二维码实例对象,并添加数据进行生成 const qrCode = new QRCodeModel(-1, correctLevel); qrCode.addData(url); qrCode.make(); // 每个方向的二维码数量 const nCount = qrCode.moduleCount; // 计算每个二维码方块的大小 const nSize = getRoundNum(rawViewportSize / nCount, 3) // 每块二维码点的大小比例 const dataScale = 1; // 计算出dataScale不为1时,每个点的偏移值 const dataXyOffset = (1 - dataScale) * 0.5; for (let row = 0; row < nCount; row++) { for (let col = 0; col < nCount; col++) { // row 和 col 处的模块是否是黑色区 const bIsDark = qrCode.isDark(row, col); // 是否是二维码的图案定位标识区 Position Detection Pattern(如本模块,是三个顶点位置处的大方块) const isBlkPosCtr = (col < 8 && (row < 8 || row >= nCount - 8)) || (col >= nCount - 8 && row < 8); // 是否是Timing Patterns,也是用于协助定位扫描的 const isTiming = (row == 6 && col >= 8 && col <= nCount - 8) || (col == 6 && row >= 8 && row <= nCount - 8); // 如果是这些区域 则不进行绘制 let isProtected = isBlkPosCtr || isTiming; // 计算每个点的绘制位置(left,top) const nLeft = col * nSize + (isProtected ? 0 : dataXyOffset * nSize); const nTop = row * nSize + (isProtected ? 0 : dataXyOffset * nSize); // 描边色、线宽、填充色配置 ctx.strokeStyle = colorDark; ctx.lineWidth = 0.5; ctx.fillStyle = bIsDark ? colorDark : "rgba(255, 255, 255, 0.6)"; // 如果不是标识区,则进行绘制 if (!isProtected) { ctx.fillRect( nLeft, nTop, (isProtected ? (isBlkPosCtr ? 1 : 1) : dataScale) * nSize, (isProtected ? (isBlkPosCtr ? 1 : 1) : dataScale) * nSize ); } } } 此时已经绘制出二维码的数据区: [图片] 2.5 绘制图形识别区// 绘制Position Detection Pattern ctx.fillStyle = colorDark; ctx.fillRect(0, 0, 7 * nSize, nSize); ctx.fillRect((nCount - 7) * nSize, 0, 7 * nSize, nSize); ctx.fillRect(0, 6 * nSize, 7 * nSize, nSize); ctx.fillRect((nCount - 7) * nSize, 6 * nSize, 7 * nSize, nSize); ctx.fillRect(0, (nCount - 7) * nSize, 7 * nSize, nSize); ctx.fillRect(0, (nCount - 7 + 6) * nSize, 7 * nSize, nSize); ctx.fillRect(0, 0, nSize, 7 * nSize); ctx.fillRect(6 * nSize, 0, nSize, 7 * nSize); ctx.fillRect((nCount - 7) * nSize, 0, nSize, 7 * nSize); ctx.fillRect((nCount - 7 + 6) * nSize, 0, nSize, 7 * nSize); ctx.fillRect(0, (nCount - 7) * nSize, nSize, 7 * nSize); ctx.fillRect(6 * nSize, (nCount - 7) * nSize, nSize, 7 * nSize); ctx.fillRect(2 * nSize, 2 * nSize, 3 * nSize, 3 * nSize); ctx.fillRect((nCount - 7 + 2) * nSize, 2 * nSize, 3 * nSize, 3 * nSize); ctx.fillRect(2 * nSize, (nCount - 7 + 2) * nSize, 3 * nSize, 3 * nSize); // 绘制Position Detection Pattern 完毕 // 绘制Timing Patterns const timingScale = 1; const timingXyOffset = (1 - timingScale) * 0.5; for (let i = 0; i < nCount - 8; i += 2) { _drawDot(ctx, 8 + i, 6, nSize, timingXyOffset, timingScale); _drawDot(ctx, 6, 8 + i, nSize, timingXyOffset, timingScale); } // 绘制Timing Patterns 完毕 这时候,一个朴素的二维码就绘制成功啦~ [图片] 具体代码详见代码片段 该代码只是提供了一个简单二维码的生成逻辑。若需要更复杂的二维码展示功能,还是建议使用wx-qr。欢迎各位提Issue和Star~~
2022-01-04 - 【小程序技巧】如何让长文本超过限定行数自动折叠,并且可以展开收起
这是去年在校做项目遇到的一个需求,文章沉在草稿箱里一直没写完,主要分享一下如何实现长文本的折叠展开。 长文本超过限定行数自动折叠,点击长文本或者按钮,实现展开收起效果。这类效果其实在平时的app中或者网站中很常见,举几个栗子: 微信朋友圈: [图片] 新浪微博: [图片] 分析需求 1、文本超长省略,主要是通过 line-clamp 实现: [代码].text-clamp2 { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } [代码] 文本效果: [图片] 2、如何判断文本是否超出两行,显示「全文」「收起」按钮呢? [图片] 通过上图我们可以发现,当文本区域省略时,它的高度会相对变小,那么我们只需要获取到不省略和省略时的文本区域高度,进行比较就能知道是否超出了两行。 [图片] 思路解决了,怀着喜悦的心情翻看了一下文档:咦?为什么小程序没有像 js 那样操作 dom 节点的接口?那还怎么获取元素的尺寸高度!好在功夫不负有心人,终于在文档找到类 DOM 操作的 API「SelectQuery」。 实现需求 3、什么是 SelectQuery?如何去使用它? 从文档(传送门)描述来看 SelectQuery 是一个查询节点信息的对象,它可以选择匹配选择器的所有节点以及显示区域内的节点信息。既然它可以类似 jQuery 那样去匹配选择器,那么我们可以获取到需要的高度信息了。 [代码]// wxml <view class="contentInner1 text-clamp2">小程序是一种新的开放能力,开发者可以快速地开发一个小程序。小程序可以在微信内被便捷地获取和传播,同时具有出色的使用体验。</view> <view class="contentInner2">小程序是一种新的开放能力,开发者可以快速地开发一个小程序。小程序可以在微信内被便捷地获取和传播,同时具有出色的使用体验。</view> [代码] [代码]// js wx.createSelectorQuery().selectAll(".contentInner1, .contentInner2").boundingClientRect(res => { console.log(res) }).exec() [代码] 查询结果(文本区域省略时高度为 52px、不省略时为 104px,只要 res[0].height < res[1].height,此时就应该显示展开收起按钮 ) [图片] 4、逻辑设计上的优化 由于论坛帖子不只一个,我们得匹配对应的两个长文本节点,如果都给一个唯一的选择器,那么在页面中一次性查询这么多节点,很明显这不是最优的。 实际上我们可以将这封装成一个自定义组件,可供每个页面循环复用,在组件内我们只需要关注 单个 长文本的节点信息,不需要一次性获取当前页面的所有长文本节点,更重要的是:在组件内每个长文本的展开与收起状态都是独立的,也省去了在页面内定义字段去标识每个帖子的展开状态。 5、实现效果 [图片] [图片] 6、参数说明 属性 类型 默认值 说明 content String “示例文本” 长文本内容 maxline Number 1 最多展示行数[只允许 1-5 的正整数] position String “left” 展开收起按钮位置[可选值为 left right] foldable Boolean true 点击长文本是否展开收起 最后附上代码片段,有疑问欢迎在下方留言或者发社区私信(三连暗示) [图片]
2021-12-30 - 微信小程序代码优化3个小技巧
抽取重复样式 样式复用 我们会发现很多时候在开发的过程中会存在多个页面中都用到了同样的样式,那么其实之前有提到过,公用样式可以放在app.wxss里面这样就可以直接复用。 如:flex布局的纵向排列,定义在app.wxss里面 [代码].flex-col{ display: flex; flex-direction: column; } [代码] 然后其他页面可以直接使用组合样式: [图片] 通用的写在app.wxss里面,个性化的在具体页面编写。 以上是样式的复用,还有一种是样式中常用的具体属性值设置成变量,便于复用。 属性复用 使用CSS自定义属性(变量) 声明一个自定义属性,属性名需要以两个减号(–)开始,属性值则可以是任何有效的CSS值。 [代码]page { --color:#F8D300 } [代码] 注意:需要在app.wxss定义,这样所有页面的wxss才能使用。 使用一个局部变量时用 var() 函数包裹以表示一个合法的属性值: [代码].content-btn { background: var(--color); } [代码] 同样的除了颜色,还有一些统一的边距、大小、等等属性都可以。 抽取重复方法 做过小程序开发的同学应该都知道都知道app.js是可以全局共享的。那么这个时候如果有多个页面都需要用到的方法和属性就可以全部写在app.js里面。 如下所示: [代码]// app.js App({ randomMsg(){ let msgs = this.globalData.msgs let msg = msgs[Math.floor(Math.random() * msgs.length)]; return msg }, globalData: { msgs:["你好吗?","加油鸭!","早点睡!","奥利给!","别熬夜!"] } }) [代码] 页面使用方法: [代码]const app = getApp() Page({ onLoad: function (options) { console.log(app.globalData) console.log(app.randomMsg()) }, }) [代码] 适用场景:在小程序里面共享都是一次应用生命周期中会有多个页面使用到的数据,小程序重启后将全局变量会重新初始化。 安装第三方包 除了可以提取方法到utils里面达到了便于复用。有时候我们维护常用工具类成本很高,而且我们要去深入去了解里面的API,这个时候我们用别人维护的工具类。 这个时候我们就会去github查找相关的开源库,找到合适的就需要进行使用。使用通常有两种方式: 直接复制原来到自己的项目中 使用 npm 包进行远程引用 具体引用可以查看我之前写过的文章:《微信小程序如何引入npm包?》 总结 无论是 css 样式还是 js 方法都要尽可能的抽象复用,这样才能提升整体效率。 在优化的过程中先局部再整体,没有最好只有更好,基于业务场景来做优化。 常用的工具类就不需要重复发明轮子,学会使用已有第三方开源库可提升效率。
2021-09-03 - 新能力丨云开发Cloudbase推出登录组件
开发「用户登录模块」是 Web 应用开发者最关心的事项之一,继云开发 CloudBase 原生支持短信验证码登录后,目前云开发已支持短信验证码、邮箱等多种登录鉴权方式,供不同的用户场景使用。 为了进一步优化开发者的使用体验,云开发 CloudBase 全新推出了自带云开发登录能力的 UI 组件——@cloudbase/ui-react,封装了邮箱登录、短信验证码登录、用户名登录、微信授权登录等能力,基本覆盖了云开发已有的用户登录场景。 [图片] 对比之前需要开发者引入 SDK 并使用相关 API 才能实现登录鉴权,现在只需几行核心代码,直接引入组件进行开发即可! [图片] 如何使用 @cloudbase/ui-react UI 组件? 1、前往云开发控制台,在 环境-登录授权 中,开启相应的登录授权开关,如“短信验证码登录”。 [图片] 2、安装依赖 npm install --save @cloudbase/ui-react 目前仅支持了 React + WEUI 组件库UI 组件需结合 @cloudbase/js-sdk@1.5.4-alpha.0 及以上版本使用3、React 示例Demo App.js import { AUTHSTATE, LOGINTYPE, CloudbaseAuthenticator, CloudbaseSignOut, createAuthHooks, } from "@cloudbase/ui-react" import cloudbase from "@cloudbase/js-sdk" const app = cloudbase.init({ env: "your envid" }) const { useAuthData } = createAuthHooks(app) function App() { const { authState, user } = useAuthData() return authState === AUTHSTATE.SIGNEDIN && user ? ( <> Hello, {user.uid} ) : ( ) } export default App 详见文档: https://docs.cloudbase.net/cloudbase-ui/introduce.html 除了本次上线的登录组件外,还有一大波 UI 组件正在筹划,后续会一一和大家见面! 你最期待 CloudBase 上线哪些组件?欢迎大家在评论区提出自己的想法和建议! 产品介绍云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为开发者提供高可用、自动弹性扩缩的后端云服务,包含计算、存储、托管等serverless化能力,可用于云端一体化开发多种端应用(小程序,公众号,Web 应用,Flutter 客户端等),帮助开发者统一构建和管理后端服务和云资源,避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。 开通云开发:https://console.cloud.tencent.com/tcb?tdl_anchor=techsite 产品文档:https://cloud.tencent.com/product/tcb?from=12763 技术文档:https://cloudbase.net?from=10004 技术交流群、最新资讯关注微信公众号【腾讯云开发CloudBase】
2021-06-24 - 怎么解析webservice返回的soap手机上无法识别DOMParser
var xmlDoc = (new DOMParser()).parseFromString(result.data, "application/xml"); var nods = xmlDoc.childNodes; 现在在手机上DOMParser不支持这个对象怎么办?
2018-03-20 - 答题小程序中,如何将数据库里的排行榜数据导出为excel
在答题小程序中,如何将数据库里的排行榜数据导出为excel? 需要借助第三方工具包,没错,就是node-xlsx了。 [图片] [图片] node-xlsx不仅可以解析Excel文件从中取出数据,还能将数据生成Excel文件,因此我们可以将云数据库里面的数据取出来之后保存为Excel文件,然后再将保存的Excel文件上传到云存储。 1、安装node-xlsx npm install node-xlsx 2、引入node-xlsx const xlsx = require('node-xlsx'); 3、查询数据 const dataList = await db.collection("rank").where({ score:_.gt(0) }).orderBy('score', 'desc').limit(100).get() 4、处理数据并导出为excel const data = dataList.data let sheet = [] let title = ['排名','姓名','支部','答题次数','累计总分'] await sheet.push(title) for(let rowIndex in data){ let rowcontent = [] rowcontent.push(data[rowIndex].rank) rowcontent.push(data[rowIndex].name) rowcontent.push(data[rowIndex].dept) rowcontent.push(data[rowIndex].num) rowcontent.push(data[rowIndex].score) await sheet.push(rowcontent) } const buffer = await xlsx.build([{name: "成绩排行榜", data: sheet}]) 5、在云存储生成excel文档 await cloud.uploadFile({ cloudPath: '成绩排行榜.xlsx', fileContent: buffer, }) [图片] 6、下载到本地 [图片]
2021-06-01 - 升级2.0!可延迟显示的小程序loading组件
一、介绍 可控制延迟显示的微信小程序 loading 组件,默认请求超过0.5s才显示loading动画;支持 slot 自定义 loading 内容。 在项目中,若网络良好的情况下,每次请求都显示loading动画,会导致页面短时间内频繁闪现loading动画,用户体验不佳。本组件可自定义loading组件显示延时,只有当请求超过设置的时间未完成时,才显示loading动画,减少loading动画出现的次数。 注:2.0版本简化了使用流程及API,与1.x版本不兼容。 点击查看 demo 二、使用 安装 [代码]npm i wx-delay-loading[代码] 组件初始化:在 app.js 的 onLaunch 中执行组件初始化方法,挂载全局对象 DLoading [代码]// app.js import DelayLoading from 'wx-delay-loading/lib/index' App({ onLaunch: function () { // 初始化组件,挂载全局对象 DLoading DelayLoading.init() } }) [代码] 在使用组件的页面或组件的配置 json 内,引入组件 注:微信小程序组件名不允许使用 wx 做前缀 [代码]// page.json "usingComponents": { // 微信小程序组件名不允许使用wx做前缀 "delay-loading": "wx-delay-loading/index" } [代码] 在页面 wxml 中使用,设置 id 属性为 loading,否则 DLoading 静态方法会找不到组件。 注:若 delay-loading 组件存在父组件,需要同时把父组件和 delay-loading 组件的 id 设为 loading [代码]// page.wxml // 不使用 slot <delay-loading id="loading" /> // 使用 slot 自定义内容 <delay-loading id="loading" customLoading="{{true}}"> <view class="container"> <image class="logo" src="/static/image/logo.png" mode="widthFix" /> <view class="text">加载中...</view> </view> </delay-loading> [代码] 请求开始时(例如 wx.request),调用全局对象 DLoading 的静态方法 setReqDelay(delaytime),delaytime 默认为超过500毫秒请求未结束则显示 loading 组件;delaytime 为0时,每次请求都会显示组件。<br/> 请求结束时,调用静态方法 endReq(),会检查正在进行的请求数,若为0,则隐藏 loading 组件。 [代码]// page.js Page({ // 仅为示例 exampleRequest () { // 请求开始 DLoading.setReqDelay(300) // 请求超过0.3秒没完成,显示 loading 组件 wx.request({ url: 'https://example.com/getData', complete () { // 请求完成 DLoading.endReq() } }) }, }) [代码] 三、进阶:在统一封装请求 request.js 内使用 项目开发中,通常会针对请求和响应进行统一处理,封装成一个 request.js 使用。 [代码]// request.js const request = (options) => { return new Promise ((resolve, reject) => { // 请求开始前调用设置延时 DLoading.setReqDelay() wx.request({ ...options, success (res) { // 请求成功后的各种处理操作... resolve(res.data) }, fail (err) { // 请求失败后的各种处理操作... reject(err) }, complete () { // 请求完成 DLoading.endReq() } }) }) } export default request [代码] [代码]// page.js import request from request.js Page({ // 仅为示例 exampleRequest () { // 使用封装后的request request({ url: 'https://example.com/getData' }).then(res => { // 对返回数据的处理... }) }, }) [代码] 四、调试:模拟低网速情况 通常在网络环境良好的情况下,请求都会很快完成,不会超过0.5s。可通过微信开发者工具-调试器-Network,把网络设置 Online,更改为 Slow 3G,或者使用 Custom 自定义网络速度。 五、文档 组件 options 参数 说明 类型 默认值 customLoading 是否使用 slot 插槽自定义 loading 内容 boolean false id 组件标识 string 需手动设置为 loading <br/> 对象 methods 方法名 说明 参数 参数类型 init 初始化组件,挂载全局对象 DLoading - - setReqDelay 标记请求开始并设置延迟显示的时间 延迟的时间,单位毫秒 number endReq 检测正在进行的请求数,若清零则隐藏 loading 组件 - - <br/> 六、示例 点击查看 demo
2021-05-17 - css 毛玻璃效果
[图片] css width: 100rpx; height: 130rpx; background: linear-gradient(to right bottom, rgba(44,44,69,0.6), rgba(44,44,69,0.3), rgba(44,44,69,0.2)); backdrop-filter: blur(9px); border-top: 2rpx solid rgba(44,44,69,0.8); border-left: 2rpx solid rgba(44,44,69,0.8); border-radius: 30rpx; padding: 20rpx; box-shadow: 0 9px 15px 0 rgba(25,21,40,0.39),0 -9px 15px 0 rgba(25,21,40,0.39);
2021-04-26 - 微信小程序表单getinput优化
微信小程序表单[代码]getinput[代码]优化 对于初学者来说,表单数据不同内容的获取大概如下所示。虽然非常容易阅读,但是如果表单需要填写的数据比较多,但是大量的重复写法会让人非常头疼。 [代码]GetName(e){ // console.log(e) this.setData({ name:e.detail.value }) }, GetId(e){ this.setData({ idcard:e.detail.value }) }, GetLoc(e){ this.setData({ userloc:e.detail.value }) }, GetPhone(e){ this.setData({ phone:e.detail.value }) }, GetTraffic(e){ this.setData({ traffic_id:e.detail.value }) }, [代码] 那么有没有一种方式能够把所有形式的表单获取都抽离成一个函数呢?我也是偶然想到这个想法 data 虽然为 page 中的一个配置项,但是本质上依旧是 一个 object* 既然确定了这个思路,那么如何抽离就很清晰了,写法如下 [代码]<view class="cu-form-group margin-top"> <view class="title">轮播内容</view> <input placeholder="请输入轮播内容" bindinput="getinput" data-attr="bannername"></input> </view> [代码] js [代码]getinput(e){ let attr = e.currentTarget.dataset.attr let setvalue = `${attr}` this.setData({ [setvalue]:e.detail.value },()=>{ console.log(this.data.bannername) }) } [代码] 对于不同的字段,只需要修改[代码]data-attr[代码]的值即可
2021-04-19 - wx.getUserProfile 修改方案
最近微信关于用户头像、昵称授权又做了调整。 点击查看原文 解决方案思路如下: 1、在util.js里写一个通用函数,函数的功能是,用户授权成功,将头像昵称,存入服务器,同时,在本地缓存设置标记用户授权成功。 [代码]// util.js function getUserProfile() { wx.getUserProfile({ desc: '用于完善个人资料', success: function(res) { var userInfo = res.userInfo // console.log('userInfo==>', userInfo) wx.setStorageSync('storage_info', 1);//本地标记 //下面将userInfo存入服务器中的用户个人资料 //... }, fail() { console.log("用户拒绝授权") } }) } [代码] 2、在需要用户授权时,做判断,如果本地已经授权,直接执行正常业务逻辑。如果未授权,则提示授权。 [代码] chooseTap: function(e) { //如果未授权,就提示授权,如果授权了,就执行正常的业务逻辑 if (!wx.getStorageSync('storage_info')) { util.getUserProfile() return } //下面是正常业务逻辑 //... } [代码] 3、在用户进入小程序时,从服务器获取用户信息(如果已授权,就有之前存入的头像,昵称),在页面展示用户信息。 完成以上3步,就全部完成了。 说明:把授权状态存入缓存的好处是:因为wx.getUserProfile每次都会弹授权框,如果每次都让用户授权,体验不好。如果只授权一次,存入服务器,以后都展示的是这个用户信息。在用户微信改名,改头像后,服务器储存的用户信息还是以前的。 所以,把授权状态存入缓存,起码在用户更换手机,或者删除过小程序,又进来时,会弹出授权提示,可以让用户重新授权,将服务器里用户的信息进行一次更新。
2021-04-11 - 还在傻傻分不清ES5、Es6数组方法?各大姿势来袭
前言 初衷: 在面试中,面试官经常问到说一下Es5和Es6的数组方法有哪些,有很多同学老是分不清楚,今天笔者就来分享一下。 适合人群: 前端初级开发 感兴趣的小伙伴点击链接,了解详情~ http://github.crmeb.net/u/yi Es5系列 indexOf 用途: 用于查找数组中是否存在某个值,如果存在则返回某个值的下标,否则返回[代码]-1[代码] let list = [1, 2, 3]; console.log(list.indexOf(2)) // 1 console.log(list.indexOf("蛙人")) // -1 map 用途: [代码]map[代码]是一个数组函数方法,接收三个参数,[代码]value[代码],[代码]index[代码],[代码]self[代码],返回值是处理完的结果。 let list = [1, 2, 3]; const res = list.map((value, key, self) => { console.log(value) // 1 2 3 console.log(key) // 0 1 2 console.log(self) // [1, 2, 3] return value * 2 }) console.log(res) forEach 用途: 用于遍历一个数组,接收三个参数,[代码]value[代码],[代码]index[代码],[代码]self[代码],返回值为[代码]undefined[代码] let list = [1, 2, 3]; const res = list.forEach((value, key, self) => { console.log(value) // 1 2 3 console.log(key) // 0 1 2 console.log(self) // [1, 2, 3] return 123 }) console.log(res) // undefined splice 用途: 用于数组删除或替换内容,接收三个参数: 第一个参数是,删除或添加的位置第二个参数是,要删除的几位,如果为0则不删除第三个参数是,向数组添加内容 let list = [1, 2, 3]; list.splice(0, 1) // 把第0个位置,给删除一位 console.log(list) // [2, 3] list.splice(0, 1, "蛙人") // 把第0个位置,给删除一位,添加上一个字符串 console.log(list) // ["蛙人", 2, 3] list.splice(0, 2, "蛙人") // 把第0个位置,给删除2位,添加上一个字符串 console.log(list) // ["蛙人", 3] slice 用途: 用于截取数组值,接收两个参数,第一个参数是要获取哪个值的下标,第二个参数是截取到哪个下标的前一位。 let list = [1, 2, 3]; let res = list.slice(1, 3) // 从第一位下标开始截取,到第三位下标的前一位,所以截取出来就是 [2, 3] console.log(res) // [2, 3] filter 用途: 用于过滤数组内的符合条件的值,返回值为满足条件的数组对象 let list = [1, 2, 3]; let res = list.filter(item => item > 1); console.log(res) // [2, 3] every 用途: 用于检测数组所有元素是否都符合指定条件,返回值为[代码]Boolean[代码] , 该方法是数组中必须全部值元素满足条件返回[代码]true[代码],否则[代码]false[代码] let list = [1, 2, 3]; let res = list.every(item => item > 0) console.log(res) // true let res1 = list.every(item => item > 1) console.log(res1) // false some 用途: 用于检测数组中的元素是否满足指定条件,返回值为[代码]Boolean[代码] , 该方法是只要数组中有一项满足条件就返回[代码]true[代码],否则[代码]false[代码] let list = [1, 2, 3]; let res = list.some(item => item > 0) console.log(res) // true reduce 用途: 该方法接收一个函数作为累加器,数组中的每个值(从左到右)开始缩减,最终计算为一个值。该方法回调函数接收四个参数 第一个参数:初始值, 或者计算结束后的返回值第二个参数:当前元素第二个参数:当前元素的索引第四个参数:当前元素所属的数组对象,本身 我们一般只用前两个就行,[代码]reduce[代码]第一个参数回调函数,第二个参数是初始值 let list = [1, 2, 3]; let res = list.reduce(( prev, cur ) => prev += cur, 0) console.log(res) // 6 reverse 用途: 用于数组反转 let list = [1, 2, 3]; let res = list.reverse(); console.log(res) // [3, 2, 1] join 用途: 用于数据以什么形式拼接 let list = [1, 2, 3]; let res = list.join("-"); console.log(res) // 1-2-3 let sum = eval(list.join("+")) console.log(sum) // 6 sort 用途: 用于将数组排序,排序规则看返回值 返回值为正数,后面的数在前面返回值为负数,前面的数不变,还在前面返回值为0,都不动 let list = [1, 2, 3]; let sort = list.sort((a, b) => b - a) console.log(sort) // [3, 2, 1] concat 用途: 用于合并数组原始 let list = [1, 2, 3]; let res = list.concat([1, 2, 3]) console.log(res) // [1, 2, 3, 1, 2, 3] push 用途: 向数组后面添加元素,返回值为数组的[代码]length[代码] let list = [1, 2, 3]; let res = list.push(1) console.log(res) // 4 pop 用途: 用于删除数组尾部的元素,返回值为删除的元素 let list = [1, 2, 3]; let res = list.pop() console.log(res) // 3 shift 用途: 用于删除数组的头部,返回值为删除的元素 let list = [1, 2, 3]; let res = list.shift() console.log(res) // 1 unshift 用途: 向数组的头部添加元素,返回值为数组的[代码]length[代码] let list = [1, 2, 3]; let res = list.unshift(1) console.log(res) // 4 toString 用途: 用于将数组内容转换为字符串 let list = [1, 2, 3]; let res = list.toString() console.log(res) // 1,2,3 Es6+ 系列 includes 用途: 检测数组中是否存在该元素,返回[代码]Boolean[代码]值 let list = [1, 2, 3]; let res = list.includes("蛙人") let res1 = list.includes(1) console.log(res, res1) // false true find 用途: 查找数组的元素,满足条件的返回单个值,按照就近原则返回 let list = [1, 2, 3]; let res = list.find((item) => item > 1) console.log(res) // 2, 按照就近原则返回 findIndex 用途: 查找数组中元素,满足条件的返回数组下标 let list = [1, 2, 3]; let res = list.findIndex((item) => item > 1) console.log(res) // 1, 按照就近原则返回下标 flat 用途: 用于拉平嵌套数组对象 let list = [1, 2, 3, [4, [5]]]; let res = list.flat(Infinity) console.log(res) // [1, 2, 3, 4, 5] fill 用途: 用于填充数组对象 let list = [1, 2, 3]; let res = list.fill(1) console.log(res) // [1, 1, 1] Array.isArray 用途: 检测对象是不是一个数组 let list = [1, 2, 3]; let res = Array.isArray(list) console.log(res) // true Array.from 用途: 将伪数组转换为真数组 let res = Array.from(document.getElementsByTagName("div")) console.log(res) // 转换为真数组就可以调用数组原型的方法 Array.of 用途: 用于生成一个数组对象,主要是用来弥补[代码]Array()[代码]的不足 let res = Array.of(1, 2, 3) console.log(res) // [1, 2, 3] 改变原始数组值的有哪些[代码]splice[代码]、[代码]reverse[代码]、[代码]sort[代码]、[代码]push[代码]、[代码]pop[代码]、[代码]shift[代码]、[代码]unshift[代码]、[代码]fill[代码] 结语这里[代码]keys[代码]、[代码]values[代码]、[代码]entries[代码]就不写啦,它们使用数组方式的话,返回的是[代码]Iterator[代码]遍历器对象。欢迎大家查漏补缺常用数组方法哦 感谢谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。 感兴趣的小伙伴点击链接,了解详情~ http://github.crmeb.net/u/yi 作者:蛙人
2021-04-02 - 数据可视化API之热力图实现
前言 数据可视化API(Web),是基于腾讯位置服务JavaScript API GL实现的专业地理空间数据可视化渲染引擎。 通过这套API,可以实现轨迹数据、坐标点数据、热力、迁徙、航线等空间数据的可视化展现。 使用步骤 1、注册成为腾讯位置服务开发者,并进入控制台 -> key管理界面创建key; [图片] 2、数据可视化API(以下简称可视化API),所提供的可视化效果是以图层的方式叠加在JavaScript API GL之上,图层中所显示的数据由实例化的对象统一管理。 [图片] 3、加载可视化API 可视化API是以 Javascript API GL 的附加库的形式加载的,请确保: 引入时须传入&libraries=visualization参数(查看: Javascript API GL加载参数说明 ) [代码]<script src="https://map.qq.com/api/gljs?v=1.exp&key=YOUR_KEY&libraries=visualization"></script> [代码] 应用场景 热力图以颜色来表现数据强弱大小及分布趋势,可以用在出行、旅游、警务安全、城市规划和研究等多方面。 代码 大家可通过参考手册先了解下参数详情。 [代码]<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>3D经典热力</title> </head> <script charset="utf-8" src="https://map.qq.com/api/gljs?v=1.exp&key=OB4BZ-D4W3U-B7VVO-4PJWW-6TKDJ-WPB77&libraries=visualization"></script> <style type="text/css"> html, body { height: 100%; margin: 0px; padding: 0px; } #container { width: 100%; height: 100%; } </style> <body onload="initMap()"> <div id="container"></div> <script src="https://mapapi.qq.com/web/lbs/visualizationApi/demo/data/heat.js"></script> <script> function initMap() { var center = new TMap.LatLng(39.909897147274364, 116.39756310116866); //初始化地图 var map = new TMap.Map("container", { zoom: 12,//设置地图缩放级别 pitch: 45, // 设置地图俯仰角 center: center,//设置地图中心点坐标 mapStyleId: "style1" //个性化样式 }); //初始化热力图并添加至map图层 new TMap.visualization.Heat({ max: 180, // 热力最强阈值 min: 0, // 热力最弱阈值 height: 40, // 峰值高度 radius: 30 // 最大辐射半径 }) .addTo(map) .setData(heatData);//设置数据 } </script> </body> </html> [代码] 效果图 [图片] 在线示例 以上内容只是简单实现,更具体代码示例以及原理讲解,可参考文章: 【硬核干货来了!鹅厂前端工程师手把手教你实现热力图!】
2021-02-26 - table表格组件,分享给各位
前言 移动端的页面本应该很少有table表格这样的展示、操作,但总归有这样的需求,然而平时用的vant和iview的小程序组件库都没有table组件,这里将自己编写的table组件展示一下供大家查看。 小程序实现table的问题在于,自定义td的实现,而小程序没办法像react一样使用[代码]jsx[代码],也没办法像vue一样用[代码]作用域插槽[代码]传row行的信息给slot,但是小程序还是留有一样东西可以完成自定义td的功能。 抽象节点 这个特性自小程序基础库版本 1.9.6 开始支持。 有时,自定义组件模板中的一些节点,其对应的自定义组件不是由自定义组件本身确定的,而是自定义组件的调用者确定的。这时可以把这个节点声明为“抽象节点”。 微信官方api地址 通过抽象节点我们可以做到使用自定义组件通过key值分发组件内容到不同的td里。 具体的源码地址可点击下方查看,如果对你有帮助请点个star~~ 源码地址 具体的实现效果可以扫描下方小程序码。 [图片] API prop 参数 说明 类型 默认值 是否必填 columns 表格的配置 Columns[] [] true dataList 数据 any[] [] true getListLoading 请求列表的loading boolean false true showTipImage 无数据时的提示文本图片 boolean false true rowKey 用于指明行的唯一标识符,在勾选中有使用 string id false scrollViewHeight 控制可滚动区域高度。 string 600rpx false tipTitle 无数据时的提示文本主标题 string 提示 false tipSubtitle 无数据时的提示文本副标题 string 暂无数据 false scrollX 是否需要X轴滚动。 boolean false false select 控制是否出现勾选。 boolean false false selectKeys 勾选的初始值 any[] [] false generic:action-td 当列表项内具有操作列,需要在[代码]columns[代码]内添加[代码]type:action[代码]的一项,操作列的内容往往需要自定义,小程序不提供react,vue的[代码]rander函数[代码],所以使用到了抽象节点,该属性指明抽象节点的组件。操作列位置可以不固定,点击事件由[代码]bindclickaction[代码]触发 component undefined false isExpand 控制是否点击展开。 boolean false false expandValueKey 展开信息的key值 string false initExpandValue 当展开信息为空时的默认提示语 string ‘暂无信息’ false expandStyle 展开信息的最外层的样式 string ‘’ false generic:expand-component 如果展开区域的内容需要自定义,[代码]expandValueKey[代码]设置为空字符串,则切换到组件模式,传一个组件进来,展开区域的点击事件由[代码]bindclickexpand[代码]触发 component undefined false dynamicValue 给自定义内容的动态值,用于改变状态 ,建议{value:放的数据} object {} false Events 事件 解释 类型 bindclicklistitem 点击列表行事件 Function(e); e.detail.value = {index:number(当前行序号),item: any(当前行的内容)} bindclickexpand 点击展开内容事件 Function(e); e.detail.value = {type:(这个按钮的含义字段,如‘close’),index:(当前的行),item:(当前行的数据)};(这是我这里定义的结构,具体可以自己定义在expand-component里)} bindclickaction 点击抽象节点事件 Function(e); e.detail.value = {type:(这个按钮的含义字段,如‘close’),index:(当前的行),item:(当前行的数据)};(这是我这里定义的结构,具体可以自己定义在action-td里)} bindcheckkey 勾选事件 返回被勾选项的rowKey数组 Function(e); e.detail.value = any[]//(数组内每一项是rowKey字段定义的数据的toString()结果) bindscrolltolower 滚动触底 Function() bindscrolltoupper 滚动触顶 Function() column 列描述数据对象,是 columns 中的一项,Column 使用相同的 API。 事件 解释 类型 必填 title 字段名中文含义 string true key 字段名 string true width 单元格宽度 string false type 判断字段是否是自定义组件 ‘action’/undefined false render td内内容由函数返回 (value: any, item: any, index: number, data?: 当前页面的this.data) => any,// 设置内容 function false
2022-11-24 - 优雅解决:关于app.js的onLaunch 与 页面的onLoad 的异步问题
// 常见的场景:打开小程序时要先获取用户数据,再调其他接口 // 步骤: // 1、获取openid // 2、根据openid获取用户数据 // 3、获取到用户数据后 再 调取其他接口 啥也别说,直接看代码吧: 实际开发会把很多步骤合并,我这展示就每一步详细说明 [图片] [图片] [图片] [图片] -------分割线--------------------- 以上为app.js页面------------ 页面index.js(打开小程序页面栈的第一个页面) [图片] 总结:原理就是跨页面调用而已。该方法也可以使用在扫码进入的场景。只需在目标页面加上接收数据的函数init即可。
2020-12-16 - 微信小程序云开发校园二手书商城,可在线支付提现
欢迎使用,下面是配置教程 劳烦您花一秒钟时间,戳戳右上角的star 本程序完全开源,说明地址: https://mp.weixin.qq.com/s/e93APJGBrqbGNBiLuqKaxQ 目前新版程序已经上线,可先预览: [图片] 长话短说,下面直接说配置流程 一、小程序端 1、下载导入 直接下载到本地,然后导入开发者工具 最近墙的厉害,也可使用蓝奏云下载 https://www.lanzous.com/i6hd9mh 小程序开发综合文档地址:https://developers.weixin.qq.com/miniprogram/dev/framework/ 2、开通云环境 不罗嗦,这都是基础,直接看官方说明操作即可 云开发官方文档说明:https://developers.weixin.qq.com/miniprogram/dev/wxcloud/basis/getting-started.html 3、配置前端config 找到config.js文件,然后按照我写的注释更改为你自己 [图片] 4、细节修改 ①app.json 全局顶部导航 [图片] ②pages/help/help.js 帮助文档 [图片] ③images 默认图片 [图片] 二、云函数 1、修改基础信息 每个云函数要修改的部分,我都捻出来放在了顶部,直接根据我做的注释信息进行修改,如下图所示 [图片] 补充说明: 1.books云函数中使用的书籍信息使用的是极速数据的接口 详情地址:https://www.jisuapi.com/api/isbn/ 2.email云函数中使用的默认发送邮件方式为QQ邮箱 开发文档地址:https://service.mail.qq.com/cgi-bin/help?subtype=1&&no=1001256&&id=28 3.sms云函数中使用的腾讯云短信接口 申请地址:https://cloud.tencent.com/product/sms 开发文档:https://cloud.tencent.com/document/product/382/34874 2、上传全部文件 挨个提交每个云函数,其中依赖包我已经一起上传了,无需再挨个本地去安装,直接上传所有文件即可 [图片] 三、云开发数据库 1、创建集合 设置权限 分别创建下图所示的集合,然后将所有集合的权限设置为所有可读 [图片] 集合名称 存储内容 banner 首页轮播 books 书籍信息 history 钱包收支记录 order 订单信息 publish 发布信息 start 启动页图 times 提现次数 user 用户数据 2、设置banner ①在banner集合下新增一条记录 ②按照下图所示添加字段 [图片] 如果不知道如何添加,可以直接导入我生成的json,然后修改即可 banner集合示例记录下载地址: http://cqu.oss-cn-shenzhen.aliyuncs.com/img/book/github/database_export-RMHdk59cOYBr.json 补充说明 list数组下的img为图片地址,id为唯一区分字段,url为点击轮播后跳转的地址,这个地址必须为与此小程序关联的公众号文章或者为业务域名地址,如果没有就留空即可 3、设置启动页图片 ①在start集合下新增一条记录 ②按照下图所示添加字段 [图片] 四、云存储配置 1.提现设置 https://mp.weixin.qq.com/s/0ee3aHbtqhYT6b-0xljleQ 2.新建文件夹【share】,用于存放生成的小程序码 五、公众平台配置 1、设置基本信息 名称 配置 类目 生活服务 > 环保回收/废品回收 基础库 2.4.3 【开发】-【开发设置】-【服务器域名】-【downloadFile合法域名】: ①api.jisuapi.com ②你的云存储下载域名,如下图所示 [图片] 2、提交审核 审核页面路径:pages/start/start 3、设置在线客服 打开【设置】–【客服】–【添加】,绑定成功后,打开小程序【客服小助手】,状态设置为在线即可,到时候有客户咨询自动会推送到你的微信号上的 [图片] 其他 源码地址
2020-09-03 - 微信小程序云开发教程-一文学会Json
本小节我们将学习什么是json [图片] 我们首先学习三个概念,什么是数据项,什么是对象,什么是数组。 每一个数据项由key和value组成,key也可以叫name,代表数据项的名称,value代表数据项的值,也可以叫数据项的内容。比如id:123456就是一个数据项,这个数据项的key是id,value是123456。最后需要注意的是,一个数据项中,key不是必须有的。 那什么是对象呢?对象是由一组至少存在一个key的数据项组成的,其被包含在一个大括号内。比如下面的这个对象,由两个数据项构成,id:123456和province:黑龙江。 最后,我们来看数组的定义,数组是由一组都没有key的数据项构成,其被包含在一个中括号内。比如下面的这个数据,由两个数据项构成,每个数据项都没有key。 [图片] 数据项、对象、数组是可以互相嵌套和组合在一起的,比如说下面的这个数据。 所以,什么是json,Json就是一种由数据项、对象、数组组成的数据存储格式。 每个json就是一个对象,最外层被一个大括号包着。 每个括号里面可以是数据项、也可以是数据、还可以再嵌套对象。 最后需要注意的是,数据项之间由英文逗号相隔。 [图片] 那么,如何访问对象里的值呢,假设有一个数据对象叫location,我们可以通过点的方式获取到province的值,也可以通过下标的方式获取到province的值。 [图片] 最后,我们来学习如何访问数组里的值呢,假设有一个数据对象叫location,我们可以通过点的方式先找到cities,再通过下标的方式获取到cities里面的第一项的值。
2020-08-17 - 微信小程序云开发教程-JavaScript入门(4)-捕捉异常
现在我们来学习一种相对高级的bug调试方法 [图片] Js有一个特点,如果一行即将执行的代码出错,则该行代码以下的所有代码都不会再执行了。 [图片] 所谓的异常,就是bug。 左边是正常没有bug的代码,我们把原来的c=a+b中的b删除了,得到右边存在bug的代码,因为+是双目运算符,必须存在右边的数,所以,这行代码就有问题。 执行右边的代码,运行到sum=myFunction(1,2)时,系统就会报错,并且不会执行之后的console.log语句。 但是,假设我们想要实现,不管sum=myFunction(1,2)是否存在bug,我们仍然想要执行console.log(666)这行代码,我们该怎么办呢? [图片] 这里,我们需要使用try catch语法。 我们可以将可能可能存在bug,需要调试的代码放到try的代码块里面,把如果出现了错误之后,需要执行的代码放到catch的代码块里面,并且,catch可以捕捉到try里面的错误,将该错误赋值给err变量,这样,我们就可以打印出该错误。 最重要的是,系统会继续执行之后的代码,并不会暂停运行,也就是执行console.log(666),下面请根据教学视频进行学习和操作
2020-08-18 - 微信小程序云开发教程-JavaScript入门(5)-函数异步同步
本小节我们将学习同步和异步的概念 [图片] 同步是指下一行代码必须等待上一行代码执行完成,才执行。 异步是指下一行代码不必等待上一行代码执行完成,才执行。 同步的优点是,关心代码的执行结果,按顺序执行,保证了结果的顺序性; 异步的优点是,不关心代码执行结果,异步执行,可以提高执行效率,尤其适用于给用户发提醒通知的情景 [图片] 所有代码,默认的执行顺序都是同步的。那么,我们应该怎么把一个函数设置成可以异步执行的呢? 只需要在function的前面增加一个关键词async,那么在调用该函数时,该函数就是异步执行的。 在右边的代码里,console.log(2)的执行不必等待test()函数执行完 [图片] 可能有的同学想问,那怎么把异步函数变成同步执行的呢?我们只需要在调用该函数的时候,在前面增加一个关键词await。 那么在右边的代码里,console.log(2)的执行就必须等待test函数执行完毕,并放回对应的直接结果。
2020-08-18 - 土办法将数据迁移导云数据库
土办法将数据迁移导云数据库 [TOC] 约课小程序开发差不多了,要开始做数据迁移了。原先的约课系统是用云表格(伙伴办公)实现的。首先看一下官方文档:云数据库目前仅支持导入 CSV、JSON 格式的文件数据。有云开发控制台和HTTP API两种导入方式。伙伴办公可以导出数据为Excel文件。Excel导出为CSV很方便,所以首先尝试Excel转CSV方式,再用云开发控制台导入的方式。 Excel转CSV导入 CSV的字符集问题 Excel转CSV很简单,点几下鼠标就完事儿了。先从云表格中导出Excel文件,把表头改为目标数据对象的属性名。在Excel中导出为CSV文件。这时候遇到了一个小坑,没有注意字符集编码,直接导出了csv文件。然后折腾半天encoding问题。想i想不对呀,不能这么麻烦呀!又回过头去研究Excel,如果直接导出utf-8。果然,在导出文件格式中发现了“CSV UTF-8”这个类型。选择导出为“CSV UTF-8”,第一个小坑就算出来了。 CSV的数据类型问题 把Excel转成了csv,在开发者工具中导入集合,成功?导入是成功了,但是遇到了数据类型问题:csv是无类型的——都是文本型,处理程序自行处理(尝试转换为正确的数据类型。然而,什么是正确的类型不应该我来定义吗?实际上,我没的选择。主要是两种类型: 像数字的文本,比如手机号码。不论咋折腾,比如手机号加引号,导入工具都勤勤恳恳的把手机号码转成数值类型。 日期时间。读了文档,似乎通过csv导入是不能导入日期时间类型的——都作为普通的字符串了。 Tips csv可以处理嵌套对象。只要把第一行的列名(键名)写为路径的形式(如[代码]"user.name"[代码]),导入时就导入成了嵌套对象的属性。 所以,用CSV导入的方法放弃。接下去尝试通过json导入。 Excel转JSON导入 有了CSV的尝试,在JSON导入前,重点先弄清楚几个问题: JSON文件格式的要求 如何导入日期时间类型数据 如何导入包含嵌入对象的数据 JSON格式的要求 官方文档说: JSON 数据不是数组,而是类似 JSON Lines,即各个记录对象之间使用 \n 分隔,而非逗号; 先去学习一下什么是 JSON Lines。 JSON Lines JSON Lines官网的定义: This page describes the JSON Lines text format, also called newline-delimited JSON. JSON Lines is a convenient format for storing structured data that may be processed one record at a time. It works well with unix-style text processing tools and shell pipelines. It’s a great format for log files. It’s also a flexible format for passing messages between cooperating processes. 简单来说就是把一个json对象拍平到一行——一个对象一行;多个对象之间用[代码]"\n"[代码]分隔,而不用[代码]“,”[代码]分隔。 Excel转JSON Lines 基本明白JSON Lines是什么之后,怎么把Excel转成JSON Lines格式呢?Pandas呀!动手,打开Jupyter,5行代码“搞定”: [代码]import pandas as pd df = pd.read_excel("xxxx.xlsx") file= open(r'test.json', mode='w+', encoding='UTF-8') df.to_json(file, orient="records", lines=True, force_ascii=False, date_format="iso") file.close() [代码] 所谓5行代码,实际上Bing了半天。其中,有篇Blog写的挺好,可以参考: Python convert normal JSON to JSON separated lines 3 examples BUT……又遇到了新问题: 日期时间类型的数据无法正确导入(导入后是字符串类型) 内嵌对象没有办法“一键生成”(后面会详细说明一下) 其实也不是真没有办法——有什么是Python搞不定的呢。主要的问题是我不会呀。我的Python水平(事实上用水平这个词有点夸张了)是基本看得懂别人写的代码、简单的可以改一改这样的程度,Pandas只是之前大概看了几眼,知道它是干啥的。不为难自己,时间也不够,等我把pandas搞明白又要好几天了。所以,多年没写过程序的土人祭出终极武器——Excel! 土人的终极解决方案——Excel 用Excel生成批量脚本已经不是第一次干了,之前也帮忙ETL同事搞过复制几千万小文件的事情。放下面子,什么都好办了。毕竟能抓住老鼠的猫都是好猫,能解决问题的工具都是好工具。认怂以后,事情就简单了。在Excel里拼出JSON Line其实很容易(前提是数据量小,数据量大还是老老实实走正途),一些公式、拖拉复制、拷贝黏贴,很快就搞定了。 言归正传,分享一下怎么解决前面的两个问题吧。 如何处理日期时间 官方文档给出了例子: 时间格式须为 ISODate 格式,例如[代码]"date": { "$date" : "2018-08-31T17:30:00.882Z" }[代码] 开始没有看明白[代码]date[代码]和[代码]$date[代码]分别是啥意思。索性在数据库里插入一个带日期字段的对象,然后导出来看看。 [代码]{"_id":"3adec2825f2d0","createTime":{"$date":"2020-08-07T07:56:17.409Z"}} [代码] 这样就看懂了,把日期类型的属性(比如createTime)作为内嵌对象,内嵌对象中包含一个名为[代码]$date[代码]的字串属性,值是ISO时间表示法格式的字符串。要去学习一下ISO的日期时间格式 ISO时间格式 看文档:W3CSchool:关于JavaScript 日期格式化 简单来说, 日期部分:YYYY-MM-DD 时间部分:hh:mm:ss.ms 用T分隔日期和时间 时区:UTC用Z表示;其他时区用±时间偏移量表示 Javascript实验一下就一目了然了: UTC时间 [代码]var d = new Date("2019-05-02T12:00:00Z"); // Thu May 02 2019 20:00:00 GMT+0800 (中国标准时间) [代码] 中国标准时间 [代码]var d = new Date("2019-05-02T12:00:00+08:00"); // Thu May 02 2019 12:00:00 GMT+0800 (中国标准时间) [代码] EXCEL的格式化日期时间 接下来就是在Excel里去拼接出时间属性。第一步先用TEXT函数把EXCEL里的日期格式化为文本 [代码]TEXT(I2, "yyyy-mm-dd""T""hh:mm:ss""+08:00""") [代码] 接着,用CONCAT函数拼出属性: [代码]=CONCAT("""expireTime"": {""$date"": """, TEXT(Y2, "yyyy-mm-dd""T""hh:mm:ss""+08:00"""), """},") [代码] 然后就得到了想要的JSON“属性”了: “expireTime”: {"$date": “2019-09-02T19:47:40+08:00”}, 如何导入嵌套对象 官方文档中有一段特别坑的描述: JSON 数据每个键值对的键名首尾不能是 .,例如 “.a”、“abc.”,且不能包含多个连续的 .,例如 “a…b”; 键名不能重复,且不能有歧义,例如 {“a”: 1, “a”: 2} 或 {“a”: {“b”: 1}, “a.b”: 2}; 给人一种错觉是可以通过键名来处理嵌套对象([代码]"a.b": 2[代码])。然而只是一种错觉,实际上云开发控制台导入工具只是把[代码]a.b[代码]作为了普通的属性名,导入了一个诡异的文档。 导入的文件 [代码]{"b": {"b": 1}, "a.b": 2} [代码] 导入结果 [代码]{ "_id": "cc2411ac5f2cccad", "b": { "b": 1 }, "a.b": 2 } [代码] 实在不理解为什么文档中要举这么一个例子(求解)。不管了,反正结论是老老实实按照标准的JSON去写! 在最前面插入一列,值填充 [代码]{[代码] 嵌套对象的属性,同样插入一列,值填充[代码]"some_property":{[代码] 嵌套对象结束后,插入一列,值填充[代码]}[代码] 结尾插入列,值填充 [代码]}[代码] 所有的属性都拼接完成后,把所有行都复制相同的公式。 最后一步,“导出”成JSON Lines。很简单,选中所有行列,ctrl+c,在notepad或者vs code里面贴进去,并保存为json文件。到这里基本就算大功告成了,剩下的就是去云开发控制台去导入数据了。 校验JSON的合法性 还是单纯了,导入时出错了: invalid character ‘Â’ looking for beginning of value 仔细检查了好几次Excel里的公式也没有看出什么问题。从报错上看,肯定是包含了什么特殊的不可显示字符。把json贴近VS CODE里面格式化一下,看着挺正常的,也没有看到报错。 无奈,去网上找了个json验证工具,BEJSON的JSON验证工具。把json文本贴进去,果然有错误。看了一下报错的位置,是个“空格”。这段是从云开发控制台导出来的呀!不管了,一删了事。再去导入,终于成功了。 结语 纠结了好几天的迁移数据问题,最后还是靠Excel搞定的,哎!不过还是那句话,能抓住老鼠的猫都是好猫,能解决问题的工具都是好工具。把整个的学习过程记录分享给大家做个参考。
2020-08-07 - 小程序云函数和云数据库中的时区必坑笔记
云函数 云函数中默认的时区是UTC +0 参考:注意事项 & FAQ 然而里面有个错误,导致我调试了好久才发现问题 设置云函数时区的两种方式: 在控制台设置: 环境变量 TZ=Asia/Shanghai *注意:TZ大小写敏感,官方文档里写的是错误的! 在代码中设置 process.env.TZ=“Asia/Shanghai” *注意:TZ大小写敏感 云数据库 聚合指令$.dateToString如果不指定时区,默认是UTC +0。所以使用这个指令格式化日期字符串时一定要加上时区属性。 参考:MongoDB参考手册 [代码]$.dateToString({ date: '$closeBookingTime', format: '%Y-%m-%d %H:%M', timezone: 'Asia/Shanghai', }), [代码]
2020-07-16 - 小程序pc端全屏(小程序页面横竖屏)的代码实现
1、在app.json文件中,与“window”同级别的地方增加配置 "resizable": true; 2、在app.json文件中,“window”模块中增加"pageOrientation":"landscape"。 这样配置后,就可以让小程序的页面呈现横屏状态,然后用户只需要点击右上角的全屏按钮就可以全屏了,赶紧去试试吧。 3、如果有的页面不想横屏显示的话,只需要在这个页面下的json文件中加上配置"pageOrientation":"portrait"即可。 这样配置后,只有页面json文件中配置了portrait的才会竖屏显示,其他的就都默认横屏显示了。 4、发现的问题:如果全局window设为了landscape,而某个页面,比如叫A页面中的json文件中单独设置了portrait(竖屏显示),假如你恰好在A页面加了激励式视频广告,那么你就会发现本来事竖屏显示的A页面,在点击观看激励式视频广告后返回来的时候就被强制显示为横屏了。 以上是我在项目中时间pc端全屏和小程序横竖屏显示配置时的总结和发现的问题,希望能给有需要的人带来帮助。
2023-04-25 - 腾讯云 Web 直播互动组件
腾讯云 Web 直播互动组件 简介 腾讯云 Web 直播互动组件,以腾讯云 Web 超级播放器 - TcPlayer 和腾讯云即时通信 IM - TIM 为基础,封装了简单易用的 API,提供了免费开源的 Demo,方便开发者快速接入和使用。适用于 Web 直播互动场景,如大型会议、活动、课程、讲座等的在线直播,带货直播的微信 H5 分享等,效果如下: [图片] [图片] 在线体验 微信扫一扫二维码 [图片] 或者 点我体验 开发背景 前端开发同学经常遇到这样的需求: 项目周期赶,甲方爸爸急着要,或者公司要做推广活动,只给了不到一周的时间(╮(╯▽╰)╭,业界常态) 微信扫一扫、或者用手机浏览器扫一扫就能看直播,并且能跟其他看直播的人聊天互动,也能点赞、送礼(给我做一个虎牙或者斗鱼的那种直播效果出来!) 在 Windows 或 Mac 浏览器上也需要上述的功能(小孩子才做选择,产品经理:我全都要!) 开发同学接到这样的需求,一般会怎么实现呢?对 Web 直播有一定了解的会选择 flv.js 或者 hls.js 来播放直播源。聊天互动用 websocket 快速写一个简单的消息收发 demo。然而写原型 demo 不难,但接下来会遇到到很多挑战: 服务器该怎么布点才能让用户就近接入?遇到蜂拥请求,服务器扛不住并发压力怎么办? 直播活动人数往往较多,全国各地的用户访问,消息通道建立不起来怎么办? 短时间内自研一个 IM Server,如何保证服务高可用? 消息量大,IM Server 推送消息遇到性能问题,导致消息堆积,或者丢消息怎么办? 用户在直播间骂人,发表涉黄、涉政言论怎么办? 如果用第三方 IM 服务,选择谁好呢?万一有坑,反馈问题没人理,导致项目延期怎么办? 第三方 IM 服务往往有一大堆概念和 API,需要花时间熟悉和使用,留给开发业务逻辑的时间太仓促 由此可见,在短时间内如果自己从头开始组装开发,往往是加班加点,赶鸭子上架,开发同学身心俱疲,直播效果也不一定好。现在直播这么火热,难道就没有一个开源的,组合了直播和聊天互动功能的项目,让我稍微改一改就能用起来么? 腾讯云终端研发中心 Web 团队,开发了腾讯云 Web 超级播放器和即时通信 IM SDK(还有其它 SDK 暂且按下不表←_←),面对这样常见的需求和痛点,于是以这两个可靠优秀的产品为基础,开发了开源的腾讯云 Web 直播互动组件,供开发者使用和参考。 对开发者和项目有什么好处? 1、为开发者节省大量重复造轮子的时间,可专注于开发业务逻辑 使用腾讯云 Web 直播互动组件,开发者仅需在下载好 SDK 后简单填入几个参数,即可快速把一个包含直播视频播放、聊天互动、点赞送礼等常见功能的项目跑起来。如下所示: [代码]// npm i tweblive import TWebLive from 'TWebLive'; let options = { SDKAppID: 0, // 接入时需要将0替换为您的云通信应用的 SDKAppID domID: "id_test_video", // 页面上播放器容器 ID,如 <div id="id_test_video" style="width:100%; height:auto;"></div> // 必须同时同时填入 hls 和 flv 流地址 // 在支持 MSE 的浏览器上,直播组件会优先选择使用 flv 直播源,延时更低,直播效果更好 // 在不支持 MSE 的浏览器上,直播组件会使用 hls 直播源,延时稍大,但在移动端适应性好 m3u8: "http://200002949.vod.myqcloud.com/200002949_b6ffc.f0.m3u8", // 请替换成实际可用的播放地址 flv: "http://200002949.vod.myqcloud.com/200002949_b6ffc.f0.flv" // 请替换成实际可用的播放地址 }; // 创建实例 let tweblive = new TWebLive(options); // SDK 进入 ready 状态时触发,接入侧监听此事件,然后可调用 SDK 发送消息等api,使用 SDK 的各项功能 let onIMReady = function() { tweblive.sendTextMessage({ roomID: 'TWebLiveDeveloperHub', // 替换为已加入的直播间 ID text: 'hello from TWebLive' }).then(function(res) { console.log('demo sendTextMessage OK', res); }).catch(function(err) { console.log('demo sendTextMessage failed', err); }); } tweblive.on(TWebLive.EVENT.IM_READY, onIMReady); // 收到直播间其他人发的文本消息 let onTextMessageReceived = function(event) { event.data.forEach(function(message) { // 有昵称则用昵称,无昵称用 userID console.log('demo ' + (message.nick || message.from) + ' 说: ', message.payload.text); }); } tweblive.on(TWebLive.EVENT.TEXT_MESSAGE_RECEIVED, onTextMessageReceived); // 收到直播间其他人发的送礼、点赞等自定义消息 let onCustomMessageReceived = function(event) { event.data.forEach(function(message) { console.log('demo ' + 'data:' + message.payload.data + ' description:' + message.payload.description + ' extension:' + message.payload.extension); }); } tweblive.on(TWebLive.EVENT.CUSTOM_MESSAGE_RECEIVED, onCustomMessageReceived); // 收到其他人加入直播间的通知 let onRemoteUserJoin = function(event) { event.data.forEach(function(message) { // 有昵称则用昵称,无昵称用 userID console.log('demo ' + (message.nick || message.payload.userIDList[0]) + ' 来了'); }); } tweblive.on(TWebLive.EVENT.REMOTE_USER_JOIN, onRemoteUserJoin); // 更多事件请参考:https://imsdk-1252463788.file.myqcloud.com/IM_DOC/Web/module-EVENT.html // 加入直播间,未登录时匿名加入直播间,只能收消息,不能发消息 tweblive.enterRoom(""); // 接入时填要加入的直播间 ID,对应于 IM 系统的直播大群(AVChatRooM)的 groupID [代码] 2、为开发者节省大量定位和解决问题的时间 直播场景观众数量多,消息量大时,自研服务容易出现性能瓶颈,如服务器扛不住并发压力导致请求成功率低、消息堆积、丢消息、消息收发延时严重等难以排查和难以解决的问题。腾讯云 Web 直播互动组件的消息服务集成的是腾讯云即时通信 IM,以 QQ 多年的 IM 能力为基础,保证高并发、高可靠的即时通信能力,且有完善的统计和日志排障系统,遇到问题可快速定位解决。 腾讯云 Web 直播互动组件支持设置消息优先级,如主播发言、观众送礼物等可设置为 高优先级,点赞等不重要的消息可设置为 低优先级,IM 系统会保证高优先级消息的下发(直播间消息量超过40条/秒时 IM 后台会限频)。 3、为项目节省大量的开发和运维成本 腾讯云 Web 直播互动组件是完全免费开源的,其集成的腾讯云 Web 超级播放器是免费的,仅腾讯云即时通信 IM 是增值服务。如果您的项目处于起步阶段,可以使用免费版的 IM 服务。项目发展好,用户量大时,可以购买 IM 旗舰版,价格非常美丽,全行业最低。 腾讯云即时通信 IM 的直播大群(AVChatRoom),群成员人数无上限(这个厉害了,让我叉会腰)。 腾讯云 IM 服务器的节点覆盖面广,可保证用户就近接入,且不用担心服务器扩容问题。 支持针对涉黄、涉政以及不雅词的安全打击,满足安全监管需求。 腾讯云 IM 服务团队响应即时,为您的项目保驾护航。 开发者需要做什么? 1、提供直播源,推荐用 腾讯实时音视频 TRTC 的 旁路推流 为了同时兼容 PC 和移动端,开发者必须同时提供 flv 和 hls 两种格式的直播源,在支持 MSE 的浏览器上,直播组件会优先选择使用 flv 直播源,延时更低,直播效果更好;在不支持 MSE 的浏览器上,直播组件会使用 hls 直播源,延时稍大,但在移动端适应性好。 如果在 Windows 或 Mac 平台推直播流,强烈建议使用 TRTC Electron,旁路推流可同时生成 flv 和 hls 流,跟腾讯云即时通信 IM 完美结合,稳定可靠,服务周到,价格美丽。 跑通 Electron Demo 这篇文档会帮您快速实现直播和旁路推流,效果如下: [图片] 2、注册腾讯云即时通信 IM 应用 在 即时通信 IM 控制台 注册应用,获得 SDKAppID。 生成 UserSig。 用 REST API 向 IM 系统 导入账号。 在 即时通信 IM 控制台 或者用 REST API 创建直播大群(AVChatRoom) 3、在腾讯云 Web 直播互动组件的基础上,开发相关业务逻辑 常见问题 1、进入直播间,其他人看到的提示信息和聊天消息都用的是 [代码]userID[代码],能否支持用昵称(nick)展示? 如果进直播间想要展示昵称,需要先设置昵称(已设置过可忽略此步骤),设置成功后再加入 [代码]// 只有已登录的用户才能修改自己的昵称 tweblive.setMyProfile({ nick: "胡八一" }).then(() => { tweblive.enterRoom(""); // 填要加入的直播间 ID,对应于 IM 系统的直播大群(AVChatRooM)的 groupID }); [代码] 2、组件什么时候会选择播放 flv 流?flv 和 hls 直播源的播放时延分别是多少? 在支持 MSE 的浏览器上,如 PC Chromium 内核浏览器(360极速浏览器,Chrome浏览器等),或者 TBS 模式下(Android 的微信、QQ 浏览器),组件会优先选择播放 flv 流。播放时延对比: 浏览器 播放时延 Windows Chrome 浏览器 3s~5s Mac Chrome 浏览器 3s~5s Mac Safari 浏览器 10s~20s iOS Safari 浏览器 10s~20s iOS 微信 10s~20s Android 微信(TBS) 3s~5s Android QQ 浏览器 3s~5s Android Chrome 浏览器 3s~5s Android 其它浏览器 10s~20s 参考文档 腾讯云 Web 直播互动组件 API 腾讯实时音视频 TRTC TRTC Electron API 腾讯云 Web 超级播放器 TcPlayer 腾讯云即时通信 IM WebIM API
2020-06-24 - 【直播课精彩回顾】变现+留存,一文解答小游戏开发者最关注问题
2020年第一季度微信小游戏通过微信广告变现的广告流量整体提升了165%,这中间你贡献了多少? 眼看着别的小游戏开发者的收入越来越多而自己却“囊中羞涩,有没有很眼馋? 看完以下六点,帮你的钱包鼓起来。 01 官方组件早知道 | 常用官方组件解密 小游戏的官方广告组件包括常见的Banner、插屏广告、激励视频广告,还有最新的格子广告。 [图片] | 官方组件优势 · 低门槛高效接入 小程序DAU超过1000就可以通过代码接入,无需额外接入API和SDK,而且接入流程可以自助完成。 · 丰富多样的广告 可以体现素材的类型、行业,还有规格等。 · 安全有保障 小游戏背后有庞大专业的腾讯广告团队,对行业、产品、素材都有严格的审核把控。 同时,后台也支持开发者直接针对产品、行业及素材等多个广告维度进行屏蔽,大幅度保障开发者的安全。 · 结算透明高效 我们公开了整套分成规则,从上半年开始提供了资金快周转能力,支持半月结算,减轻开发者财务流转压力,而且整个过程透明公开。 官方流量主开发者可以在“流量主服务助手”这个小程序上看到整个结算的流程,包括提现、账单进度等。 02善用游戏场景 | 用户“争着”看广告 激励视频人均观看3次,日均推广新增注册65万,7日ROI达到100+%......一起来看热门游戏"全民养恐龙"是怎么将广告渗透进游戏环节里的? · 将广告渗透进每日任务 可以最大可能的触达全力量用户,将广告与奖励日常结合起来,会得到不错的效果。 · 设置额外增益的随机礼包 如额外获得恐龙等,利用深度用户希望比别人获得更多恐龙的行为,将视频广告渗透进礼包中; · 深度结合玩法,在游戏中设置激励点。 如获得新的恐龙、新的工具等,大大提高了广告覆盖率和触发率。 [图片] · banner组件投放,高效低骚扰 官方的Banner广告组件对用户非常低骚扰但又非常高效。大家不要小看Banner广告组件,"全民养恐龙"利用Banner广告组件,获得了将近20%的收益。 "全民养恐龙"通过基于视频植入的设计,实现单次激励视频人均观看次数达到3次,甚至有段时间人均观看达到了3次以上。 · 将广告植入不同的场景 不区分具体玩法,可以通过通用场景、单局玩法的核心游戏场景、将自己游戏的特色场景如挂机收益开、启宝箱等相结合。 除了跟激励结合的场景之外,还可以找更多元丰富的场景,如结合商店系统、运营活动、平台功能等。 设定固定可观看的广告位置,结合平台功能及任务系统来提高广告观看频次,实现ARPU的拉升。 03划重点 | 零门槛新能力助你灵活变现 针对小游戏内购和广告变现,我们分别内测了首次付费的oCPM,以及广告变现的oCPM。在内测期,以"全民养恐龙"来说,使用广告变现的oCPM,让整体的ROI有了大幅度提升。 这两种模式在本月底或下月初全面启用,大家届时可以使用这两种能力进行投放。 [图片] | 智能投放的优势 · 零门槛 不需要接入API或者SDK,使用oCPM做到智能投放。 · 效率高 首次付费的oCPM,以及广告变现的oCPM,预计将最大程度优化收益效率。 · 超灵活 格子广告组件,从一大片格子广告变成单个格子广告,大幅度提高接入灵活度以及流量利用率。 [图片] 04广告数量要控制 | “3”个以内效果好 从数据来看,用户对于广告的耐受度是"3个"。 核心单流程下大于5个广告点的游戏,如果把广告点的数量压缩到3-5个,其实对留存的提升效果不是特别明显。 但如果把广告点数量压缩到3个以内,会对新进用户的留存有35%的提升。 [图片] 05广告形态有很多,用户激励不可少 广告点数量小于3,且新进次留较高的游戏广告形态都是以激励视频为主。 相比之下,广告点数量大于3个,并且平均新进次留较低的这部分游戏,广告形态是多以Banner和分流界面为主的。 [图片] 06用户体验很重要,简洁易懂少打扰 | 对于活跃用户 可以通过扩展植入激励视频的场景、增加观看频次,优化用户体验这三个角度来增加广告收入。 · 拓展植入场景 开发者可以通过活跃用户的习惯,通过平台的功能、玩家的游戏场景等植入激励视频和Banner,刺激用户点击。 · 定向增加广告频次 活跃用户对游戏内的广告位置有一定的熟悉度,开发者可以适当的在广告位置、平台功能、任务系统,增加广告的频次。 · 优化用户体验 无论是什么类型的广告,开发者需要结合用户游戏体验,通过对用户核心诉求的理解,优化广告植入时机。同时优化广告的展示内容(如避免遮挡游戏区域,避免引导用户误触、减少强弹、与奖励/玩法结合、优化美工等)提升活跃用户的好感度。 | 对于新手用户 · 减少打断新手体验游戏的流程 有些广告植入会破坏新用户游戏体验的连贯性。打开首页广告的环节,可以减少分流界面的弹屏、减少打断核心环节的体验。 · 保持新手引导界面清晰 如果这一类新手引导中植入很多广告去打断,玩家会不理解游戏的玩法,造成新用户的流失。同时也要避免玩家刚进入游戏,集中弹出广告点。广告点可以随着玩家的等级、玩法逐步开放。 · 植入场景与新人运营活动做结合 扩展植入场景,就可以强化新用户观看广告的动力。 · 扩展植入场景,就可以强化新用户观看广告的动力 结合新手专属任务植入,与玩法紧密结合。 · 结合新手专属任务植入,与玩法紧密结合。 开发者可以设置新手免观看体验收益的策略,慢慢培养新手玩家观看广告的习惯。 对于微信小游戏开发者来说,通过将用户的体验、角度和广告插件相结合,能够获取更多的流量收益。 大家还可以到“微信广告助手”的《流量主成长营》课程,了解"如何接入和成为流量主"、"通过审核"、"植入广告"等内容。
2020-05-29 - 如何在小程序中快速实现环形进度条
在小程序开发过程中经常涉及到一些图表类需求,其中环形进度条比较属于比较常见的需求 [图片] [中间的文字部分需要自己实现,因为每个项目不同,本工具只实现进度条] 上图中,一方面我们我们需要实现动态计算弧度的进度条,还需要在进度条上加上渐变效果,如果每次都需要自己手写,那需要很多重复劳动,所以决定为为小程序生态圈贡献一份小小的力量,下面来介绍一下整个工具的实现思路,喜欢的给个star咯 https://github.com/lucaszhu2zgf/mp-progress 环形进度条由灰色底圈+渐变不确定圆弧+双色纽扣组成,首先先把页面结构写好: .canvas{ position: absolute; top: 0; left: 0; width: 400rpx; height: 400rpx; } 因为进度条需要盖在文字上面,所以采用了绝对定位。接下来先把灰色底圈给画上: const context = wx.createContext(); // 打底灰色曲线 context.beginPath(); context.arc(this.convert_length(200), this.convert_length(200), r, 0, 2*Math.PI); context.setLineWidth(12); context.setStrokeStyle('#f0f0f0'); context.stroke(); wx.drawCanvas({ canvasId: 'progress', actions: context.getActions() }); 效果如下: [图片] 接下来就要画绿色的进度条,渐变暂时先不考虑 // 圆弧角度 const deg = ((remain/total).toFixed(2))*2*Math.PI; // 画渐变曲线 context.beginPath(); // 由于外层大小是400,所以圆弧圆心坐标是200,200 context.arc(this.convert_length(200), this.convert_length(200), r, 0, deg); context.setLineWidth(12); context.setStrokeStyle('#56B37F'); context.stroke(); // 辅助函数,用于转换小程序中的rpx convert_length(length) { return Math.round(wx.getSystemInfoSync().windowWidth * length / 750); } [图片] 似乎完成了一大部分,先自测看看不是满圆的情况是啥样子,比如现在剩余车位是120个 [图片] 因为圆弧函数arc默认的起点在3点钟方向,而设计想要的圆弧的起点从12点钟方向开始,现在这样是没法达到预期效果。是不是可以使用css让canvas自己旋转-90deg就好了呢?于是我在上面的canvas样式中新增以下规则: .canvas{ transform: rotate(-90deg); } 但是在真机上并不起作用,于是我把新增的样式放到包裹canvas的外层元素上,发现外层元素已经旋转,可是圆弧还是从3点钟方向开始的,唯一能解释这个现象的是官方说:小程序中的canvas使用的是原生组件,所以这样设置css并不能达到我们想要的效果 [图片] 所以必须要在canvas画图的时候把坐标原点移动到弧形圆心,并且在画布内旋转-90deg [图片] // 更换原点 context.translate(this.convert_length(200), this.convert_length(200)); // arc原点默认为3点钟方向,需要调整到12点 context.rotate(-90 * Math.PI / 180); // 需要注意的是,原点变换之后圆弧arc原点也变成了0,0 真机预览效果达成预期 [图片] 接下来添加环形渐变效果,但是canvas原本提供的渐变类型只有两种: 1、LinearGradient线性渐变 [图片] 2、CircularGradient圆形渐变 [图片] 两种渐变中离设计效果最近的是线性渐变,至于为什么能够形成似乎是随圆形弧度增加而颜色变深的效果也只是控制坐标开始和结束的坐标位置罢了 const grd = context.createLinearGradient(0, 0, 100, 90); grd.addColorStop(0, '#56B37F'); grd.addColorStop(1, '#c0e674'); // 画渐变曲线 context.beginPath(); context.arc(0, 0, r, 0, deg); context.setLineWidth(12); context.setStrokeStyle(grd); context.stroke(); 来看一下真机预览效果: [图片] 非常棒,最后就剩下跟随进度条的纽扣效果了 [图片] 根据三角函数,已知三角形夹角根据公式radian = 2*Math.PI/360*deg,再利用cos和sin函数可以x、y,从而计算出纽扣在各部分半圆的坐标 const mathDeg = ((remain/total).toFixed(2))*360; // 计算弧度 let radian = ''; // 圆圈半径 const r = +this.convert_length(170); // 三角函数cos=y/r,sin=x/r,分别得到小点的x、y坐标 let x = 0; let y = 0; if (mathDeg <= 90) { // 求弧度 radian = 2*Math.PI/360*mathDeg; x = Math.round(Math.cos(radian)*r); y = Math.round(Math.sin(radian)*r); } else if (mathDeg > 90 && mathDeg <= 180) { // 求弧度 radian = 2*Math.PI/360*(180 - mathDeg); x = -Math.round(Math.cos(radian)*r); y = Math.round(Math.sin(radian)*r); } else if (mathDeg > 180 && mathDeg <= 270) { // 求弧度 radian = 2*Math.PI/360*(mathDeg - 180); x = -Math.round(Math.cos(radian)*r); y = -Math.round(Math.sin(radian)*r); } else{ // 求弧度 radian = 2*Math.PI/360*(360 - mathDeg); x = Math.round(Math.cos(radian)*r); y = -Math.round(Math.sin(radian)*r); } [图片] 有了纽扣的圆形坐标,最后一步就是按照设计绘制样式了 // 画纽扣 context.beginPath(); context.arc(x, y, this.convert_length(24), 0, 2 * Math.PI); context.setFillStyle('#ffffff'); context.setShadow(0, 0, this.convert_length(10), 'rgba(86,179,127,0.5)'); context.fill(); // 画绿点 context.beginPath(); context.arc(x, y, this.convert_length(12), 0, 2 * Math.PI); context.setFillStyle('#56B37F'); context.fill(); 来看一下最终效果 [图片] 最后我重新review了整个代码逻辑,并且已经将代码开源到https://github.com/lucaszhu2zgf/mp-progress,欢迎大家使用
2020-05-27 - 干货|揭秘2020年最新的24招小程序运营玩法
小程序面世三年,在电商方面,为企业创造了超过100亿的gmv,而商家们该如何落地布局,抢占小程序电商红利呢?今天,新爷从我们服务过的众多商户中,总结出了24招高流量、高裂变、高转化、高复购的最新运营玩法,给电商企业以借鉴,让你赢在2020! 01 7招花式找流量,拉新获客不用愁 No.1 门店导流 功能简介:通过用户在小程序商城下单时主动选择服务门店,将线上流量导流至门店,构建门店与用户更直接、更紧密的连接关系,拉动实体门店的业绩增长。 推荐理由:零售企业可借助小程序的流量优势为实体门店带去流量,赋能实体门店,带动门店业绩提升,助力企业快速向智慧零售转型升级。 玩法示例:联想集团 联想在智慧零售后台启用门店导流模式后,用户在商家总店加购商品时,有货门店会在列表中展示出来供其选择,总部将不再承接和处理订单。对于消费者来讲,除可以按距离、销量、搜索地址等方式选择服务门店外,还能根据门店所提供的不同商品服务,选择合适门店下单。消费者加购商品选择服务门店后,一旦消费者下单,订单业绩、物流发货以及售后服务将都由门店完成。 No.2 预约到店 功能简介: 商家后台设置预约活动,用户线上登记报名,预约时间和门店,即可线下到店体验或消费,提高消费者体验的同时,带动店铺营收的提升。 推荐理由: 商家或门店发布预约,邀请客户到店,将线上流量引流到门店,助力商家提高运营效率,提升用户体验,促进用户消费。 玩法示例:劲霸男装 中国商务休闲男装行业标志性品牌劲霸男装在其小程序“劲霸云店”上发布到店新款试穿活动,邀请客户到店试穿,还针对SVIP用户专属上门配装服务,为客户提供体验服务、促进消费。 No.3 拼团 功能简介: 拼团指必须邀请指定数量好友一起购买,才可享受优惠或福利的一种营销玩法,是常见的社交电商玩法。因此拼团跟小程序的结合将会把拼团拉新的价值进一步放大。 推荐理由:拼团活动可以为小程序商城做曝光的同时、为新品做预热,提高关注度;价格差异可以刺激用户掏腰包购买、组团裂变,为商户带来新流量。 玩法示例:SAMSTERR森宿 文艺女装品牌森宿为配合夏季新品上架,在小程序商城上开启了“一式两份,cp值蹭蹭暴涨”的2人拼团和“病毒式种草收获多倍快乐”的3人拼团。2人拼团活动中上架了5件商品,价格从79元到249元不等;3人拼团活动则上架了10件商品,价格从49元到119元不等。两个拼团活动在为森宿带来新流量的同时,也带动了商品销量,提高了营收。 No.4 砍价 功能简介:小程序砍价指通过分享,邀请好友帮忙砍价,即可享受优惠的玩法。通过利益刺激用户分享传播,而对于被分享的好友而言,这是一种低成本的举手之劳。 推荐理由:通过参与者的分享,实现快速传播,在增加粉丝的同时,提升店铺销量与品牌知名度。 玩法示例:天扬鲜果 宿迁当地的知名生鲜品牌天扬鲜果,通过低价 1元砍28元的橙子,实现线上曝光5.2万次;吸粉1.2万人 No.5 荐客有礼 功能简介:荐客有礼活动是粉丝推荐会员或小程序访客达到一定数量时,即可获得积分、余额、优惠券等奖励,通过新老顾客推荐拉新,奖励条件可分为最高层奖励、阶梯奖励、单条件奖励。 推荐理由:荐客有礼活动可帮助商户不断裂变拓客,增加粉丝、会员数量,提升销量与品牌知名度。 玩法示例:HOWL口香糖小卖部 互联网女装设计师服装品牌HOWL口香糖小卖部设置了“荐客有礼”活动,每个用户只要推荐来访客和会员,就可以获得不同的奖励,推荐来的数量越多,奖励越好,并将荐客有礼的活动信息发到社群里鼓励大家参与,快速获得社交了流量,上线2个月,已达到 25 万的销量。 No.6 任务卡 功能简介:任务卡允许商户自定义发布任务,用户上传截图,商户审核通过后系统发放奖励,支持多种奖品类型(实物、优惠券、积分、余额),适用于朋友圈集赞、到店打卡、晒单有礼等多种营销场景。 推荐理由:用户完成任务后可邀请好友参加或发送分享海报至朋友圈,使活动传播更迅速,为商户实现品牌宣传。 No.7 小程序+广告 功能简介:无论是小程序广告、公众号文中广告、公众号底部广告或是朋友圈广告,都能够帮助商家通过社交传播触达微信活跃用户,快速获取潜在客户。 推荐理由:小程序作为广告落地页轻量易用、可反复触达客户,有更好的用户体验和更佳的转化效率,助力企业在展示创意的同时打通营销链路,品效合一。 玩法示例:西风阁蟹太太 西风阁蟹太太通过小程序+朋友圈广告打通了从浏览到购买的营销链路,在节日期间用爆款带动传播,有效提升了品牌的影响力,增加了商城销量。 02 解锁九大玩法,疯狂“撩”客促转化 No.8 开屏推广 功能简介:开屏推广是一款定向曝光推广工具,可以达到特定人群进入商城特定页面后,第一时间看到弹窗广告内容(图片或优惠券)的效果,适合商户用做新品上市/会员活动/促销季等信息推广。 推荐理由:推广内容可以对指定人群(会员、标签、客户身份)推送,做到精准锁客;开屏强制展现,效果醒目,提升了打开率。 玩法示例:商家可以通过活动吸引一批新会员,设置了一张内容为无门槛百元优惠券限时抢的开屏广告图,对所有人群推送,并配置链接将进店的客户引导至开卡页面,结合开卡有礼活动,达到快速拉取新会员的目的。 No.9 知识付费 功能简介:“知识付费”功能支持商户将图文、音频、视频以知识商品和专栏的形式放于店内出售,实现内容变现。 推荐理由:支持图文、音频、视频3种商品内容,满足不同商户场景需求;支持商户开启试看,提高购买转化率;支持店铺同时售卖知识付费商品与实物商品,实现多重变现。 玩法示例:一家母婴店商户,除了在店内售卖母婴用品外,它也一直想将母婴知识售卖给更多的宝妈们。“知识付费”功能上线后,它创建了一系列育婴知识专栏,借助店铺的粉丝基础,售卖专栏,同时通过专栏又吸引了一批新的客户,它通过举办各种营销活动,促进新客购买店内母婴用品,实现知识商品与实物商品强强结合,多重变现,从而提高店铺营业额。 No.10 买家秀 功能简介:买家秀是一款店铺内容营销工具,支持用户在店铺下单后发布图文买家秀,商户审核通过后展示到店铺买家秀社区,以用户真实购物体验带动商品销售。 推荐理由:通过买家说服粉丝,有效提升店铺商品转化。 玩法示例:服饰类商户使用买家秀后,买家可将自己的实拍图上传至买家秀社区,商户挑选优质的内容展示在买家秀社区,并将买家秀社区放在首页,粉丝们学习自己喜欢的风格,通过买家秀中关联的商品,跳转到店铺进行购买。 No.11 直播 功能简介:小程序直播更适合随时随地观看购买的场景。商户可以通过手机跟粉丝直播互动,让商户更亲民的进行品牌宣传、产品售卖、客户关系维护,为商户打开视频营销的流量入口。 推荐理由:用户可在小程序上预约直播,在直播过程中商家可推送商品,用户可点击购物袋直接查看并购买,实现实时互动、边看边买;同时,用户还可以对直播进行评论,主播也可根据评论实时互动,如在直播间发放礼盒等,从而增加用户与商户之间的亲近感。 玩法示例:劲霸男装 中国商务休闲男装行业标志性品牌劲霸男装,在云店上开通了直播预约功能,于3月的劲霸大秀上,还通过“劲霸男装云店”小程序进行全程直播,8件[漫威系列惊奇队长]限量预售款商品在直播期间实现“边看边买”。 No.12 导购 功能简介:智慧零售“导购”,以会员招募、导购分润、导购管理、智能客服、大数据分析等亮点功能为门店提供智慧化管理服务,能充分激发导购的积极性,实现业绩的快速增长。 推荐理由:导购客户互绑定,高效管理促转化;1V1专属服务,极致消费者体验;工作标准量化,导购工作更清晰;数据追踪,精准分析提升运营效率。 玩法示例:MUMO木墨 原创家居品牌木墨,在其门店中安排导购以面对面沟通和分享朋友圈的方式,邀请用户通过自己的专属二维码关注木墨小程序。同时,在小程序商城中,设置了导购优惠券,导购通过小程序商家后台将优惠券推送给顾客,引导顾客在线购买与回购。此外,木墨还对导购进行科学化管理,根据导购的引流数量、销售业绩等进行评价,针对导购的销售、服务水平进行阶梯式奖励。运营一个月,导购功能为木墨新增了用户6500+,带来销售额增加30%,连带到店率提高20%。 No.13 优惠码&特权价 功能简介(优惠码):优惠券又可分为代金券、折扣券、兑换券不同形式,通过优惠券的刺激提升店铺的销量和转化。 推荐理由:带动商品销量,同时增加复购率。 玩法示例: 清库存:如商家需要清库存,则可以设置比如“全场满30减10元,同时送2张优惠券”(1张是10元全场通用优惠券,1张是指定商品可用的20元优惠券)的满减送活动。 专属码 + 品宣物流推广:品牌的物料是优惠码最好的宣传途径,发货单里、售后维权提示单、主推活动宣传 DM 页都加上优惠券二维码,也可以引导微信公众号关注后即领优惠金券。 功能简介(特权价):商户可通过此活动投放某一个优惠商品或某一些优惠商品组合,并将特价商品链接分享至某一类人群,如社群、门店客流等,粉丝只能通过二维码或商家链接才能访问和购买特价商品。 推荐理由:实现客群精准营销,提高粉丝特权感,增加品牌粘性,提高商品销量与复购率。 玩法示例: 节日促销:如水果店的“三八女神节”活动,为女粉丝设置特价活动水果组合。 新品传播+粉丝专享:将特权价新品发布到粉丝社群,优先让粉丝体验,有利于粉丝维护,提升粉丝活跃度,也实现了新品的第一层传播裂变。 No.14 周期购 功能简介:周期购可以为商户提供“买家一次付款,商家多次发货”的服务,商铺可灵活制定配送周期、时间以及配送方式,还可以线上统一管理订单、客户、周期性发货,达到提高销量、快速回笼货款的效果。 推荐理由:只需按时配送即可,帮助商家提前锁定客户,提高商品销量。 玩法示例:一宅一花 鲜花商家一宅一花开通了周期购功能,上线了98元、128元、168元三种鲜花包月周期购套餐,用户可自主选择配送门店和首次配送时间。 No.15 N元N件 功能简介:多件商品灵活搭配销售,帮助商户提高店铺销量。 推荐理由:节日促销时可快速提客单及销量;反季清仓时实现清库存及回笼资金。 玩法示例: 1、一家母婴店,在六一儿童节期间,利用节日气氛在店内举办了300元2件活动,设置店内儿童类商品参加,既促活粉丝,又能提高客户单价与店铺销量。 2、一家服装店将店内过季的服饰举办了500元4件的活动,活动结束后,过季的服饰都被清空,减轻库存压力的同时,实现了资金回笼。 No.16 限量抢购&限时折扣 功能简介(限量抢购):限量抢购可以刺激消费,让商家销量快速上涨。 推荐理由:营造出紧张氛围,促进用户消费,提高当天的营收。 玩法示例:百草味 全国休闲食品巨头企业百草味在小程序商城提前限量发布双十一满减券,每天限量发布满199减100元的全场通用券,优惠券只能在双十一当天使用。 更详细的玩法请点击: 功能简介(限时折扣):通过设定高优惠的限时折扣活动吸引粉丝进行快速转化,实现新品推广, 清库存等目的。 推荐理由:限时限量抢购,造成活动的紧张氛围,刺激顾客购买,提高门店的访问量及销售量,拉动销量库存。 玩法示例:SUPERGA 意大利知名服饰品牌SUPERGA,在其小程序商城上设置,原价458元的经典款小白鞋现价只需要398元,小白鞋销量近1w双。 03 裂变风暴来袭,引爆客流增长 No.17 好物圈 功能简介:好物圈被誉为“微信生态中的小红书”,旨在通过用户点赞、推荐、好评等帮助商家获取新流量。 推荐理由:用户可以直接将“好物”分享给好友、微信群,实现圈内“好物”在圈外的裂变传播。 玩法示例:妖精的口袋 知名原创先锋服饰品牌妖精的口袋,在小程序商城中率先接入好物圈,以引导用户加购好物圈送好礼的方式,增加服装的点击和曝光率,获取新的流量。 No.18 社区团购 功能简介:商家在小区或便利店招募团长,团长在群里推广团购产品,用户通过小程序下单,在裂变用户的同时拉动了商品的销量。 推荐理由:利用熟人关系降低商家获客成本;预售模式降低了损耗;消费者可选择物流配送和社区门店自提,在提高用户体验的同时减少配送成本;运营模式轻,易于规模化复制。 玩法示例:田家优鲜 福州生鲜零售平台田家优鲜,将线下门店店主和团长组建了特价团购群,多推社群拼团活动,通过线上拼团活动,引导客户去线下最近门店自提,提高了门店的进客量,带动了门店的日常销售,3个月的时间,团长超200位,团购群超过400个,销售额翻5倍。 No.19 分销 功能简介:分销是一款店铺利用客户推广带来流量与销量的营销工具。商户可通过招募微客吸引用户注册,利用现金、积分、储值余额的奖励方式刺激粉丝进行推广,提高店铺转化率。 推荐理由:在快速裂变获客的同时,佣金模式还可激励用户传播动力的持久性。 玩法示例:木欣欣水果公社 果园种植企业木欣欣水果公社为推广其果园认购模式,在微商城开启了分销活动,分销员分享专属海报给微信好友、朋友圈,对方扫码关注公众号后即可成为分销客户。木欣欣公社的佣金模式促进了分销商积极性,而零门槛则扩大了品牌推广覆盖面。 No.20 礼品卡 功能简介:用户在微商城购买礼品卡后,可以将礼品卡赠送给微信好友,也可以自己兑换使用,主要适用于美妆、珠宝、服饰、箱包、玩具、母婴、数码产品等。 推荐理由:扩大了品牌知名度。 04 精细化会员运营,“圈”住回头客 No.21 签到有礼 功能简介:签到功能的核心规则在于连续签到时间越长,可获取的收益越大,中途中断签到,则连续天数归零重新计算,从而实现用户与商家的持续互动。 推荐理由:签到享好礼,在提升客户活跃度的同时,提高了消费复购率。 No.22会员卡 功能简介:会员卡功能,包括会员卡创建体系、开卡方式、动态会员成长值和会员权益等,旨在帮助商家寻找优质用户,固化会员消费群体,提高店铺复购率。 推荐理由:会员消费升级机制,鼓励粉丝通过不断消费享受会员特权,提升了复购率。 玩法示例:Q'S秸熙 知名女装品牌Q'S秸熙在商城内设置了不同的会员卡样式,鼓励会员消费升级,享受更多特权,会员卡线上线下都可使用,方便第一时间掌握会员的信息,以便日后进行回访、优惠活动精准宣传等操作。 No.23 积分商城 功能简介:积分商城是一款会员使用账户积分兑换商品的营销工具,店铺通过设置具有吸引力的积分商品提高用户活跃度与忠诚度。 推荐理由:提高小程序日活,促进用户二次消费,提高特权感,增加会员粉丝粘性,提升会员复购率。 玩法示例:Hopeshow红袖 创立于1995年的HOPESHOW女装每月定期会员开放日,引导客户到店消费,为线下引流的同时促进开放日当天下单率;会员在生日月收到7折券,此外,Hopeshow红袖还促进会员每日签到,并推动会员完善信息。
2020-04-30 - 小程序中如何实现表情组件
先上效果图(无图无真相) [图片] 1. 第一步准备表情包素材 我这里用的微博的表情包可以点击下面的链接查看具体JSON格式这里不展示 表情包文件weibo-emotions.js 2. 第二步编写表情组件(基于wepy2.0) 如果不会 wepy 可以先去了解下如果你会vue那非常容易上手 首先我们需要把表情包文件weibo-emotions.js中的JSON文件转换成我们需要的格式 [代码]emojis = [ { id: 编号, value: 表情对应的汉字含义 例如:[偷笑], icon: 表情相对图片路径, url: 表情具体图片路径 } ] [代码] 具体转换方法 [代码]function () { const _emojis = {} for (const key in emotions) { if (emotions.hasOwnProperty(key)) { const ele = emotions[key]; for (const item of ele) { _emojis[item.value] = { id: item.id, value: item.value, icon: item.icon.replace('/', '_'), url: weibo_icon_url + item.icon } } } } return _emojis } [代码] 编写组件的html代码 [代码]<template> <div class="emoji" style="height:{{height}}px;" :hidden="hide"> <scroll-view :scroll-y="true" style="height:{{height}}px;"> <div class="icons"> <div class="img" v-for="img in emojis" :key="img.id" @tap.stop="onTap(img.value)"> <img class="icon-image" :src="img.url" :lazy-load="true" /> </div> </div> <div style="height:148rpx;"></div> </scroll-view> <div class="btn-box"> <div class="btn-del" @tap.stop="onDel"> <div class="icon icon-input-del" /> </div> </div> </div> </template> [代码] html代码中的height变量为键盘的高度,通过props传入 编写组件的css代码 [代码].emoji { position: fixed; bottom: 0px; left: 0px; width: 100%; transition: all 0.3s; z-index: 10005; &::after { content: ' '; position: absolute; left: 0; top: 0; right: 0; height: 1px; border-top: 0.4px solid rgba(235, 237, 245, 0.8); color: rgba(235, 237, 245, 0.8); } .icons { display: flex; flex-wrap: wrap; .img { flex-grow: 1; padding: 20rpx; text-align: left; justify-items: flex-start; .icon-image { width: 48rpx; height: 48rpx; } } } scroll-view { background: #f8f8f8; } .btn-box { right: 0rpx; bottom: 0rpx; position: fixed; background: #f8f8f8; padding: 30rpx; .btn-del { background: #ffffff; padding: 20rpx 30rpx; border-radius: 10rpx; .icon { font-size: 48rpx; } } } .icon-loading { height: 100%; display: flex; justify-content: center; align-items: center; } } [代码] 这里是使用less来编写css样式的,flex布局如果你对flex不是很了解可以看看 这篇文章 组件JS代码比较少 [代码]import { weibo_emojis } from '../common/api'; import wepy from '@wepy/core'; wepy.component({ options: { addGlobalClass: true }, props: { height: Number, hide: Boolean }, data: { emojis: weibo_emojis, }, methods: { onTap(val) { this.$emit('emoji', val); }, onDel() { this.$emit('del'); } } }); [代码] 表情组件基本已经编写完成是不是很简单 那么编写好的组件怎么用呢? 其实也很简单 第一步把组件引入到页面 [代码]<config> { "usingComponents": { "emoji-input": "../components/input-emoji", } } </config> [代码] 第二步把组件加入到页面html代码中 [代码]<emoji-input :height="boardheight" @emoji="onInputEmoji" @del="onDelEmoji" :hide="bottom === 0" /> [代码] 第三步编写onInputEmoji,onDelEmoji方法 [代码] /** * 选择表情 */ onInputEmoji(val) { let str = this.content.split(''); str.splice(this.cursor, 0, val); this.content = str.join(''); if (this.cursor === -1) { this.cursor += val.length + 1; } else { this.cursor += val.length; } this.canSend(); }, /** * 删除表情 */ onDelEmoji() { let str = this.content.split(''); const leftStr = this.content.substring(0, this.cursor); const leftLen = leftStr.length; const rightStr = this.content.substring(this.cursor); const left_left_Index = leftStr.lastIndexOf('['); const left_right_Index = leftStr.lastIndexOf(']'); const right_right_Index = rightStr.indexOf(']'); const right_left_Index = rightStr.indexOf('['); if ( left_right_Index === leftLen - 1 && leftLen - left_left_Index <= 8 && left_left_Index > -1 ) { // "111[不简单]|23[33]"left_left_Index=3,left_right_Index=7,leftLen=8 const len = left_right_Index - left_left_Index + 1; str.splice(this.cursor - len, len); this.cursor -= len; } else if ( left_left_Index > -1 && right_right_Index > -1 && left_right_Index < left_left_Index && right_right_Index <= 6 ) { // left_left_Index:4,left_right_Index:3,right_right_Index:1,right_left_Index:2 // "111[666][不简|单]"right_right_Index=1,left_left_Index=3,leftLen=6 let len = right_right_Index + 1 + (leftLen - left_left_Index); if (len <= 10) { str.splice(this.cursor - (leftLen - left_left_Index), len); this.cursor -= leftLen - left_left_Index; } else { str.splice(this.cursor, 1); this.cursor -= 1; } } else { str.splice(this.cursor, 1); this.cursor -= 1; } this.content = str.join(''); }, [代码] 好了基本就完成了一个表情组件的编写和调用 如果你想看完整的代码请点击这里 如果你想体验可以扫下面的二维码自己去体验下 [图片] 下篇 我们写写怎么实现一个简单的富文本编辑器
2020-03-09 - 【笔记】小程序优化过程中遇到几个问题总结
这几天一直在优化小程序遇到几个问题 1、setData大数据了赋值是卡顿 2、swiper滑动 3、缓存利用, 4、数据规整前置处理 5、mysql的数据如何导入云开发的数据库 等项目完结,挨着总结下,先记录在这里, 这几天一直在优化小程序遇到几个问题 相关帖子 堂堂大腾讯,请大幅度优化一下setData的效率吧 https://developers.weixin.qq.com/community/develop/doc/000a06914000c821652836dc756800 微信小程序答题页实现——swiper渲染优化 https://developers.weixin.qq.com/community/develop/article/doc/000ecafb3486f07000c92c3225c013 这几天一直在优化小程序遇到几个问题
2020-03-08 - 云开发,获取群ID——调试出来真的很简单。
1 app.js中 onLaunch: function (options) { if (!wx.cloud) console.error(‘请使用 2.2.3 或以上的基础库以使用云能力’) else wx.cloud.init({ traceUser: true, }) [代码]if (options.shareTicket) wx.getShareInfo({ shareTicket: options.shareTicket, success: function (res) { console.log('getShareTiket---shareTicket-->res', res) //获取cloudID let cID=res.cloudID //调用云函数mytest wx.cloud.callFunction({ name: 'mytest', // 这个 CloudID 值到云函数端会被替换 data: { weRunData: wx.cloud.CloudID(cID) }, success: function (res) { console.log('wx cloud mytest fun res', res); } }) } }) [代码] }, 2 云函数mytest const cloud = require(‘wx-server-sdk’) cloud.init() exports.main = (event, context) => { return { event } } /console.log(‘wx cloud mytest fun res’, res);查看打印出来的res, 真是一个惊喜。 不用npm,不用加密解密,不用传数据到自己开发服务器上。哎,一个群ID花了我好多时间啊,最后到底是迎来柳暗花明了。/
2019-09-24 - 自制图标和引入iconfont
先看效果图: [图片] 以上的UI并不需要美工,程序员自己就能实现。具体步骤如下: 1、找现成的图标去这里:https://icomoon.io/ [图片] 2、点击Generage Font F[图片] 3、下载font文件,下载后解压成icomoon文件夹,找到/fonts/icomoon.ttf,接下来要用到它; 4、生成 @font-face去这里:https://transfonter.org 第一步Add fonts时,选择/fonts/icomoon.ttf 第二步打开Base64开关; 第三步点击转换; 第四步下载生成的文件; [图片] 5、将在icomoon生成的文件style.css和在transfonter生成的文件stylesheet.css中的内容编辑合并,生成小程序中的一个wxss文件: [图片] 6、在其他wxss里引用该icon.wxss文件: @import "./icon.wxss"; 7、在wxml里的用法: <icon class="ico icon-chevron-right" /> 8、结束。
2020-01-06 - 微信小程序UI组件库合集
UI组件库合集,大家有遇到好的组件库,欢迎留言评论然后加入到文档里。 第一款: 官方WeUI组件库,地址 https://developers.weixin.qq.com/miniprogram/dev/extended/weui/ 预览码: [图片] 第二款: ColorUI:地址 https://github.com/weilanwl/ColorUI 预览码: [图片] 第三款: vantUI(又名:ZanUI):地址 https://youzan.github.io/vant-weapp/#/intro 预览码: [图片] 第四款: MinUI: 地址 https://meili.github.io/min/docs/minui/index.html 预览码: [图片] 第五款: iview-weapp:地址 https://weapp.iviewui.com/docs/guide/start 预览码: [图片] 第六款: WXRUI:暂无地址 预览码: [图片] 第七款: WuxUI:地址https://www.wuxui.com/#/introduce 预览码: [图片] 第八款: WussUI:地址 https://phonycode.github.io/wuss-weapp/quickstart.html 预览码: [图片] 第九款: TouchUI:地址 https://github.com/uileader/touchwx 预览码: [图片] 第十款: Hello UniApp: 地址 https://m3w.cn/uniapp 预览码: [图片] 第十一款: TaroUI:地址 https://taro-ui.jd.com/#/docs/introduction 预览码: [图片] 第十二款: Thor UI: 地址 https://thorui.cn/doc/ 预览码: [图片] 第十三款: GUI:https://github.com/Gensp/GUI 预览码: [图片] 第十四款: QyUI:暂无地址 预览码: [图片] 第十五款: WxaUI:暂无地址 预览码: [图片] 第十六款: kaiUI: github地址 https://github.com/Chaunjie/kai-ui 组件库文档:https://chaunjie.github.io/kui/dist/#/start 预览码: [图片] 第十七款: YsUI:暂无地址 预览码: [图片] 第十八款: BeeUI:git地址 http://ued.local.17173.com/gitlab/wxc/beeui.git 预览码: [图片] 第十九款: AntUI: 暂无地址 预览码: [图片] 第二十款: BleuUI:暂无地址 预览码: [图片] 第二十一款: uniydUI:暂无地址 预览码: [图片] 第二十二款: RovingUI:暂无地址 预览码: [图片] 第二十三款: DojayUI:暂无地址 预览码: [图片] 第二十四款: SkyUI:暂无地址 预览码: [图片] 第二十五款: YuUI:暂无地址 预览码: [图片] 第二十六款: wePyUI:暂无地址 预览码: [图片] 第二十七款: WXDUI:暂无地址 预览码: [图片] 第二十八款: XviewUI:暂无地址 预览码: [图片] 第二十九款: MinaUI:暂无地址 预览码: [图片] 第三十款: InyUI:暂无地址 预览码: [图片] 第三十一款: easyUI:地址 https://github.com/qq865738120/easyUI 预览码: [图片] 第三十二款 Kbone-UI: 地址 https://wechat-miniprogram.github.io/kboneui/ui/#/ 暂无预览码 第三十三款 VtuUi: 地址 https://github.com/jisida/VtuWeapp 预览码: [图片] 第三十四款 Lin-UI 地址:http://doc.mini.talelin.com/ 预览码: [图片] 第三十五款 GraceUI 地址: http://grace.hcoder.net/ 这个是收费的哦~ 预览码: [图片] 第三十六款 anna-remax-ui npm:https://www.npmjs.com/package/anna-remax-ui/v/1.0.12 anna-remax-ui 地址: https://annasearl.github.io/anna-remax-ui/components/general/button 预览码 [图片] 第三十七款 Olympus UI 地址:暂无 网易严选出品。 预览码 [图片] 第三十八款 AiYunXiaoUI 地址暂无 预览码 [图片] 第三十九款 visionUI npm:https://www.npmjs.com/package/vision-ui 预览码: [图片] 第四十款 AnimaUI(灵动UI) 地址:https://github.com/AnimaUI/wechat-miniprogram 预览码: [图片] 第四十一款 uView 地址:http://uviewui.com/components/quickstart.html 预览码: [图片] 第四十二款 firstUI 地址:https://www.firstui.cn/ 预览码: [图片]
2023-01-10 - 如何快速在微信小程序中接入微信对话开放平台
如何快速在微信小程序中接入微信对话开放平台 前言 之前我写了一篇《微信对话开放平台初体验》,链接地址如下: https://developers.weixin.qq.com/community/develop/article/doc/000666072c0ad8f876891815b56013?jumpto=comment&commentid=0000243ff409a0797a89feb535b4 相信看过的朋友,通过这篇文章,会对微信对话开放平台有大致的了解,无论是后台的配置项,还是提供的服务能力,都一目了然。这么好的平台,光看不用实属浪费。微信对话开放平台不光是可以接入微信公众号、微信小程序,还可以接入其他网站。虽然官方的指引文档和视频都有,但在实际开发过程中,仍然会遇到一些问题。本文将为你介绍,如何在微信小程序中,快速接入微信对话开放平台。帮你规避会遇到的各种坑,更顺利的完成微信对话开放平台的接入。 准备工作 你需要有一个自己的小程序,没有的话可以注册一个个人主体的,建议注册账号使用单独的QQ邮箱。 接入小程序插件 查看官方文档 首先进入微信对话开放平台官网(https://openai.weixin.qq.com/)。 [图片] 微信对话开放平台点击右上角的【使用文档】按钮,即可跳转到文档中心的【智能对话】版块,如下图所示: [图片] 我们要做的是小程序接入,需要选中对应的选项卡。 [图片] 这里给一个快捷入口,点下面链接就可以了。 https://developers.weixin.qq.com/doc/aispeech/miniprogram/intro.html 只是这里都只是介绍跟示例,具体怎么操作,需要点击这里【快速接入】,才能看到具体步骤。 [图片] 文档里面写了接入的基本步骤,比方说appid配置、怎么注册插件什么的。只是这个介绍写的过于简略,只看这个远远不够。 细心的你,会注意到这里有一个超链接文字。 [图片] 点击这个【申请使用插件】,你会跳转下面这个链接: https://mp.weixin.qq.com/wxopen/plugindevdoc?appid=wx8c631f7e9f2465e1 [图片] 这是插件的详情页面,这里每个步骤写的详细多了。按理说看到这里,就不用我多说,照葫芦画瓢都会玩了吧。 就如龙哥(微信之父)所说的那样——生活是不美好的。 接入过程中,仍然会遇到一些坑,稍不留心,在某个步骤卡住,就进行不下去了。下面我会告诉你这些坑在哪,也希望官方能够及时调整文档,修正这些问题。 可能会遇到的坑 小程序后台添加插件(坑指数:1星) 这个有两种方法: 进入小程序后台【设置-第三方平台授权管理】,点击添加插件,搜索「openaiwidget」即可。 访问插件主页https://mp.weixin.qq.com/wxopen/pluginbasicprofile?action=intro&appid=wx8c631f7e9f2465e1&token=&lang=zh_CN 点击添加,会出现【申请成功】提示,页面刷新后会显示【已添加】。 [图片] 其实上面的操作还好,并没有什么坑。但是,当你添加后,小程序的后台不是实时更新的,比方说这种情况,我用方法2添加后,回到小程序后台,搜索会提示已达到上限,而列表是没有更新的。插件列表不同步的情况时有发生,记得添加后多刷新几次小程序后台页面,不然会以为这个插件没有添加成功。 [图片] 添加插件的时候,如果你的按钮是灰色,说明已经达到插件上限。个人小程序插件上限是5个,其他不清楚,如果你有不需要的插件,可以移考虑掉。 [图片] 我个人觉得5个插件是完全不够用的,像我这样的情况,接入的都还只是官方,如果要在这个基础上加非腾讯系第三方,感觉很难。 [图片] appid配置(坑指数:2星) 这个看起来也很简单,查一下appid,复制粘贴也没啥。 [图片] 可你看下面一个文档配置,发现事情并不简单。除了appid,还有个WechatSI要配置。第一次看我一头雾水,不知道是个啥。而且第一个version要自己查,第二个不是最新版本,要么都自己查,要么都是最新的,不知道这个地方上下表述不一致是个什么操作…… [图片] 我把这个名字,用全球最大的搜索引擎搜了一下,发现这个原来是【微信同声传译小程序插件】。 [图片] 这个插件是为了让对话支持语音转文字功能,这样可以让人机对话的交互方式更加丰富,而配置中没有说明。 查版本好也很简单,以【微信同声传译小程序插件】为例,先进入首页: https://mp.weixin.qq.com/wxopen/plugindevdoc?appid=wx069ba97219f66d99&token=61191740&lang=zh_CN 选择【基本信息】,可以看到更新日志,这里面有最新的版本号。 [图片] 就是appid和版本号这里有点饶,还额外引入了个多的插件。后面的小程序修改app.json,注册组件什么的,相信各位也轻车熟路,文档这部分写的更详细,这里不做赘述。 文档JSON配置书写错误(坑指数:1星) 到了初始化配置,说明你已经搞定了网站的后台配置,可以专注写代码了。可刚写到这里,你发现控制台出现了你最不愿意看到的红色英文字符串。于是你觉得此事必有蹊跷,开始思考报错的原因。代码是从官方那边复制粘贴的,讲道理不应该报错啊。当你再次看这段配置的时候,发现了一个文档上的低级错误——没有加分号。 [图片] 当然这个小问题对你来说是小case了,只是就连这样复制粘贴一把梭,还要卡一下,难免有点不快。希望官方看到可以修正这个文档错误。 没错,我就是微信开放社区的列文虎克(列文虎克是微生物学开拓者)。 页面样式问题(坑指数:3星) 之所以给3颗星,是因为这个样式实在是不好调,可能是因为我有自定义的导航栏,导致我不能直接照搬这个100vh高度的样式。我需要自己写calc,减一个大约100px的高度。 [图片] 其实官方也很贴心,这个高度样式问题官方文档在第8点注意里面也写了,只是光这样是不够的,还是没有彻底解决样式问题。 [图片] 减去顶部导航栏高度后,你还是会看到样式很奇怪,不是下面输入框被挤压,就是上面消息第一条的图片(默认是大幂幂),上面少了半截,如下图所示: 我在这里地方调了很久,发现上面的第3步的配置,里面有很多高度的配置。经过一番研究调试后发现,改动这几处是可以调整页面每个部分的高度。 [图片] guideCardHeight、operateCardHeight、historySize、navHeight这些参数,可以根据需要自定调节,多试几次就知道是怎么回事了。 总结 总体来说,接入还是比较简单的,不需要自己写很多代码,只需要按照文档步骤来,根据实际需要,配置对应的参数,调整下样式基本就OK了。只是接入过程还是会遇到一些小问题,卡在这里也很耽误时间。另外审核这个也比较迷,以前都是最多半天搞定的,这次引入这个插件后,一直显示审核中,因此我也无法将最新版的小程序分享出来给大家体验。 下面放出官方示例,可以直观的体验各个功能模块,感受微信对话开放平台的魅力。 [图片] 彩蛋 我在自己的小程序「EXIF查看器」体验版中,接入了微信对话开放平台插件。我还录了一段20秒左右的演示视频,想看看微信开放社区中,有多少人知道这个对话回复说的是哪个梗。 https://v.qq.com/x/page/y3027wu64bu.html
2019-11-30 - 免费直播课预告:搭建小程序订阅消息系统
小程序·云开发介绍 小程序·云开发实战直播课是由腾讯云云开发和微信极客WeGeek 联合举办的免费课程,旨在为微信小程序开发者提供云到端的一站式解决方案,降低技术门槛、减少研发成本、提升开发效率,以协助开发者快速搭建稳定高质量的微信小程序应用。 基础知识点 1、小程序·云开发介绍 “小程序·云开发”由微信团队和腾讯云联合打造的“应用服务中台”,秉承高效、易用、安全、低成本的服务理念,整合了微信公众平台和腾讯云的核心技术,提供云数据库、云存储、云函数、日志和监控等开发运维能力。通过“小程序·云开发”,开发者可无缝安全调用小程序的开放服务,提升开发效率,快速试错和落地产品。 2、订阅消息介绍 微信官方提供了订阅消息能力,以便实现开发者实现服务的闭环和更优的体验。可以支持在用户自主订阅后,推送消息到用户端(服务通知),用户点击查看详情可跳转至小程序的页面,实现服务的闭环,提高活跃度和用户粘性 准备工作 1、准备小程序账号和开发工具 温馨提示:本次的实战的案例里用到了“订阅课程开课提醒”这个订阅消息模板,需要小程序服务类目里包含 “教育 > 在线教育”,可以在服务类目中加入此类目。后续生产环境中可根据自己的场景选择合适的服务类目和订阅消息模板。 已经申请微信小程序(在服务类目中加入“教育 > 在线教育”),获取小程序 AppID 下载 开发版 微信开发者工具( Nightly Build (Windows 64、Windows 32、macOS)) 在微信公共平台的 "订阅消息"中申请一个开课提醒订阅消息模板,获得 消息模板ID,字段的内容和顺序需要按下图所示: [图片] 2、下载并导入初始项目的源代码 此次课程的项目源代码压缩包可在公众号【腾讯云云开发】回复"订阅"获得。解压源代码压缩包后,可以看到 “第六期课程资料”⽂件夹下有两个⽂件夹,分别为 init(此次活动的实战初始代码)和 intact(完成后的完整代码)。 点击开发者工具工具栏项目-导入项目,项目名称可以任意填写比如“小程序订阅消息系统”,项目路径为之前解压出来的 “第六期课程资料”文件夹里面的 init 文件夹,AppID 使用之前准备好的小程序 AppID。 3、配置项目 打开云开发控制台,在数据库管理页新建⼀个 [代码]messages[代码] 集合。 修改 [代码]pages/index/index[代码] 里面的 lessonTmplId 变量的值为准备好的消息模板ID [图片] 4、了解本次实战的整体流程图 [图片] 搭建步骤 1、小程序前端实现订阅和退订交互 [图片] 打开 [代码]miniprogram/pages/index/index.js[代码],在“@todo 实现订阅逻辑” 下方粘贴如下代码: [代码] // 获取课程信息 const item = e.currentTarget.dataset.item; // 调用微信 API 申请发送订阅消息 wx.requestSubscribeMessage({ // 传入订阅消息的模板id,模板 id 可在小程序管理后台申请 tmplIds: [lessonTmplId], success(res) { // 申请订阅成功 if (res.errMsg === 'requestSubscribeMessage:ok') { // 这里将订阅的课程信息调用云函数存入db wx.cloud .callFunction({ name: 'subscribe', data: { ...item, data: { thing2: {value: item.title}, date5: {value: item.startTimeString}, phrase4: {value: item.teacher}, thing3: {value: item.description}, }, templateId: lessonTmplId, }, }) .then(() => { wx.showToast({ title: '订阅成功', icon: 'success', duration: 2000, }); }) .catch(() => { wx.showToast({ title: '订阅失败', icon: 'success', duration: 2000, }); }); } [代码] [图片] 在 “@todo 实现取消订阅逻辑” 下方粘贴如下代码: [代码] // 获取课程信息 const item = e.currentTarget.dataset.item; // 这里将订阅的课程信息调用云函数存入db wx.cloud .callFunction({ name: 'unsubscribe', data: { id: item.id, templateId: lessonTmplId, }, }) .then(() => { wx.showToast({ title: '取消订阅成功', icon: 'success', duration: 2000, }); }) .catch(() => { wx.showToast({ title: '取消订阅失败', icon: 'success', duration: 2000, }); }); [代码] 实现完这两个方法之后,在真机上面点击订阅的时候,会首先发起订阅消息的授权,成功之后会请求我们的 [代码]subscribe[代码] 云函数,在退订时会请求我们的 [代码]unsubscribe[代码] 云函数。 2、实现订阅消息存储 [图片] 打开 [代码]cloudfunctions/subscribe/index.js[代码], 在 “@todo 将消息内容存储在 [代码]messages[代码] 集合,并做去重” 下方粘贴如下代码: [代码] // 防止重复存储 let message = await db .collection('messages') .where({ id: event.id, touser: OPENID, templateId: event.templateId, }) .get(); if (message.data.length) { return message; } // 在云开发数据库中存储用户订阅的信息 const result = await db.collection('messages').add({ data: { ...event, touser: OPENID, page: 'index', done: false, // 消息发送状态设置为 false }, }); return result; [代码] 在这里我们实现了用户订阅信息存储在 [代码]messages[代码] 集合,并且做到了防止同一门课程重复订阅的问题。 3、实现订阅消息的定时发送 [图片] 打开 [代码]cloudfunctions/send/index.js[代码], 在“@todo 实现定时发送订阅消息逻辑”下方粘贴以下代码: [代码] try { // 从云开数据库中查询等待发送的消息列表 const messages = await db .collection('messages') .where({ done: false, // 课程开始时间前半小时之内 startTime: _.lte(new Date().getTime() + 30 * 60 * 1000), }) .get(); // 循环消息列表 const sendPromises = messages.data.map(async message => { try { // 发送订阅消息 await cloud.openapi.subscribeMessage.send({ touser: message.touser, page: message.page, data: message.data, templateId: message.templateId, }); // 发送成功后将消息的状态改为已发送 return db .collection('messages') .doc(message._id) .update({ data: { done: true, }, }); } catch (e) { return e; } }); return Promise.all(sendPromises); } catch (err) { console.log(err); return err; } [代码] [图片] 打开 [代码]cloudfunctions/send/config.json[代码],在其中加入如下配置: [代码] "triggers": [ { "name": "sendMessagerTimer", "type": "timer", "config": "0 * * * * * *" } ] [代码] 加入这个配置之后,需要使用前面下载的开发版的开发者工具,部署一下函数,将定时触发器发布出去。根据我们的配置,每分钟都运行一次 send 函数,在 send 函数中,我们会将消息集合中满足发送条件的订阅消息通过云调用推送出去。 4、实现订阅消息的退订 [图片] 打开 [代码]cloudfunctions/unsubscribe/index.js[代码], 在 “@todo 删除订阅的消息” 下方粘贴以下代码: [代码] // 删除订阅的消息 const result = await db .collection('messages') .where({ touser: OPENID, templateId: event.templateId, id: event.id, }) .remove(); return result; [代码] 实现了对指定用户对某个课程的订阅,定时触发时不会再给该用户发送消息,实现了退订的功能。 参与直播 搭建过程大致是这样,但还有一些细节没有在文章提及。关于项目的具体实操,我们将在 11月28日(周四)20:00 进行直播演练,欢迎大家扫码进微信群观看,并参与交流。 [图片]
2019-11-20 - 小程序订阅消息开发指南
2019年10月12日微信开放了小程序订阅消息的功能。按官方的说法,目前的模板消息在实现小程序服务闭环上存在缺陷: 1. 部分开发者在用户无预期或未进行服务的情况下发送与用户无关的消息,对用户产生了骚扰;2. 模板消息需在用户访问小程序后的 7 天内下发,不能满足部分业务的时间要求模板消息确实存在上述的硬伤,不利于小程序的用户留存和用户体验。为了解决这些问题,微信官方推出了用户订阅消息功能。我在微慕专业版上加了订阅消息的功能,并验证了这个功能。这个功能是否能都达到官方的预期,这个我感觉不那么乐观。这里我先说我的感受:目前的订阅消息还不完善,后续还有很大的优化空间。 目前,官方只开放了“一次性订阅消息”,尚未开放“长期性订阅消息”,因此我只尝试了“一次性订阅消息”。 一次性订阅消息:用于解决用户使用小程序后,后续服务环节的通知问题。用户自主订阅后,开发者可不限时间地下发一条对应的服务消息;每条消息可单独订阅或退订。 订阅消息推送位置:服务通知 订阅消息下发条件:用户自主订阅 订阅消息卡片跳转能力:点击查看详情可跳转至该小程序的页面 以下我简单说明订阅消息的开发过程和使用体验。 一.订阅消息的开发1.获取订阅消息的模板ID 在微信小程序的管理后台,在左侧“功能”菜单,选择“订阅消息”,然后点击“添加” [图片] 然后选择你需要的消息模板,并配置关键词。 [图片] 配置完成后,如下图所示。 [图片] 值得关注的是,在配置好的模板详情页面里的“详细内容”很重要,这个就是开发订阅消息时需要遵循的消息格式,这个格式和模板消息有细微的差别 根据微慕小程序的需要,我选用了“新的评论提醒”和“内容更新提醒”这两个消息模版。前者用于提醒发表话题或文章的作者,有新的话题或文章评论,增强作者与读者之间的交流互动;后者是提醒订阅用户,小程序有新的文章发布,引导用户回归小程序。 订阅消息申请模板的时候,需要选择所属类目,只能选择当前小程序相关的类目模板,对于模板消息不需要选择对应类目。如果删除小程序类目,就会把订阅消息模板一起删除。因此删除类目要小心谨慎。 [图片] 2.触发用户订阅,获取下发的权限 触发用户订阅,微信小程序提供的api是: [代码]wx.requestSubscribeMessage[代码],用户发生点击行为或者发起支付回调后,才可以调起订阅消息界面。 注意:微信小程序开发工具尚不支持此功能,在开发工具触发订阅的api,会提示: requestSubscribeMessage:fail 开发者工具暂时不支持此 API 调试,请使用真机进行开发 调用api的代码示例如下: [代码]wx.requestSubscribeMessage({[代码] [代码]tmplIds: ["模板A","模板B"],[代码] [代码]success: function (res) {[代码] [代码]//成功[代码] [代码]},[代码] [代码]fail(err) {[代码] [代码]//失败[代码] [代码]console.error(err);[代码] [代码]}[代码] [代码]})[代码] wx.requestSubscribeMessage(Object object) 的回调函数[代码]object.success [代码]参数有两个:errMsg和TEMPLATE_ID; 接口调用成功时errMsg值为’requestSubscribeMessage:ok’。TEMPLATE_ID是动态的键,即模板id,值包括’accept’、’reject’、’ban’。’accept’表示用户同意订阅该条id对应的模板消息,’reject’表示用户拒绝订阅该条id对应的模板消息,’ban’表示已被后台封禁。例如 { errMsg: “requestSubscribeMessage:ok”, zun-LzcQyW-edafCVvzPkK4de2Rllr1fFpw2A_x0oXE: “accept”} 表示用户同意订阅zun-LzcQyW-edafCVvzPkK4de2Rllr1fFpw2A_x0oXE这条消息。 个人觉得这个动态键不是特别合理,代码处理起来有些麻烦,如果改成静态键的json格式比较方便处理,例如: [代码]{[代码] [代码] errMsg:"requestSubscribeMessage:ok",[代码] [代码] result: [[代码] [代码] { templateId:"zun-LzcQyW-edafCVvzPkK4de2Rllr1fFpw2A_x0oXE",[代码] [代码]status:"accept"[代码] [代码]}[代码] [代码] ][代码] [代码]}[代码] 在手机上调用此api方法会调出订阅消息的界面,如下图所示: [图片] 关于这个订阅消息的授权有几点要注意: 1) 在确认提示框里,如果用户选择“取消”表示拒绝(取消)订阅消息,选择“允许”表示用户订阅一次消息。 2) 如果用户不勾选“总是保持以上选择,不再询问”,那么每次用户触发都会弹出提示框。 3) 如果用户勾选“总是保持以上选择,不再询问”,那么将再也不会唤起这个对话框。同时,如果选择“取消”,那么以后每次调用这个api的时候,都会自动拒绝;如果选择“允许”,那么以后每次调用此api,都会自动允许授权。 目前小程序没有提供获取用户是否授权订阅消息的方法。通过wx.openSetting 方法无法获取用户是否授权消息订阅的信息,scope 列表没有订阅消息的内容。 如果想从自动拒绝转换到自动自动运行,需要打开小程序的设置去配置。设置方法:点击小程序右上角的三个点,打开如下对话框 [图片] 然后选择“设置”,在设置项里选择“订阅消息” [图片] [图片] 4)对于同一种消息,用户可以订阅多次,订阅多少次,就会收到多少次订阅消息,这个订阅次数是否有上限,官方没有说明,初步判断是不限的。但是,微信不会提供订阅的次数,因此需要在小程序的后端服务里存储用户订阅的次数。因此,我在微慕小程序专业版里,提供了一个给用户多次订阅的设置,并记录用户订阅的次数。 [图片] 如果用户需要某个消息服务,可以订阅多次,当然也可以在点击“订阅”的对话框里选择“取消”,“取消”一次也就减少一次订阅。 5)对于支付的场景,也需要用户确认是否订阅,这个我觉得不合理,支付后给用户一个订单推送消息应该是刚性需求,不需要再询问一遍用户是否订阅。 2.调用接口下发订阅消息 订阅消息下发的接口是小程序后台服务端调用:subscribeMessage.send,此方法类似下发模板消息的方法,详细调用说明见参考官方的链接: https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/subscribe-message/subscribeMessage.send.html 订阅消息的下发接口方法和模板消息稍有不同, 模板消息的json格式如下 [代码]"data": {[代码] [代码]"keyword1": {[代码] [代码]"value": "内容1",[代码] [代码]"color": "#000"[代码] [代码]},[代码] [代码]"keyword2": {[代码] [代码]"value": "内容2",[代码] [代码]"color": "#000"[代码] [代码]}[代码] [代码]}[代码] 而订阅消息的json格式如下: [代码]"data": {[代码] [代码]"thing1": {[代码] [代码]"value": "内容"[代码] [代码]},[代码] [代码]"number2": {[代码] [代码]"value": 20[代码] [代码]}[代码] 订阅消息的字段key是和数据类型有关,value的参数需要严格按照设置的类型提交,如果不按类型提交,会导致发送失败。同时如果是文本型的内容,字数也有限制,超过限制也会发送失败,但具体字数是多少,官方没有给出,同时中英文混合计算的长度也有差异,据我目前测试25个中文字符是可以的。希望官方能给出具体的字符长度限制的明确数字。 如果调用下发的次数大于用户的订阅次数,调用接口下发订阅消息会返回失败。报如下错误 [图片] 二.订阅消息使用心得1.订阅消息虽然把订阅的授权的交给了用户,但是也增加了用户使用难度,同时,一次性订阅只能收到一次,操作起来比较繁琐,如果不是刚需用户可能会首次就拒绝了这个服务,要想重新获取授权,需要用户自己打开小程序设置里去配置,颇为麻烦,小程序没有提供更简便的方法去唤起。 2.小程序的服务商为了获得更多给用户发送订阅消息的次数,肯定会想方设法去埋点引诱用户去点击订阅,这种诱导估计也是违规。 3.用户使用门槛和学习比较高,比如某个预约的服务,原来的场景是用户只要有提交表单,小程序就可以推送消息给用户,但是现在需要用户主动去订阅,无形中多了一步,如果用户不熟悉订阅消息或者直接点了“取消”,小程序就没法通知到用户了,用户可能因此错失服务,对商家和用户都是损失。 4.微信小程序将采用订阅消息,并逐步取消模板消息,虽然微信官方试图在方便用户和不打扰用户这两种选择里去寻求平衡,但订阅消息目前的模式恐怕无法达到这个期望,至少在我看来,无论对小程序的服务商,还是小程序的用户,都感到不方便。 update:2020年5月18日,日前订阅消息已经支持微信小程序开发工具。
2020-05-18 - 汉光百货
坐拥100万公众号粉丝和30万服务号粉丝的汉光百货,一直以来都在尝试将这块巨大的粉丝蛋糕,转化成直接消费力。 文章+H5商城,是他们想到的第一个办法,但是玩法过于单一,只能在公众号、服务号文章末位嵌入H5线上商城。由于链路过长、场景单一,用户体验非常糟糕。 彼时,汉光百货商务电子部产品总监董有良也正在思考更好的解决方案。凭借着自己在腾讯做产品经理时沉淀的互联网嗅觉,在张小龙提出“应用号”概念时,董有良敏锐地感觉“应用号”可能是个方向。 经过一年多的尝试,汉光百货推出了多款小程序,并借助小程序这个工具,打造了智慧零售模型:不仅优化了线上商城,还打通了线上线下,解决了线下购物场景的一些痛点。 01 单点突破,小步迭代 互联网产品有个基本的原则:“小步快跑,快速迭代”,就是不要想着一次性做出好的产品,而要通过快速迭代的方式进行更新,保证每一小步都跑得很快。 董有良深谙此道,所以,在设计小程序最初,董有良的想法很简单,就是找到一个场景“单点突破”,并通过小步迭代的方式优化产品,让产品接近完美。 但是,在场景设计上,汉光内部却出现了三个版本,他们分别是:会员卡、百货品牌导航、线上商城。 几番评估下来,董有良认为会员卡是最为合适的场景,主要原因在于: 1、会员卡轻量的服务,符合小程序即用即走的理念; 2、微信也在探索,并没有一下子放开很多能力,汉光只能在现有的能力做文章。 此时的汉光百货有几个迫切需要解决的痛点:用户在线上H5购物体验糟糕;而到了线下收银台,却常常要长时间排队。 董有良左思右想,决定用会员卡先消灭收银台长队,一边积累经验,一边观察小程序发展,以便未来更好的布局小程序。 [图片] 除了线下排队的痛点,汉光百货在会员卡创新上也遇到了一些难题:由于会员卡内含有优惠券、积分等信息,如果不加处理,被人截图滥用,这会损害用户和商场的利益,所以汉光百货希望通过定制的加密二维码来解决问题,但是,卡包并没有办法支持定制二维码。 但小程序很好的解决了这个问题。用户出示小程序里的定制二维码,收银员扫码,小程序可以通过接口调用唤起微信支付,用户只要输入密码并可以完成支付。 [图片] 通过会员卡小程序,汉光百货实现了一码支付功能,极大优化了用户体验和收银流程,也缩减了汉光百货的运营成本。 02 小程序+爆款将线上用户引流线下 会员卡是汉光百货使用小程序的初始体验,此后,尝到了甜头的董有良,对小程序的研究从未停止。 董有良观察到,随着微信对小程序能力释放不断加速,小程序摆脱了功能单一、入口很少的局限,摇身一变,既能实现APP操作的丝滑体验,又无需下载、即用即走,庞大的入口数量可以触达大量场景,充分连接了线上线下,时机成熟,汉光百货立刻推出了汉光百货+小程序,这也终结了汉光百货公众号+H5的线上商城时代。 [图片] 线上下单、线下自提,汉光百货+小程序延续了之前线上的特色玩法:每周推出一期闪购活动,通过公众号+小程序的方式推广,用户在小程序下单后,必须到店自提,并不支持邮寄,用户到线下品牌店只要出示二维码,导购打开企业微信的闪购提货应用,扫描用户的二维码,即可进行核销。 除了闪购,汉光百货还推出了微信试用、生日特惠等活动,均采取类似玩法,目的很简单,就是将线上用户引流线下场景。 [图片] 这背后是汉光百货对用户需求的深刻洞察: 1、闪购的品类一定要是爆品,并且要全网最低价格,比其他电商平台还要便宜很多; 2、每个闪购产品必须只能购买一件,给用户造成一种稀缺感,而做到上述两点,就很容易能够把用户从线上引向线下了。 线上商城闪购为什么一定要引导用户线下提货?董有良有自己的考虑:用户到了线下场景,产生连带消费的可能性就大,以汉光为例,此举引流到店的用户,连带率超过40%。 数据显示,小程序上线后,汉光百货的线上收入同比增长70%。经测算,通过闪购等玩法,预计每年将拉动10万以上用户进店,给汉光百货带来4000万元+的增量收入,这些数字未来还会不断上涨。 [图片] 此外,截止目前为止,汉光百货+上线一个月,已积累17万用户,留存率达47.4%。 03 小程序+企业微信完成服务大串联 董有良分享了一个小故事。 在一个暴雨天,商场客流骤减,导购通过汉光百货在企业微信上开发的手机开票应用给有顾客分享商品,顾客选中后,导购通过汉光百货自己开发的手机开票功能开单,顾客收到订单信息并付款后,导购提货并去商场邮件中心,选中订单打印运单,顾客实时收到物流信息提醒。整个流程通畅、合规。 这一天,汉光百货里的一家专柜仅用手机开票功能,就完成了当天80%的业绩。在此前,导购卖货给非店内客人是要通过加微信转账的。这样会存在两个问题: 1、没有商场、品牌背书,导购个人和顾客的交易,给顾客不安全感,成交率低; 2、流程复杂,用户体验差。用户选中商品→截图会员卡信息→微信转账,导购去收银台结账→提货→找快递发货→给用户发物流单号,用户自己查询物信息,步骤繁多。 但现在,用户直接通过手机开票应用完成购物交易,此后商品的物流信息也会及时反馈给用户。 [图片] 除了手机开票应用,汉光百货还通过闪购提货应用、导购对账等各种服务都放在了企业微信上,这样做的好处是,整个服务链条的角色方行为都留在了汉光系统里,便于汉光更好的服务用户,挖掘用户的需求。 [图片] 而这必须有一个前提是:汉光百货需要将品牌方导购直接搬到其企业微信上。 我们都知道,百货公司对品牌的导购并没有管理权,那么如何调动导购积极性呢? 董有良认为这是一个双赢的选择,导购愿意配合取决两个因素: 1、汉光通过闪购等玩法成功将用户引流到了线下; 2、线上商城部分,也给导购带来了订单,所以,导购的配合度高。 小程序矩阵+企业微信,为汉光百货积累了很多数字化经验,为后续智慧升级铺了一条好路,未来,汉光百货将继续围绕购物核心优化服务: 1、加强导购与顾客的链接,在不打扰顾客的前提下,进行加强沟通,增加销售; 2、开发扫码购,让顾客在活动大促等导购很忙的期间,可以自助扫码下单。 扫码体验“汉光百货+”小程序 [图片]
2020-01-17 - 小程序海报生成工具,可视化编辑直接生成代码使用,你的海报你自己做主
开门见山 工具地址 点我直达>>painter-custom-poster 由于挂载在github page上,打开速度会慢一些,请耐心等待或自行解决git网速问题 背景 在做小程序时候,我们经常会有一个需求,需要将小程序分享到朋友圈,但是朋友圈是不允许直接分享小程序,那我们还有其他的办法解决吗?答案肯定是有的,即 canvas 生成个性化海报分享图片到朋友圈 分析 小程序中有大量的生成图片需求,但是使用过 canvas 的人,都会发现一些难以预料的问题>>有关小程序的坑 直接在 canvas 上绘制图形,对于普通开发者来说代码会特别凌乱并且难以维护,经常会花费很久的时间去优化代码 不同的环境渲染问题,例如在开发者工具看起来好好的,一到 Android 真机,就出现图片不显示,位置不对应等等问题 解决 那可不可以开发一款生成海报的插件库呢? 首先,只需要提供一份简单的参数配置文件即可 解决掉小程序Canvas遇到的一些大大小小的坑 有严苛的测试环节,解决各种环境和各种机型遇到的问题,并提供稳定的线上版本 长期维护,并有专人更新迭代更新颖的功能 以上的要求当然是可以的,曾经的我也想尝试开发一款出来,但是后来尝试了几款现成的工具之后就放弃了,毕竟轮子这个东西,是需要不断维护更新的,另外已经有这么多优秀现成的插件了,我为何还要费力去写呢,贡献代码岂不更美哉,以下是我收集的几款 小程序生成图片库,轻松通过 json 方式绘制一张可以发到朋友圈的图片>>Painter 小程序组件-小程序海报组件>>wxa-plugin-canvas 微信小程序:一个 json 帮你完成分享朋友圈图片>>mp_canvas_drawer 我想干什么 唠了这么多,好像提供给大家插件就没我什么事情了…想走是不可能的 为了能够制作出更酷炫的海报,我思考了许久 虽然有了插件后,只需要提供配置代码就能够制作出一款海报来,但是我发现还是有些许问题 制作海报效率还是不够高,微调一个元素的大小和位置,就需要不断的修改保存代码,等待片刻,查看效果,真的烦 一个小小的位置调整可能就需要来回调整无数次,这种最简单的机械化劳动,这辈子是不可能的 拿着完美的稿子,递给设计师看,这个位置不对,这个线太粗,这个颜色太重…你信不信我打死你 对于一些精美复杂的海报,实现起来真的不太现实 那我需要怎么做呢,请点击这个链接体验>>painter-custom-poster 点击左侧例子展示中的任意一个例子,然后导入代码就能看到效果图,这下你应该能猜到了我的想法了 如何实现 刚开始我想用简单的html和css加拖动功能实现,通过简单尝试之后就放弃了,因为这个功能真的太复杂了,简单的工具肯定是不行的 中间这个计划停滞了很长时间,一度已经放弃 直到发现了这个库fabric.js,真的太太优秀了,赞美之词无以言表,唯一的缺点就是中文教程太少,必须生啃英文加谷歌翻译 fabric介绍,你可以很容易地创建任何一个简单的形状,复杂的形状,图像;将它们添加到画布中,并以任何你想要的方式进行修改:位置、尺寸、角度、颜色、笔画、不透明度等 How To Use 目前工具一共分成4部分 例子展示 用来将一些用户设计的精美海报显示出来,通过点击对应的例子并将代码导入画布中 画布区 显示真实的海报效果,画布里添加的元素,都可以直接用鼠标进行拖动,旋转,缩放操作 操作区 第一排四个按钮 复制代码 将画布的展示效果转化成小程序海报插件库所需要的json配置代码,目前我使用的是Painter库,默认会转化成这个插件的配置代码,将代码直接复制到card.js即可 查看代码 这个功能用不用无所谓,可以直观的看到生成的代码 导出json 将画布转化成fabric所需要的json代码,方便将自己设计的海报代码保存下来 导入json 将第3步导出的json代码导入,会在画布上显示已设计的海报样式 第二排五个按钮 画布 画布的属性参数 详解见下方 文字 添加文字的属性参数 详解见下方 矩形 添加矩形的属性参数 详解见下方 图片 添加图片的属性参数 详解见下方 二维码 添加二维码的属性参数 详解见下方 第三排 各种元素的详细设置参数 激活区 激活对象是指鼠标点击画布上的元素,该对象会被蓝色的边框覆盖,此时该对象被激活,可以执行拖动 旋转 缩放等操作 激活区只有对象被激活才会出来,用来设置激活对象的各种配置参数,修改value值后,实时更新当前激活对象的对应状态,点击其他区域,此模块将隐藏 快捷键 ‘←’ 左移一像素 ‘→’ 右移一像素 ‘↑’ 上移一像素 ‘↓’ 下移一像素 ‘ctrl + z’ 撤销 ‘ctrl + y’ 恢复 ‘delete’ 删除 ‘[’ 提高元素的层级 ‘]’ 降低元素的层级 布局属性 通用布局属性 属性 说明 默认 rotate 旋转,按照顺时针旋转的度数 0 width、height view 的宽度和高度 top、left 如 css 中为 absolute 布局时的作用 0 background 背景颜色 rgba(0,0,0,0) borderRadius 边框圆角 0 borderWidth 边框宽 0 borderColor 边框颜色 #000000 shadow 阴影 ‘’ shadow 可以同时修饰 image、rect、text 等 。在修饰 text 时则相当于 text-shadow;修饰 image 和 rect 时相当于 box-shadow 使用方法: [代码]shadow: 'h-shadow v-shadow blur color'; h-shadow: 必需。水平阴影的位置。允许负值。 v-shadow: 必需。垂直阴影的位置。允许负值。 blur: 必需。模糊的距离。 color: 必需。阴影的颜色。 举例: shadow:10 10 5 #888888 [代码] 渐变色支持 你可以在画布的 background 属性中使用以下方式实现 css 3 的渐变色,其中 radial-gradient 渐变的圆心为 中点,半径为最长边,目前不支持自己设置。 [代码]linear-gradient(-135deg, blue 0%, rgba(18, 52, 86, 1) 20%, #987 80%) radial-gradient(rgba(0, 0, 0, 0) 5%, #0ff 15%, #f0f 60%) [代码] !!!注意:颜色后面的百分比一定得写。 画布属性 属性 说明 默认 times 控制生成插件代码的宽度大小,比如画布宽100,times为2,生成的值为200 1 文字属性 属性名称 说明 默认值 text 字体内容 别跟我谈感情,谈感情伤钱 maxLines 最大行数 不限,根据 width 来 lineHeight 行高(上下两行文字baseline的距离) 1.3 fontSize 字体大小 30 color 字体颜色 #000000 fontWeight 字体粗细。仅支持 normal, bold normal textDecoration 文本修饰,支持none underline、 overline、 linethrough none textStyle fill: 填充样式,stroke:镂空样式 fill fontFamily 字体 sans-serif textAlign 文字的对齐方式,分为 left, center, right left 备注: fontFamily,工具中的第一个例子支持文字字体,但是导入小程序为什么看不到呢,小程序官网加载网络字体方法>> 加载字体教程>> 文字高度 是maxLines lineHeight2个字段一起计算出来的 图片属性 属性 说明 默认 url 图片路径 mode 图片裁剪、缩放的模式 aspectFill mode参数详解 scaleToFill 缩放图片到固定的宽高 aspectFill 图片裁剪显示对应的宽高 auto 自动填充 宽度全显示 高度自适应居中显示 Tips(一定要看哦~) 本工具不考虑兼容性,如发现不兼容请使用google浏览器 painter现在只支持这几种图形,所以暂不支持圆,线等 如果编辑过程,一个元素被挡住了,无法操作,请选择对象并通过[ ]快捷键提高降低元素的层级 文字暂不支持直接缩放操作,因为文字大小和元素高度不容易计算,可以通过修改激活栏目maxLines lineHeight fontSize值来动态改变元素 如发现导出的代码一个元素被另一个元素挡住了,请手动调整元素的位置,json数组中元素越往后层级显示就越高,由于painter没有提供层级参数,所以目前只能这样做 本工具导出代码全是以px为单位,为什么不支持rpx, 因为painter在rpx单位下,阴影和边框宽会出现大小计算问题,由于原例子没有提供px生成图片方案,可以下载我这里修改过的demo>>Painter即可解决 文本宽度随着字数不同而动态变化,想在文本后面加个图标根据文本区域长度布局, 请参考Painter文档这块教程直接修改源码 由于本工具开发有些许难度,如出现bug,建议或者使用上的问题,请提issue,源码地址>>painter-custom-poster 海报贡献 如果你设计的海报很好看,并且愿意开源贡献,可以贡献你的海报代码和缩略图,例子代码文件在example中,按顺序排列,例如现在库里例子是example2.js,那你添加example3.js和example3.jpg图片,事例可以参考一下文件夹中源码,然后在index.js中导出一下 导出代码 代码不要格式化,会报错,请原模原样复制到json字段里 生成缩略图 刚开始我想在此工具中直接生成图片,但是由于浏览器图片跨域问题导致报错失败 所以请去小程序中生成保存图片,图片质量设置0.2,并去tinypng压缩一下图片 找到painter.js,替换下边这个方法,可以生成0.2质量的图片,代码如下 [代码] saveImgToLocal() { const that = this; setTimeout(() => { wx.canvasToTempFilePath( { canvasId: 'k-canvas', fileType: 'jpg', quality: 0.2, success: function(res) { that.getImageInfo(res.tempFilePath); }, fail: function(error) { console.error(`canvasToTempFilePath failed, ${JSON.stringify(error)}`); that.triggerEvent('imgErr', { error: error }); } }, this ); }, 300); } [代码] TODO 颜色值选择支持调色板工具 文字padding支持 缩放位置弹跳问题优化 假如需求大的话,支持其他几款插件库代码的生成 ~ 创作不易,如果对你有帮助,请给个星星 star✨✨ 谢谢 ~
2019-09-27 - 10行代码实现微信小程序支付功能,使用小程序云开发实现小程序支付功能(含源码)
前面给大家讲过一个借助小程序云开发实现微信支付的,但是那个操作稍微有点繁琐,并且还会经常出现问题,今天就给大家讲一个简单的,并且借助官方支付api实现小程序支付功能。 传送门 借助小程序云开发实现小程序支付功能 老规矩,先看本节效果图 [图片] 我们实现这个支付功能完全是借助小程序云开发实现的,不用搭建自己的服务器,不用买域名,不用备案域名,不用支持https。只需要一个简单的云函数,就可以轻松的实现微信小程序支付功能。 核心代码就下面这些 [图片] 一,创建一个云开发小程序 关于如何创建云开发小程序,这里我就不再做具体讲解。不知道怎么创建云开发小程序的同学,可以去翻看我之前的文章,或者看下我录制的视频:https://edu.csdn.net/course/play/9604/204528 创建云开发小程序有几点注意的 1,一定不要忘记在app.js里初始化云开发环境。 [图片] 2,创建完云函数后,一定要记得上传 二, 创建支付的云函数 1,创建云函数pay [图片] [图片] 三,引入三方依赖tenpay 我们这里引入三方依赖的目的,是创建我们支付时需要的一些参数。我们安装依赖是使用里npm 而npm必须安装node,关于如何安装node,我这里不做讲解,百度一下,网上一大堆。 1,首先右键pay,然后选择在终端中打开 [图片] 2,我们使用npm来安装这个依赖。 在命令行里执行 npm i tenpay [图片] 安装完成后,我们的pay云函数会多出一个package.json 文件 [图片] 到这里我们的tenpay依赖就安装好了。 四,编写云函数pay [图片] 完整代码如下 [代码]//云开发实现支付 const cloud = require('wx-server-sdk') cloud.init() //1,引入支付的三方依赖 const tenpay = require('tenpay'); //2,配置支付信息 const config = { appid: '你的小程序appid', mchid: '你的微信商户号', partnerKey: '微信支付安全密钥', notify_url: '支付回调网址,这里可以先随意填一个网址', spbill_create_ip: '127.0.0.1' //这里填这个就可以 }; exports.main = async(event, context) => { const wxContext = cloud.getWXContext() let { orderid, money } = event; //3,初始化支付 const api = tenpay.init(config); let result = await api.getPayParams({ out_trade_no: orderid, body: '商品简单描述', total_fee: money, //订单金额(分), openid: wxContext.OPENID //付款用户的openid }); return result; } [代码] 一定要注意把appid,mchid,partnerKey换成你自己的。 到这里我们获取小程序支付所需参数的云函数代码就编写完成了。 不要忘记上传这个云函数。 [图片] 出现下图就代表上传成功 [图片] 五,写一个简单的页面,用来提交订单,调用pay云函数。 [图片] 这个页面很简单, 1,自己随便编写一个订单号(这个订单号要大于6位) 2,自己随便填写一个订单价(单位是分) 3,点击按钮,调用pay云函数。获取支付所需参数。 下图是官方支付api所需要的一些必须参数。 [图片] 下图是我们调用pay云函数获取的参数,和上图所需要的是不是一样。 [图片] 六,调用wx.requestPayment实现支付 下图是官方的示例代码 [图片] 这里不在做具体讲解了,把完整代码给大家贴出来 [代码]// pages/pay/pay.js Page({ //提交订单 formSubmit: function(e) { let that = this; let formData = e.detail.value console.log('form发生了submit事件,携带数据为:', formData) wx.cloud.callFunction({ name: "pay", data: { orderid: "" + formData.orderid, money: formData.money }, success(res) { console.log("提交成功", res.result) that.pay(res.result) }, fail(res) { console.log("提交失败", res) } }) }, //实现小程序支付 pay(payData) { //官方标准的支付方法 wx.requestPayment({ timeStamp: payData.timeStamp, nonceStr: payData.nonceStr, package: payData.package, //统一下单接口返回的 prepay_id 格式如:prepay_id=*** signType: 'MD5', paySign: payData.paySign, //签名 success(res) { console.log("支付成功", res) }, fail(res) { console.log("支付失败", res) }, complete(res) { console.log("支付完成", res) } }) } }) [代码] 到这里,云开发实现小程序支付的功能就完整实现了。 实现效果 1,调起支付键盘 [图片] 2,支付完成 [图片] 3,log日志,可以看出不同支付状态的回调 [图片] 上图是支付成功的回调,我们可以在支付成功回调时,改变订单支付状态。 下图是支付失败的回调, [图片] 下图是支付完成的状态。 [图片] 到这里我们就轻松的实现了微信小程序的支付功能了。是不是很简单啊。 如果感觉图文不是很好理解,我后面会录制视频讲解。 视频讲解 https://edu.csdn.net/course/detail/25701 源码地址: https://github.com/qiushi123/xiaochengxu_demos [图片] 014云开发实现小程序支付,就是我们的源码,如果你导入源码或者学习过程中有任何问题,都可以加我微信2501902696(备注小程序)
2019-08-14 - 适用于小程序的二维码生成器(支持中文,多框架使用)
最近在开发中,需要生成自定义的二维码,于是做了一个包出来,分享给大家一起使用。适用于微信小程序的二维码生成器,基于Canvas生成,支持中文的输入。可在原生小程序,mpvue,taro中使用。(文末有一个使用示例)[图片] github地址(wxmp-qrcode)[https://github.com/Z-HNAN/wxmp-qrcode] 安装 [代码]npm install wxmp-qrcode [代码] 使用 创建一个canvas,设置其[代码]id[代码],与[代码]canvas-id[代码], 并设置canvas的样式,二维码基于其大小生成并居中 [代码]<canvas id="cav-qrcode" canvas-id="cav-qrcode"></canvas> [代码] 引入包并使用 [代码]import QR from 'wxmp-qrcode' QR.draw(str, canvasId, () => { console.log('draw success') } ) [代码] api [代码]/** * 根据canvas尺寸,画出合适居中的qrcode * @param {Object} str 二维码的内容 (必须) * @param {Object} canvasId canvasId的值 (必须) * @param {Object} $this 传入组件的this,兼容在组件中生成二维码 (可选,可省略该参数) * @param {Object} callback 回调函数 (可选) */ draw: function (str, canvasId, $this, callback) /** * 清除canvas内容 * @param {Object} canvasId canvasId (必须) * @param {Object} callback 回调函数 (可选) */ clear: function (canvasId, callback) [代码] 注意 canvas中 id, canvas-id必须保持一致 id 获取canvas节点,自动计算大小使用, 二维码大小基于canvas自动生成 canvas-id 绘制二维码使用 如果在组件中使用,需要传入组件的this,[代码]draw(str, canvasId, componentThis)[代码] 具体参见 https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.createCanvasContext.html 可以保存二维码为临时图片地址 具体可参见 https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.canvasToTempFilePath.html bug: 该方法有时保存的图片会有一个竖条。 [代码]createQrCode: function (content, canvasId) { QR.draw(content, canvasId) this.canvasToTempImage(canvasId) }, //获取临时缓存图片路径 canvasToTempImage: function (canvasId) { wx.canvasToTempFilePath({ canvasId, success: function (res) { let tempFilePath = res.tempFilePath; // 临时图片地址,可在放入图片src中使用 } }) } [代码] 原生小程序wxmp中使用 在项目设置中选择 [代码]使用npm模块[代码] [图片] 如果第一次使用npm模块,需要首先在根目录中[代码]npm init[代码], 之后再安装模块 [代码]npm i wxmp-qrcode[代码] 在工具中选择 [代码]构建npm[代码] [图片] index.wxml [代码]<view class="container"> <canvas id="{{canvasId}}" canvas-id="{{canvasId}}"></canvas> <button bindtap="creatQRCode"> 生成二维码 </button> </view> [代码] index.wxss [代码]canvas { border: 1rpx solid #eee; width: 400rpx; height: 400rpx; } button { margin-top: 100rpx; } [代码] index.js [代码]import QR from './qrcode' Page({ data: { canvasId: 'canvasId', QRdata: '你好 wxmp-qrcode' }, creatQRCode () { let str = this.data.QRdata let canvasId = this.data.canvasId QR.draw(str, canvasId) } }) [代码]
2019-09-01 - 小程序·云开发实战 - 迷你微博
0. 前言 本文将手把手教你如何写出迷你版微博的一行行代码,迷你版微博包含以下功能: Feed 流:关注动态、所有动态 发送图文动态 搜索用户 关注系统 点赞动态 个人主页 使用到的云开发能力: 云数据库 云存储 云函数 云调用 没错,几乎是所有的云开发能力。也就是说,读完这篇实战,你就相当于完全入门了云开发! 咳咳,当然,实际上这里只是介绍核心逻辑和重点代码片段,完整代码建议下载查看。 1. 取得授权 作为一个社交平台,首先要做的肯定是经过用户授权,获取用户信息,小程序提供了很方便的接口: [代码]<button open-type="getUserInfo" bindgetuserinfo="getUserInfo"> 进入小圈圈 </button> [代码] 这个 [代码]button[代码] 有个 [代码]open-type[代码] 属性,这个属性是专门用来使用小程序的开放能力的,而 [代码]getUserInfo[代码] 则表示 获取用户信息,可以从[代码]bindgetuserinfo[代码]回调中获取到用户信息。 于是我们可以在 wxml 里放入这个 [代码]button[代码] 后,在相应的 js 里写如下代码: [代码]Page({ ... getUserInfo: function(e) { wx.navigateTo({ url: "/pages/circle/circle" }) }, ... }) [代码] 这样在成功获取到用户信息后,我们就能跳转到迷你微博页面了。 需要注意,不能使用 [代码]wx.authorize({scope: "scope.userInfo"})[代码] 来获取读取用户信息的权限,因为它不会跳出授权弹窗。目前只能使用上面所述的方式实现。 2. 主页设计 社交平台的主页大同小异,主要由三个部分组成: Feed 流 消息 个人信息 那么很容易就能想到这样的布局(注意新建一个 Page 哦,路径:[代码]pages/circle/circle.wxml[代码]): [代码]<view class="circle-container"> <view style="display:{{currentPage === 'main' ? 'block' : 'none'}}" class="main-area" > </view> <view style="display:{{currentPage === 'msg' ? 'flex' : 'none'}}" class="msg-area" > </view> <view style="display:{{currentPage === 'me' ? 'flex' : 'none'}}" class="me-area" > </view> <view class="footer"> <view class="footer-item"> <button class="footer-btn" bindtap="onPageMainTap" style="background: {{currentPage === 'main' ? '#111' : 'rgba(0,0,0,0)'}}; color: {{currentPage === 'main' ? '#fff' : '#000'}}" > 首页 </button> </view> <view class="footer-item"> <button class="footer-btn" bindtap="onPageMsgTap" style="background: {{currentPage === 'msg' ? '#111' : 'rgba(0,0,0,0)'}}; color: {{currentPage === 'msg' ? '#fff' : '#000'}}" > 消息 </button> </view> <view class="footer-item"> <button class="footer-btn" bindtap="onPageMeTap" style="background: {{currentPage === 'me' ? '#111' : 'rgba(0,0,0,0)'}}; color: {{currentPage === 'me' ? '#fff' : '#000'}}" > 个人 </button> </view> </view> </view> [代码] 很好理解,画面主要被分为上下两个部分:上面的部分是主要内容,下面的部分是三个 Tab 组成的 Footer。重点 WXSS 实现(完整的 WXSS 可以下载源码查看): [代码].footer { box-shadow: 0 0 15rpx #ccc; display: flex; position: fixed; height: 120rpx; bottom: 0; width: 100%; flex-direction: row; justify-content: center; z-index: 100; background: #fff; } .footer-item { display: flex; justify-content: center; align-items: center; height: 100%; width: 33.33%; color: #333; } .footer-item:nth-child(2) { border-left: 3rpx solid #aaa; border-right: 3rpx solid #aaa; flex-grow: 1; } .footer-btn { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; border-radius: 0; font-size: 30rpx; } [代码] 核心逻辑是通过 [代码]position: fixed[代码] 来让 Footer 一直在下方。 读者会发现有一个 [代码]currentPage[代码] 的 data ,这个 data 的作用其实很直观:通过判断它的值是 [代码]main[代码]/[代码]msg[代码]/[代码]me[代码] 中的哪一个来决定主要内容。同时,为了让首次使用的用户知道自己在哪个 Tab,Footer 中相应的 [代码]button[代码] 也会从白底黑字黑底白字,与另外两个 Tab 形成对比。 现在我们来看看 [代码]main[代码] 部分的代码(在上面代码的基础上扩充): [代码]... <view class="main-header" style="display:{{currentPage === 'main' ? 'flex' : 'none'}};max-height:{{mainHeaderMaxHeight}}" > <view class="group-picker-wrapper"> <picker bindchange="bindGroupPickerChange" value="{{groupArrayIndex}}" range="{{groupArray}}" class="group-picker" > <button class="group-picker-inner"> {{groupArray[groupArrayIndex]}} </button> </picker> </view> <view class="search-btn-wrapper"> <button class="search-btn" bindtap="onSearchTap">搜索用户</button> </view> </view> <view class="main-area" style="display:{{currentPage === 'main' ? 'block' : 'none'}};height: {{mainAreaHeight}};margin-top:{{mainAreaMarginTop}}" > <scroll-view scroll-y class="main-area-scroll" bindscroll="onMainPageScroll"> <block wx:for="{{pageMainData}}" wx:for-index="idx" wx:for-item="itemName" wx:key="_id" > <post-item is="post-item" data="{{itemName}}" class="post-item-wrapper" /> </block> <view wx:if="{{pageMainData.length === 0}}" class="item-placeholder" >无数据</view > </scroll-view> <button class="add-poster-btn" bindtap="onAddPosterTap" hover-class="add-poster-btn-hover" style="bottom:{{addPosterBtnBottom}}" > + </button> </view> ... [代码] 这里用到了 列表渲染 和 条件渲染,还不清楚的可以点击进去学习一下。 可以看到,相比之前的代码,我添加一个 header,同时 [代码]main-area[代码] 的内部也新增了一个 [代码]scroll-view[代码](用于展示 Feed 流) 和一个 [代码]button[代码](用于编辑新迷你微博)。header 的功能很简单:左侧区域是一个 [代码]picker[代码],可以选择查看的动态类型(目前有 关注动态 和 所有动态 两种);右侧区域是一个按钮,点击后可以跳转到搜索页面,这两个功能我们先放一下,先继续看 [代码]main-area[代码] 的新增内容。 [代码]main-area[代码] 里的 [代码]scroll-view[代码] 是一个可监听滚动事件的列表,其中监听事件的实现: [代码]data: { ... addPosterBtnBottom: "190rpx", mainHeaderMaxHeight: "80rpx", mainAreaHeight: "calc(100vh - 200rpx)", mainAreaMarginTop: "80rpx", }, onMainPageScroll: function(e) { if (e.detail.deltaY < 0) { this.setData({ addPosterBtnBottom: "-190rpx", mainHeaderMaxHeight: "0", mainAreaHeight: "calc(100vh - 120rpx)", mainAreaMarginTop: "0rpx" }) } else { this.setData({ addPosterBtnBottom: "190rpx", mainHeaderMaxHeight: "80rpx", mainAreaHeight: "calc(100vh - 200rpx)", mainAreaMarginTop: "80rpx" }) } }, ... [代码] 结合 wxml 可以知道,当页面向下滑动 (deltaY < 0) 时,header 和 [代码]button[代码] 会 “突然消失”,反之它们则会 “突然出现”。为了视觉上有更好地过渡,我们可以在 WXSS 中使用 [代码]transition[代码] : [代码]... .main-area { position: relative; flex-grow: 1; overflow: auto; z-index: 1; transition: height 0.3s, margin-top 0.3s; } .main-header { position: fixed; width: 100%; height: 80rpx; background: #fff; top: 0; left: 0; display: flex; justify-content: space-around; align-items: center; z-index: 100; border-bottom: 3rpx solid #aaa; transition: max-height 0.3s; overflow: hidden; } .add-poster-btn { position: fixed; right: 60rpx; box-shadow: 5rpx 5rpx 10rpx #aaa; display: flex; justify-content: center; align-items: center; color: #333; padding-bottom: 10rpx; text-align: center; border-radius: 50%; font-size: 60rpx; width: 100rpx; height: 100rpx; transition: bottom 0.3s; background: #fff; z-index: 1; } ... [代码] 3. Feed 流 3.1 post-item 前面提到,[代码]scroll-view[代码] 的内容是 Feed 流,那么首先就要想到使用 列表渲染。而且,为了方便在个人主页复用,列表渲染中的每一个 item 都要抽象出来。这时就要使用小程序中的 Custom-Component 功能了。 新建一个名为 [代码]post-item[代码] 的 [代码]Component[代码],其中 wxml 的实现(路径:[代码]pages/circle/component/post-item/post-item.js[代码]): [代码]<view class="post-item" hover-class="post-item-hover" bindlongpress="onItemLongTap" bindtap="onItemTap" > <view class="post-title"> <view class="author" hover-class="author-hover" catchtap="onAuthorTap" >{{data.author}}</view > <view class="date">{{data.formatDate}}</view> </view> <view class="msg-wrapper"> <text class="msg">{{data.msg}}</text> </view> <view class="image-outer" wx:if="{{data.photoId !== ''}}" catchtap="onImgTap"> <image-wrapper is="image-wrapper" src="{{data.photoId}}" /> </view> </view> [代码] 可见,一个 [代码]poster-item[代码] 最主要有以下信息: 作者名 发送时间 文本内容 图片内容 其中,图片内容因为是可选的,所以使用了 条件渲染,这会在没有图片信息时不让图片显示区域占用屏幕空间。另外,图片内容主要是由 [代码]image-wrapper[代码] 组成,它也是一个 [代码]Custom-Component[代码],主要功能是: 强制长宽 1:1 裁剪显示图片 点击查看大图 未加载完成时显示 加载中 具体代码这里就不展示了,比较简单,读者可以在 [代码]component/image-wrapper[代码] 里找到。 回过头看 [代码]main-area[代码] 的其他新增部分,细心的读者会发现有这么一句: [代码]<view wx:if="{{pageMainData.length === 0}}" class="item-placeholder" >无数据</view > [代码] 这会在 Feed 流暂时没有获取到数据时给用户一个提示。 3.2 collections: poster、poster_users 展示 Feed 流的部分已经编写完毕,现在就差实际数据了。根据上一小节 [代码]poster-item[代码] 的主要信息,我们可以初步推断出一条迷你微博在 云数据库 的 collection [代码]poster[代码] 里是这样存储的: [代码]{ "username": "Tester", "date": "2019-07-22 12:00:00", "text": "Ceshiwenben", "photo": "xxx" } [代码] 先来看 [代码]username[代码]。由于社交平台一般不会限制用户的昵称,所以如果每条迷你微博都存储昵称,那将来每次用户修改一次昵称,就要遍历数据库把所有迷你微博项都改一遍,相当耗费时间,所以我们不如存储一个 [代码]userId[代码],并另外把 id 和 昵称 的对应关系存在另一个叫 [代码]poster_users[代码] 的 collection 里。 [代码]{ "userId": "xxx", "name": "Tester", ...(其他用户信息) } [代码] [代码]userId[代码] 从哪里拿呢?当然是通过之前已经授权的获取用户信息接口拿到了,详细操作之后会说到。 接下来是 [代码]date[代码],这里最好是服务器时间(因为客户端传过来的时间可能会有误差),而云开发文档里也有提供相应的接口:serverDate。这个数据可以直接被 [代码]new Date()[代码] 使用,可以理解为一个 UTC 时间。 [代码]text[代码] 即文本信息,直接存储即可。 [代码]photo[代码] 则表示附图数据,但是限于小程序 [代码]image[代码] 元素的实现,想要显示一张图片,要么提供该图片的 url,要么提供该图片在 云存储 的 id,所以这里最佳的实践是:先把图片上传到云存储里,然后把回调里的文件 id 作为数据存储。 综上所述,最后 [代码]poster[代码] 每一项的数据结构如下: [代码]{ "authorId": "xxx", "date": "utc-format-date", "text": "Ceshiwenben", "photoId": "yyy" } [代码] 确定数据结构后,我们就可以开始往 collection 添加数据了。但是,在此之前,我们还缺少一个重要步骤。 3.3 用户信息录入 与 云数据库 没错,我们还没有在 [代码]poster_users[代码] 里添加一条新用户的信息。这个步骤一般在 [代码]pages/circle/circle[代码] 页面首次加载时判断即可: [代码]getUserId: function(cb) { let that = this var value = this.data.userId || wx.getStorageSync("userId") if (value) { if (cb) { cb(value) } return value } wx.getSetting({ success(res) { if (res.authSetting["scope.userInfo"]) { wx.getUserInfo({ withCredentials: true, success: function(userData) { wx.setStorageSync("userId", userData.signature) that.setData({ userId: userData.signature }) db.collection("poster_users") .where({ userId: userData.signature }) .get() .then(searchResult => { if (searchResult.data.length === 0) { wx.showToast({ title: "新用户录入中" }) db.collection("poster_users") .add({ data: { userId: userData.signature, date: db.serverDate(), name: userData.userInfo.nickName, gender: userData.userInfo.gender } }) .then(res => { console.log(res) if (res.errMsg === "collection.add:ok") { wx.showToast({ title: "录入完成" }) if (cb) cb() } }) .catch(err => { wx.showToast({ title: "录入失败,请稍后重试", image: "/images/error.png" }) wx.navigateTo({ url: "/pages/index/index" }) }) } else { if (cb) cb() } }) } }) } else { wx.showToast({ title: "登陆失效,请重新授权登陆", image: "/images/error.png" }) wx.navigateTo({ url: "/pages/index/index" }) } } }) } [代码] 代码实现比较复杂,整体思路是这样的: 判断是否已存储了 [代码]userId[代码],如果有直接返回并调用回调函数,如果没有继续 2 通过 [代码]wx.getSetting[代码] 获取当前设置信息 如果返回里有 [代码]res.authSetting["scope.userInfo"][代码] 说明已经授权读取用户信息,继续 3,没有授权的话就跳转回首页重新授权 调用 [代码]wx.getUserInfo[代码] 获取用户信息,成功后提取出 [代码]signature[代码](这是每个微信用户的唯一签名),并调用 [代码]wx.setStorageSync[代码] 将其缓存 调用 [代码]db.collection().where().get()[代码] ,判断返回的数据是否是空数组,如果不是说明该用户已经录入(注意 [代码]where()[代码] 中的筛选条件),如果是说明该用户是新用户,继续 5 提示新用户录入中,同时调用 [代码]db.collection().add()[代码] 来添加用户信息,最后通过回调判断是否录入成功,并提示用户 不知不觉我们就使用了云开发中的 云数据库 功能,紧接着我们就要开始使用 云存储 和 云函数了! 3.4 addPoster 与 云存储 发送新的迷你微博,需要一个编辑新迷你微博的界面,路径我定为 [代码]pages/circle/add-poster/add-poster[代码]: [代码]<view class="app-poster-container"> <view class="body"> <view class="text-area-wrapper"> <textarea bindinput="bindTextInput" placeholder="在此填写" value="{{text}}" auto-focus="true" /> <view class="text-area-footer"> <text>{{remainLen}}/140</text> </view> </view> <view bindtap="onImageTap" class="image-area"> <view class="image-outer"> <image-wrapper is="image-wrapper" src="{{imageSrc}}" placeholder="选择图片上传" /> </view> </view> </view> <view class="footer"> <button class="footer-btn" bindtap="onSendTap">发送</button> </view> </view> [代码] wxml 的代码很好理解:[代码]textarea[代码] 显示编辑文本,[代码]image-wrapper[代码] 显示需要上传的图片,最下面是一个发送的 [代码]button[代码]。其中,图片编辑区域的 [代码]bindtap[代码] 事件实现: [代码]onImageTap: function() { let that = this wx.chooseImage({ count: 1, success: function(res) { const tempFilePaths = res.tempFilePaths that.setData({ imageSrc: tempFilePaths[0] }) } }) } [代码] 直接通过 [代码]wx.chooseImage[代码] 官方 API 获取本地图片的临时路径即可。而当发送按钮点击后,会有如下代码被执行: [代码]onSendTap: function() { if (this.data.text === "" && this.data.imageSrc === "") { wx.showModal({ title: "错误", content: "不能发送空内容", showCancel: false, confirmText: "好的" }) return } const that = this wx.showLoading({ title: "发送中", mask: true }) const imageSrc = this.data.imageSrc if (imageSrc !== "") { const finalPath = imageSrc.replace("//", "/").replace(":", "") wx.cloud .uploadFile({ cloudPath: finalPath, filePath: imageSrc // 文件路径 }) .then(res => { that.sendToDb(res.fileID) }) .catch(error => { that.onSendFail() }) } else { that.sendToDb() } }, sendToDb: function(fileId = "") { const that = this const posterData = { authorId: that.data.userId, msg: that.data.text, photoId: fileId, date: db.serverDate() } db.collection("poster") .add({ data: { ...posterData } }) .then(res => { wx.showToast({ title: "发送成功" }) wx.navigateBack({ delta: 1 }) }) .catch(error => { that.onSendFail() }) .finally(wx.hideLoading()) } [代码] 首先判断文本和图片内容是否都为空,如果是则不执行发送,如果不是继续 2 提示发送中,上传图片到云存储,注意需要将图片中的临时 url 的一些特殊字符组合替换一下,原因见 文件名命名限制 上传成功后,调用 [代码]db.collection().add()[代码],发送成功后退回上一页(即首页),如果失败则执行 [代码]onSendFail[代码] 函数,后者见源码,逻辑较简单这里不赘述 于是,我们就这样创建了第一条迷你微博。接下来就让它在 Feed 流中显示吧! 3.5 云函数 getMainPageData 这个函数的主要作用如前所述,就是通过处理云数据库中的数据,将最终数据返回给客户端,后者将数据可视化给用户。我们先做一个初步版本,因为现在 [代码]poster_users[代码] 中只有一条数据,所以仅先展示自己的迷你微博。[代码]getMainPageData[代码] 云函数代码如下: [代码]// 云函数入口文件 const cloud = require("wx-server-sdk") cloud.init() const db = cloud.database() // 云函数入口函数 exports.main = async (event, context, cb) => { // 通过 event 获取入参 const userId = event.userId let followingResult let users // idNameMap 负责存储 userId 和 name 的映射关系 let idNameMap = {} let followingIds = [] // 获取用户信息 followingResult = await db .collection("poster_users") .where({ userId: userId }) .get() users = followingResult.data followingIds = users.map(u => { return u.userId }) users.map(u => { idNameMap[u.userId] = u.name }) // 获取动态 const postResult = await db .collection("poster") .orderBy("date", "desc") .where({ // 通过高级筛选功能筛选出符合条件的 userId authorId: db.command.in(followingIds) }) .get() const postData = postResult.data // 向返回的数据添加 存储用户昵称的 author 属性、存储格式化后的时间的 formatDate 属性 postData.map(p => { p.author = idNameMap[p.authorId] p.formatDate = new Date(p.date).toLocaleDateString("zh-Hans", options) }) return postData } [代码] 最后在 [代码]pages/circle/circle.js[代码] 里补充云调用: [代码]getMainPageData: function(userId) { const that = this wx.cloud .callFunction({ name: "getMainPageData", data: { userId: userId, isEveryOne: that.data.groupArrayIndex === 0 ? false : true } }) .then(res => { that.setData({ pageMainData: res.result, pageMainLoaded: true }) }) .catch(err => { wx.showToast({ title: "获取动态失败", image: "/images/error.png" }) wx.hideLoading() }) } [代码] 即可展示 Feed 流数据给用户。 之后,[代码]getMainPageData[代码] 还会根据使用场景的不同,新增了查询所有用户动态、查询关注用户动态的功能,但是原理是一样的,看源码可以轻易理解,后续就不再说明。 4. 关注系统 上一节中我们一口气把云开发中的大部分主要功能:云数据库、云存储、云函数、云调用都用了一遍,接下来其他功能的实现也基本都依赖它们。 4.1 poster_user_follows 首先我们需要建一个新的 collection [代码]poster_user_follows[代码],其中的每一项数据的数据结构如下: [代码]{ "followerId": "xxx", "followingId": "xxx" } [代码] 很简单,[代码]followerId[代码] 表示关注人,[代码]followingId[代码] 表示被关注人。 4.2 user-data 页面 关注或者取消关注需要进入他人的个人主页操作,我们在 [代码]pages/circle/user-data/user-data.wxml[代码] 中放一个 [代码]user-info[代码] 的自定义组件,然后新建该组件编辑: [代码]<view class="user-info"> <view class="info-item" hover-class="info-item-hover">用户名: {{userName}}</view> <view class="info-item" hover-class="info-item-hover" bindtap="onPosterCountTap">动态数: {{posterCount}}</view> <view class="info-item" hover-class="info-item-hover" bindtap="onFollowingCountTap">关注数: {{followingCount}}</view> <view class="info-item" hover-class="info-item-hover" bindtap="onFollowerCountTap">粉丝数: {{followerCount}}</view> <view class="info-item" hover-class="info-item-hover" wx:if="{{originId && originId !== '' && originId !== userId}}"><button bindtap="onFollowTap">{{followText}}</button></view> </view> [代码] 这里注意条件渲染的 [代码]button[代码]:如果当前访问个人主页的用户 id (originId) 和 被访问的用户 id (userId)的值是相等的话,这个按钮就不会被渲染(自己不能关注/取消关注自己)。 我们重点看下 [代码]onFollowTap[代码] 的实现: [代码]onFollowTap: function() { const that = this // 判断当前关注状态 if (this.data.isFollow) { wx.showLoading({ title: "操作中", mask: true }) wx.cloud .callFunction({ name: "cancelFollowing", data: { followerId: this.properties.originId, followingId: this.properties.userId } }) .then(res => { wx.showToast({ title: "取消关注成功" }) that.setData({ isFollow: false, followText: "关注" }) }) .catch(error => { wx.showToast({ title: "取消关注失败", image: "/images/error.png" }) }) .finally(wx.hideLoading()) } else if (this.data.isFollow !== undefined) { wx.showLoading({ title: "操作中", mask: true }) const data = { followerId: this.properties.originId, followingId: this.properties.userId } db.collection("poster_user_follows") .add({ data: { ...data } }) .then(res => { wx.showToast({ title: "关注成功" }) that.setData({ isFollow: true, followText: "取消关注" }) }) .catch(error => { wx.showToast({ title: "关注失败", image: "/images/error.png" }) }) .finally(wx.hideLoading()) } } } [代码] 这里读者可能会有疑问:为什么关注的时候直接调用 [代码]db.collection().add()[代码] 即可,而取消关注却要调用云函数呢?这里涉及到云数据库的设计问题:删除多个数据的操作,或者说删除使用 [代码]where[代码] 筛选的数据,只能在服务端执行。如果确实想在客户端删除,则在查询用户关系时,将唯一标识数据的 [代码]_id[代码] 用 [代码]setData[代码] 存下来,之后再使用 [代码]db.collection().doc(_id).delete()[代码] 删除即可。这两种实现方式读者可自行选择。当然,还有一种实现是不实际删除数据,只是加个 [代码]isDelete[代码] 字段标记一下。 查询用户关系的实现很简单,云函数的实现方式如下: [代码]// 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init() const db = cloud.database() // 云函数入口函数 exports.main = async(event, context) => { const followingResult = await db.collection("poster_user_follows") .where({ followingId: event.followingId, followerId: event.followerId }).get() return followingResult } [代码] 客户端只要检查返回的数据长度是否大于 0 即可。 另外附上 [代码]user-data[代码] 页面其他数据的获取云函数实现: [代码]// 云函数入口文件 const cloud = require("wx-server-sdk") cloud.init() const db = cloud.database() async function getPosterCount(userId) { return { value: (await db.collection("poster").where({ authorId: userId }).count()).total, key: "posterCount" } } async function getFollowingCount(userId) { return { value: (await db.collection("poster_user_follows").where({ followerId: userId }).count()).total, key: "followingCount" } } async function getFollowerCount(userId) { return { value: (await db.collection("poster_user_follows").where({ followingId: userId }).count()).total, key: "followerCount" } } async function getUserName(userId) { return { value: (await db.collection("poster_users").where({ userId: userId }).get()).data[0].name, key: "userName" } } // 云函数入口函数 exports.main = async (event, context) => { const userId = event.userId const tasks = [] tasks.push(getPosterCount(userId)) tasks.push(getFollowerCount(userId)) tasks.push(getFollowingCount(userId)) tasks.push(getUserName(userId)) const allData = await Promise.all(tasks) const finalData = {} allData.map(d => { finalData[d.key] = d.value }) return finalData } [代码] 很好理解,客户端获取返回后直接使用即可。 5. 搜索页面 这部分其实很好实现。关键的搜索函数实现如下: [代码]// 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init() const db = cloud.database() const MAX_LIMIT = 100 async function getDbData(dbName, whereObj) { const totalCountsData = await db.collection(dbName).where(whereObj).count() const total = totalCountsData.total const batch = Math.ceil(total / 100) const tasks = [] for (let i = 0; i < batch; i++) { const promise = db .collection(dbName) .where(whereObj) .skip(i * MAX_LIMIT) .limit(MAX_LIMIT) .get() tasks.push(promise) } const rrr = await Promise.all(tasks) if (rrr.length !== 0) { return rrr.reduce((acc, cur) => { return { data: acc.data.concat(cur.data), errMsg: acc.errMsg } }) } else { return { data: [], errMsg: "empty" } } } // 云函数入口函数 exports.main = async (event, context) => { const text = event.text const data = await getDbData("poster_users", { name: { $regex: text } }) return data } [代码] 这里参考了官网所推荐的分页检索数据库数据的实现(因为搜索结果可能有很多),筛选条件则是正则模糊匹配关键字。 搜索页面的源码路径是 [代码]pages/circle/search-user/search-user[代码],实现了点击搜索结果项跳转到对应项的用户的 [代码]user-data[代码] 页面,建议直接阅读源码理解。 6. 其他扩展 6.1 poster_likes 与 点赞 由于转发、评论、点赞的原理基本相同,所以这里只介绍点赞功能如何编写,另外两个功能读者可以自行实现。 毫无疑问我们需要新建一个 collection [代码]poster_likes[代码],其中每一项的数据结构如下: [代码]{ "posterId": "xxx", "likeId": "xxx" } [代码] 这里的 [代码]posterId[代码] 就是 [代码]poster[代码] collection 里每条记录的 [代码]_id[代码] 值,[代码]likeId[代码] 就是 [代码]poster_users[代码] 里的 [代码]userId[代码] 了。 然后我们扩展一下 [代码]poster-item[代码] 的实现: [代码]<view class="post-item" hover-class="post-item-hover" bindlongpress="onItemLongTap" bindtap="onItemTap"> ... <view class="interact-area"> <view class="interact-item"> <button class="interact-btn" catchtap="onLikeTap" style="color:{{liked ? '#55aaff' : '#000'}}">赞 {{likeCount}}</button> </view> </view> </view> [代码] 即,新增一个 [代码]interact-area[代码],其中 [代码]onLikeTap[代码] 实现如下: [代码]onLikeTap: function() { if (!this.properties.originId) return const that = this if (this.data.liked) { wx.showLoading({ title: "操作中", mask: true }) wx.cloud .callFunction({ name: "cancelLiked", data: { posterId: this.properties.data._id, likeId: this.properties.originId } }) .then(res => { wx.showToast({ title: "取消成功" }) that.refreshLike() that.triggerEvent('likeEvent'); }) .catch(error => { wx.showToast({ title: "取消失败", image: "/images/error.png" }) }) .finally(wx.hideLoading()) } else { wx.showLoading({ title: "操作中", mask: true }) db.collection("poster_likes").add({ data: { posterId: this.properties.data._id, likeId: this.properties.originId } }).then(res => { wx.showToast({ title: "已赞" }) that.refreshLike() that.triggerEvent('likeEvent'); }) .catch(error => { wx.showToast({ title: "赞失败", image: "/images/error.png" }) }) .finally(wx.hideLoading()) } } [代码] 细心的读者会发现这和关注功能原理几乎是一样的。 6.2 数据刷新 我们可以使用很多方式让主页面刷新数据: [代码]onShow: function() { wx.showLoading({ title: "加载中", mask: true }) const that = this function cb(userId) { that.refreshMainPageData(userId) that.refreshMePageData(userId) } this.getUserId(cb) } [代码] 第一种是利用 [代码]onShow[代码] 方法:它会在页面每次从后台转到前台展示时调用,这个时候我们就能刷新页面数据(包括 Feed 流和个人信息)。但是这个时候用户信息可能会丢失,所以我们需要在 [代码]getUserId[代码] 里判断,并将刷新数据的函数们整合起来,作为回调函数。 第二种是让用户手动刷新: [代码]onPageMainTap: function() { if (this.data.currentPage === "main") { this.refreshMainPageData() } this.setData({ currentPage: "main" }) } [代码] 如图所示,当目前页面是 Feed 流时,如果再次点击 首页 Tab,就会强制刷新数据。 第三种是关联数据变更触发刷新,比如动态类型选择、删除了一条动态以后触发数据的刷新。这种可以直接看源码学习。 6.3 首次加载等待 当用户第一次进入主页面时,我们如果想在 Feed 流和个人信息都加载好了再允许用户操作,应该如何实现? 如果是类似 Vue 或者 React 的框架,我们很容易就能想到属性监控,如 [代码]watch[代码]、[代码]useEffect[代码] 等等,但是小程序目前 [代码]Page[代码] 并没有提供属性监控功能,怎么办? 除了自己实现,还有一个方法就是利用 [代码]Component[代码] 的 [代码]observers[代码],它和上面提到的属性监控功能差不多。虽然官网文档对其说明比较少,但摸索了一番还是能用来监控的。 首先我们来新建一个 [代码]Component[代码] 叫 [代码]abstract-load[代码],具体实现如下: [代码]// pages/circle/component/abstract-load.js Component({ properties: { pageMainLoaded: { type: Boolean, value: false }, pageMeLoaded: { type: Boolean, value: false } }, observers: { "pageMainLoaded, pageMeLoaded": function (pageMainLoaded, pageMeLoaded) { if (pageMainLoaded && pageMeLoaded) { this.triggerEvent("allLoadEvent") } } } }) [代码] 然后在 [代码]pages/circle/circle.wxml[代码] 中添加一行: [代码]<abstract-load is="abstract-load" pageMainLoaded="{{pageMainLoaded}}" pageMeLoaded="{{pageMeLoaded}}" bind:allLoadEvent="onAllLoad" /> [代码] 最后实现 [代码]onAllLoad[代码] 函数即可。 另外,像这种没有实际展示数据的 [代码]Component[代码],建议在项目中都用 [代码]abstract[代码] 开头来命名。 6.4 scroll-view 在 iOS 的 bug 如果读者使用 iOS 系统调试这个小程序,可能会发现 Feed 流比较短的时候,滚动 [代码]scroll-view[代码] header 和 [代码]button[代码] 会有鬼畜的上下抖动现象,这是因为 iOS 自己实现的 WebView 对于滚动视图有回弹的效果,而该效果也会触发滚动事件。 对于这个 bug,官方人员也表示暂时无法修复,只能先忍一忍了。 6.5 关于消息 Tab 读者可能会疑惑我为什么没有讲解消息 Tab 以及消息提醒的实现。首先是因为源码没有这个实现,其次是我觉得目前云开发所提供的能力实现主动提醒比较麻烦(除了轮询想不到其他办法)。 希望未来云开发可以提供 数据库长连接监控 的功能,这样通过订阅者模式可以很轻松地获取到数据更新的状态,主动提醒也就更容易实现了。到那时我可能会再更新相关源码。 6.6 关于云函数耗时 读者可能会发现我有一个叫 [代码]benchmark[代码] 的云函数,这个函数只是做了个查询数据库的操作,目的在于计算查询耗时。 诡异的是,我前天在调试的时候,发现查询一次需要1秒钟,而写这篇文章时却不到100ms。建议在一些需要多次操作数据库的函数配置里,把超时时间设置长一点吧。目前云函数的性能不太稳定。 7. 结语 那么关于迷你版微博开发实战介绍就到此为止了,更多资料可以直接下载源码查看哦。 源码链接 https://github.com/TencentCloudBase/Good-practice-tutorial-recommended
2019-07-24 - 小程序发送短信验证码完整工具
第一家提供小程序发送短信sdk的平台,支持小程序前端发送、云函数、带路由的云函数,以及60秒倒计时插件。 微信小程序(验证码)版,你完全不用生成、存储、校验 验证码,SDK都帮你去实现了。 微信小程序云函数版, 该版本更安全,避免了他人获取源码后,短信发送账号丢失。 详情查看 : http://smsow.zhenzikj.com/doc/sdk.html 官网: http://smsow.zhenzikj.com [图片] 如何发送短信 [代码]zhenzisms.client.sendCode([代码][代码]function[代码][代码](res){[代码][代码] [代码][代码]console.log(res.data);[代码][代码] [代码][代码]}, number, [代码][代码]'验证码为:{code}'[代码][代码], messageId, seconds, length);[代码]只需要写上{code}这个占位符,sdk自动生成验证码并替换 seconds:验证码有效期,单位是秒, 比如5分钟 length:验证码长度,比如4位或6位 2. 用户填好后如何校验验证码 [代码]var[代码] [代码]result = zhenzisms.client.validateCode(number, code);[代码]参数number:发送短信的手机号码 参数code:用户输入的验证码 返回结果是字符串, ok: 校验成功,empty:未生成验证码, number_error: 手机号码不一致, code_error:验证码不一致, code_expired:验证码已过期
2019-07-15 - 视频教程:小程序中的同步、异步
[视频]
2019-07-05 - json2canvas:使用JSON生成小程序海报
作者:诗人的咸鱼 原文:小程序生成分享海报,一个json就够了。同时支持web Fundebug经授权转载,版权归原作者所有。 需求 在项目里写过几个canvas生成分享海报页面后,觉得这是个重复且冗余的工作.于是就想有没有能通过类似json直接生成海报的库. 然后就在github找到到两个项目: wxa-plugin-canvas,不太喜欢配置文件的写法.就没多去了解 mp_canvas_drawer,使用方式就比较符合直觉,不过可惜功能有点少. 然后就想着能不能自己再造个轮子.于是就有了这个项目 json2canvas,你可以简单的理解为是mp_canvas_drawer的增强版吧. json2canvas canvas绘制海报,写个json就够了. 项目的canvas绘制是基于cax实现的.所以天然的带来一个好处,json2canvas同时支持小程序和web 功能 支持缩放. 如果设计稿是750,而画布只有375时.你不需要任何换算,只需要将scale设置为0.5即可. 支持文本(长文本自动换行,感谢 coolzjy@v2ex 提供的正则 https://regexr.com/4f12l ,优化了换行的计算方式(不会粗暴的折断单词)) 支持图片(圆角) 支持圆型,矩形,矩形圆角 支持分组(cax里很好用的一个功能) 同时支持小程序和web 示例 web-demo 界面左边的json,可以进行编辑,直接看效果哟~ 小程序-demo [代码]git clone https://github.com/willnewii/json2canvas.git 微信开发者工具导入项目 example/weapp/ [代码] 小程序安装 [代码]npm i json2canvas 微信开发者工具->工具->构建npm [代码] 在需要使用的界面引入Component [代码]{ "usingComponents": { "json2canvas":"/miniprogram_npm/json2canvas/index" } } [代码] 效果图 想要生成一个这样的海报,需要怎么做?(红框是图片元素,蓝框是文字元素,其余的是一张背景图。) [图片] 一个json就搞定.具体支持的元素和参数,请查看项目readme [代码]{ "width": 750, "height": 1334, "scale": 0.5, "children": [ { "type": "image", "url": "http://res.mayday5.me/wxapp/wxavatar/tmp/bg_concerts_1.jpg", "width": 750, "height": 1334 }, { "type": "image", "url": "http://res.mayday5.me/wxapp/wxavatar/tmp/wxapp_code.jpg", "width": 100, "x": 48, "y": 44, "isCircular": true, }, { "type": "circle", "r": 50, "lineWidth": 5, "strokeStyle": "#CCCCCC", "x": 48, "y": 44, }, { "type": "text", "text": "歌词本", "font": "30px Arial", "color": "#FFFFFF", "x": 168, "y": 75, "shadow": { "color": "#000", "offsetX": 2, "offsetY": 2, "blur": 2 } }, { "type": "image", "url": "http://res.mayday5.me/wxapp/wxavatar/tmp/medal_concerts_1.png", "width": 300, "x": "center", "y": 361 }, { "type": "text", "text": "一生活一场 五月天", "font": "38px Arial", "color": "#FFFFFF", "x": "center", "y": 838, "shadow": { "color": "#000", "offsetX": 2, "offsetY": 2, "blur": 2 } }, { "type": "text", "text": "北京6场,郑州2场,登船,上班,听到你想听的歌了吗?", "font": "24px Arial", "color": "#FFFFFF", "x": "center", "y": 888, "shadow": { "color": "#000", "offsetX": 2, "offsetY": 2, "blur": 2 } }, { "type": "rect", "width": 750, "height": 193, "fillStyle": "#FFFFFF", "x": 0, "y": "bottom" }, { "type": "image", "url": "http://res.mayday5.me/wxapp/wxavatar/tmp/wxapp_code.jpg", "width": 117, "height": 117, "x": 47, "y": 1180 }, { "type": "text", "text": "长按识别小程序二维码", "font": "26px Arial", "color": "#858687", "x": 192, "y": 1202 }, { "type": "text", "text": "加入五月天 永远不会太迟", "font": "18px Arial", "color": "#A4A5A6", "x": 192, "y": 1249 }] } [代码] 问题反馈 有什么问题可以直接提issue
2019-06-29 - 小程序如何生成海报分享朋友圈
摘要: 小程序开发必备技能啊… 原文:小程序如何生成海报分享朋友圈 作者:小白 Fundebug经授权转载,版权归原作者所有。 项目需求写完有一段时间了,但是还是想回过来总结一下,一是对项目的回顾优化等,二是对坑的地方做个记录,避免以后遇到类似的问题。 需求 利用微信强大的社交能力通过小程序达到裂变的目的,拉取新用户。 生成的海报如下: [图片] 需求分析 1、利用小程序官方提供的api可以直接分享转发到微信群打开小程序 2、利用小程序生成海报保存图片到相册分享到朋友圈,用户长按识别二维码关注公众号或者打开小程序来达到裂变的目的 实现方案 一、分析如何实现 相信大家应该都会有类似的迷惑,就是如何按照产品设计的那样绘制成海报,其实当时我也是不知道如何下手,认真想了下得通过canvas绘制成图片,这样用户保存这个图片到相册,就可以分享到朋友圈了。但是要绘制的图片上面不仅有文字还有数字、图片、二维码等且都是活的,这个要怎么动态生成呢。认真想了下,需要一点一点的将文字和数字,背景图绘制到画布上去,这样通过api最终合成一个图片导出到手机相册中。 二、需要解决的问题 二维码的动态获取和绘制(包括如何生成小程序二维码、公众号二维码、打开网页二维码) 背景图如何绘制,获取图片信息 将绘制完成的图片保存到本地相册 处理用户是否取消授权保存到相册 三、实现步骤 这里我具体写下围绕上面所提出的问题,描述大概实现的过程 ①首先创建canvas画布,我把画布定位设成负的,是为了不让它显示在页面上,是因为我尝试把canvas通过判断条件动态的显示和隐藏,在绘制的时候会出现问题,所以采用了这种方法,这里还有一定要设置画布的大小。 [代码]<canvas canvas-id="myCanvas" style="width: 690px;height:1085px;position: fixed;top: -10000px;"></canvas> [代码] ②创建好画布之后,先绘制背景图,因为背景图我是放在本地,所以获取 <canvas> 组件 canvas-id 属性,通过createCanvasContext创建canvas的绘图上下文 CanvasContext 对象。使用drawImage绘制图像到画布,第一个参数是图片的本地地址,后面两个参数是图像相对画布左上角位置的x轴和y轴,最后两个参数是设置图像的宽高。 [代码]const ctx = wx.createCanvasContext('myCanvas') ctx.drawImage('/img/study/shareimg.png', 0, 0, 690, 1085) [代码] ③创建好背景图后,在背景图上绘制头像,文字和数字。通过getImageInfo获取头像的信息,这里需要注意下在获取的网络图片要先配置download域名才能生效,具体在小程序后台设置里配置。 获取头像地址,首先量取头像在画布中的大小,和x轴Y轴的坐标,这里的result[0]是我用promise封装返回的一个图片地址 [代码]let headImg = new Promise(function (resolve) { wx.getImageInfo({ src: `${app.globalData.baseUrl2}${that.data.currentChildren.headImg}`, success: function (res) { resolve(res.path) }, fail: function (err) { console.log(err) wx.showToast({ title: '网络错误请重试', icon: 'loading' }) } }) }) let avatarurl_width = 60, //绘制的头像宽度 avatarurl_heigth = 60, //绘制的头像高度 avatarurl_x = 28, //绘制的头像在画布上的位置 avatarurl_y = 36; //绘制的头像在画布上的位置 ctx.save(); // 先保存状态 已便于画完圆再用 ctx.beginPath(); //开始绘制 //先画个圆 前两个参数确定了圆心 (x,y) 坐标 第三个参数是圆的半径 四参数是绘图方向 默认是false,即顺时针 ctx.arc(avatarurl_width / 2 + avatarurl_x, avatarurl_heigth / 2 + avatarurl_y, avatarurl_width / 2, 0, Math.PI * 2, false); ctx.clip(); //画了圆 再剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 ctx.drawImage(result[0], avatarurl_x, avatarurl_y, avatarurl_width, avatarurl_heigth); // 推进去图片 [代码] 这里举个例子说下如何绘制文字,比如我要绘制如下这个“字”,需要动态获取前面字数的总宽度,这样才能设置“字”的x轴坐标,这里我本来是想通过measureText来测量字体的宽度,但是在iOS端第一次获取的宽度值不对,关于这个问题,我还在微信开发者社区提了bug,所以我想用另一个方法来实现,就是先获取正常情况下一个字的宽度值,然后乘以总字数就获得了总宽度,亲试是可以的。 [图片] [代码]let allReading = 97 / 6 / app.globalData.ratio * wordNumber.toString().length + 325; ctx.font = 'normal normal 30px sans-serif'; ctx.setFillStyle('#ffffff') ctx.fillText('字', allReading, 150); [代码] ④绘制公众号二维码,和获取头像是一样的,也是先通过接口返回图片网络地址,然后再通过getImageInfo获取公众号二维码图片信息 ⑤如何绘制小程序码,具体官网文档也给出生成无限小程序码接口,通过生成的小程序可以打开任意一个小程序页面,并且二维码永久有效,具体调用哪个小程序二维码接口有不同的应用场景,具体可以看下官方文档怎么说的,也就是说前端通过传递参数调取后端接口返回的小程序码,然后绘制在画布上(和上面写的绘制头像和公众号二维码一样的) [代码]ctx.drawImage('小程序码的本地地址', x轴, Y轴, 宽, 高) [代码] ⑥最终绘制完把canvas画布转成图片并返回图片地址 [代码] wx.canvasToTempFilePath({ canvasId: 'myCanvas', success: function (res) { canvasToTempFilePath = res.tempFilePath // 返回的图片地址保存到一个全局变量里 that.setData({ showShareImg: true }) wx.showToast({ title: '绘制成功', }) }, fail: function () { wx.showToast({ title: '绘制失败', }) }, complete: function () { wx.hideLoading() wx.hideToast() } }) [代码] ⑦保存到系统相册;先判断用户是否开启用户授权相册,处理不同情况下的结果。比如用户如果按照正常逻辑授权是没问题的,但是有的用户如果点击了取消授权该如何处理,如果不处理会出现一定的问题。所以当用户点击取消授权之后,来个弹框提示,当它再次点击的时候,主动跳到设置引导用户去开启授权,从而达到保存到相册分享朋友圈的目的。 [代码]// 获取用户是否开启用户授权相册 if (!openStatus) { wx.openSetting({ success: (result) => { if (result) { if (result.authSetting["scope.writePhotosAlbum"] === true) { openStatus = true; wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } } }, fail: () => { }, complete: () => { } }); } else { wx.getSetting({ success(res) { // 如果没有则获取授权 if (!res.authSetting['scope.writePhotosAlbum']) { wx.authorize({ scope: 'scope.writePhotosAlbum', success() { openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) }, fail() { // 如果用户拒绝过或没有授权,则再次打开授权窗口 openStatus = false console.log('请设置允许访问相册') wx.showToast({ title: '请设置允许访问相册', icon: 'none' }) } }) } else { // 有则直接保存 openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } }, fail(err) { console.log(err) } }) } [代码] 总结 至此所有的步骤都已实现,在绘制的时候会遇到一些异步请求后台返回的数据,所以我用promise和async和await进行了封装,确保导出的图片信息是完整的。在绘制的过程确实遇到一些坑的地方。比如初开始导出的图片比例大小不对,还有用measureText测量文字宽度不对,多次绘制(可能受网络原因)有时导出的图片上的文字颜色会有误差等。如果你也遇到一些比较坑的地方可以一起探讨下做个记录,下面附下完整的代码 [代码]import regeneratorRuntime from '../../utils/runtime.js' // 引入模块 const app = getApp(), api = require('../../service/http.js'); var ctx = null, // 创建canvas对象 canvasToTempFilePath = null, // 保存最终生成的导出的图片地址 openStatus = true; // 声明一个全局变量判断是否授权保存到相册 // 获取微信公众号二维码 getCode: function () { return new Promise(function (resolve, reject) { api.fetch('/wechat/open/getQRCodeNormal', 'GET').then(res => { console.log(res, '获取微信公众号二维码') if (res.code == 200) { console.log(res.content, 'codeUrl') resolve(res.content) } }).catch(err => { console.log(err) }) }) }, // 生成海报 async createCanvasImage() { let that = this; // 点击生成海报数据埋点 that.setData({ generateId: '点击生成海报' }) if (!ctx) { let codeUrl = await that.getCode() wx.showLoading({ title: '绘制中...' }) let code = new Promise(function (resolve) { wx.getImageInfo({ src: codeUrl, success: function (res) { resolve(res.path) }, fail: function (err) { console.log(err) wx.showToast({ title: '网络错误请重试', icon: 'loading' }) } }) }) let headImg = new Promise(function (resolve) { wx.getImageInfo({ src: `${app.globalData.baseUrl2}${that.data.currentChildren.headImg}`, success: function (res) { resolve(res.path) }, fail: function (err) { console.log(err) wx.showToast({ title: '网络错误请重试', icon: 'loading' }) } }) }) Promise.all([headImg, code]).then(function (result) { const ctx = wx.createCanvasContext('myCanvas') console.log(ctx, app.globalData.ratio, 'ctx') let canvasWidthPx = 690 * app.globalData.ratio, canvasHeightPx = 1085 * app.globalData.ratio, avatarurl_width = 60, //绘制的头像宽度 avatarurl_heigth = 60, //绘制的头像高度 avatarurl_x = 28, //绘制的头像在画布上的位置 avatarurl_y = 36, //绘制的头像在画布上的位置 codeurl_width = 80, //绘制的二维码宽度 codeurl_heigth = 80, //绘制的二维码高度 codeurl_x = 588, //绘制的二维码在画布上的位置 codeurl_y = 984, //绘制的二维码在画布上的位置 wordNumber = that.data.wordNumber, // 获取总阅读字数 // nameWidth = ctx.measureText(that.data.wordNumber).width, // 获取总阅读字数的宽度 // allReading = ((nameWidth + 375) - 325) * 2 + 380; // allReading = nameWidth / app.globalData.ratio + 325; allReading = 97 / 6 / app.globalData.ratio * wordNumber.toString().length + 325; console.log(wordNumber, wordNumber.toString().length, allReading, '获取总阅读字数的宽度') ctx.drawImage('/img/study/shareimg.png', 0, 0, 690, 1085) ctx.save(); // 先保存状态 已便于画完圆再用 ctx.beginPath(); //开始绘制 //先画个圆 前两个参数确定了圆心 (x,y) 坐标 第三个参数是圆的半径 四参数是绘图方向 默认是false,即顺时针 ctx.arc(avatarurl_width / 2 + avatarurl_x, avatarurl_heigth / 2 + avatarurl_y, avatarurl_width / 2, 0, Math.PI * 2, false); ctx.clip(); //画了圆 再剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 ctx.drawImage(result[0], avatarurl_x, avatarurl_y, avatarurl_width, avatarurl_heigth); // 推进去图片 ctx.restore(); //恢复之前保存的绘图上下文状态 可以继续绘制 ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.setFontSize(28); // 文字字号 ctx.fillText(that.data.currentChildren.name, 103, 78); // 绘制文字 ctx.font = 'normal bold 44px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText(wordNumber, 325, 153); // 绘制文字 ctx.font = 'normal normal 30px sans-serif'; ctx.setFillStyle('#ffffff') ctx.fillText('字', allReading, 150); ctx.font = 'normal normal 24px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText('打败了全国', 26, 190); // 绘制文字 ctx.font = 'normal normal 24px sans-serif'; ctx.setFillStyle('#faed15'); // 文字颜色 ctx.fillText(that.data.percent, 154, 190); // 绘制孩子百分比 ctx.font = 'normal normal 24px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText('的小朋友', 205, 190); // 绘制孩子百分比 ctx.font = 'normal bold 32px sans-serif'; ctx.setFillStyle('#333333'); // 文字颜色 ctx.fillText(that.data.singIn, 50, 290); // 签到天数 ctx.fillText(that.data.reading, 280, 290); // 阅读时长 ctx.fillText(that.data.reading, 508, 290); // 听书时长 // 书籍阅读结构 ctx.font = 'normal normal 28px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText(that.data.bookInfo[0].count, 260, 510); ctx.fillText(that.data.bookInfo[1].count, 420, 532); ctx.fillText(that.data.bookInfo[2].count, 520, 594); ctx.fillText(that.data.bookInfo[3].count, 515, 710); ctx.fillText(that.data.bookInfo[4].count, 492, 828); ctx.fillText(that.data.bookInfo[5].count, 348, 858); ctx.fillText(that.data.bookInfo[6].count, 212, 828); ctx.fillText(that.data.bookInfo[7].count, 148, 726); ctx.fillText(that.data.bookInfo[8].count, 158, 600); ctx.font = 'normal normal 18px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText(that.data.bookInfo[0].name, 232, 530); ctx.fillText(that.data.bookInfo[1].name, 394, 552); ctx.fillText(that.data.bookInfo[2].name, 496, 614); ctx.fillText(that.data.bookInfo[3].name, 490, 730); ctx.fillText(that.data.bookInfo[4].name, 466, 850); ctx.fillText(that.data.bookInfo[5].name, 323, 878); ctx.fillText(that.data.bookInfo[6].name, 184, 850); ctx.fillText(that.data.bookInfo[7].name, 117, 746); ctx.fillText(that.data.bookInfo[8].name, 130, 621); ctx.drawImage(result[1], codeurl_x, codeurl_y, codeurl_width, codeurl_heigth); // 绘制头像 ctx.draw(false, function () { // canvas画布转成图片并返回图片地址 wx.canvasToTempFilePath({ canvasId: 'myCanvas', success: function (res) { canvasToTempFilePath = res.tempFilePath that.setData({ showShareImg: true }) console.log(res.tempFilePath, 'canvasToTempFilePath') wx.showToast({ title: '绘制成功', }) }, fail: function () { wx.showToast({ title: '绘制失败', }) }, complete: function () { wx.hideLoading() wx.hideToast() } }) }) }) } }, // 保存到系统相册 saveShareImg: function () { let that = this; // 数据埋点点击保存学情海报 that.setData({ saveId: '保存学情海报' }) // 获取用户是否开启用户授权相册 if (!openStatus) { wx.openSetting({ success: (result) => { if (result) { if (result.authSetting["scope.writePhotosAlbum"] === true) { openStatus = true; wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } } }, fail: () => { }, complete: () => { } }); } else { wx.getSetting({ success(res) { // 如果没有则获取授权 if (!res.authSetting['scope.writePhotosAlbum']) { wx.authorize({ scope: 'scope.writePhotosAlbum', success() { openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) }, fail() { // 如果用户拒绝过或没有授权,则再次打开授权窗口 openStatus = false console.log('请设置允许访问相册') wx.showToast({ title: '请设置允许访问相册', icon: 'none' }) } }) } else { // 有则直接保存 openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } }, fail(err) { console.log(err) } }) } }, [代码]
2019-06-15 - js异步编程
前言 我们都知道,JS是单线程执行的,天生异步。在开发的过程中会遇到很多异步的场景,只用回调来处理简单的异步逻辑,当然是可以,但是逻辑逐渐复杂起来,回调的处理方式显得力不从心。 接下来会介绍js中处理异步的方式,通过对比了解各自的原理以及优缺点,帮助我们更好的使用这些强大的异步处理方式。 回调 基本用法 回调函数作为参数传进方法中,在合适的时机被调用。 比如调用ajax,或是使用定时器: [代码] // ajax请求 $.ajax({ url: '/ajax/hdportal_h.jsp?cmd=xxx', error: function(err) { console.log(err) }, success: function(data) { console.log(data) } }) // 定时器的回调 setTimeout(function callback() { console.log('hi') }, 1000) [代码] 回调的问题 1. 回调地狱 过深的嵌套,形成回调地狱 使得代码难以阅读和调试 层层嵌套,代码间耦合严重,牵一发而动全身 2.信任缺失,错误处理无法保证 控制反转,回调函数的调用是在请求函数内部,无法保证回调函数一定会被正确调用,回调本身没有错误处理机制,需要额外设计。 可能存在以下问题: 调用回调过早 调用回调过晚 调用回调次数太多或者太少 未能把所需的参数成功传给你的回调函数 吞掉可能出现的错误或异常 Promise 基本用法 Promise对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败) 一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。 [代码] new Promise((resovle, reject) => { setTimeout(() => { resovle('hello promise') }, 1000) }).then(res => { console.log(res) }).catch(err => { console.log(err) }) [代码] Promise与回调的区别 Promise 不是对回调的替代。 Promise 在回调代码和将要执行这个任务的异步代码之间提供了一种可靠的中间机制来管理回调 Promise 并没有完全摆脱回调。它们只是改变了传递回调的位置。我们并不是把回调传递给处理函数,而是从处理函数得到Promise,然后把回调传给这个Promise Promise 保证了行为的一致性,使其变得可信任,我们传递的回调会被正确的执行 Promise如何解决信任缺失问题? 调用时机上,不会调用过早,也不会调用过晚 根据PromiseA+规范,then中的回调会在 then 方法被调用的那一轮事件循环之后的新执行栈中执行。 这个事件队列可以采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。 所以提供给then的回调也总会在JavaScript事件队列的当前运行完成后,再被调用,即异步调用。 [代码] var p = Promise.resolve('p'); console.log('A'); p.then(function () { p.then(function () { console.log('E'); }); console.log('C'); }) .then(function () { console.log('D'); }); console.log('B'); [代码] 运行这段代码,会依次打印出ABCED 这里要注意两个点: 会先执行同步代码,再执行then中的代码 then执行回调时,打印D的代码晚于打印E的代码 调用次数上,不会出现回调未调用,也不会出现调用次数太多或者太少 一个Promise注册了一个成功回调和拒绝回调,那么Promise在决议的时候总会调用其中一个。 即使是在决议后调用then注册的回调函数,也会被正确调用,所以不会出现回调未调用的情况。 Promise只能被决议一次。如果处于多种原因,Promise创建代码试图调用多次resolve(…)或reject(…),或者试图两者都调用,那么这个Promise将只会接受第一次决议,忽略任何后续调用,所以调用次数不会太多也不会太少。 错误处理上,不会吞掉可能出现的错误或异常 如果在Promise的创建过程中或在查看其决议结果的过程中的任何时间点上,出现了一个JavaScript异常错误,比如一个TypeError或ReferenceError,这个异常都会被捕捉,并且会使这个Promise被拒绝。 [代码] var p = new Promise(function (resolve, reject) { foo.bar(); // foo未定义 resolve(2); }); p.then(function (data) { console.log(data); // 永远也不会到达这里 }, function (err) { console.log('出错了', err); // err将会是一个TypeError异常对象来自foo.bar()这一行 }); [代码] Promise中的then then方法的设计是promise中最重要的部分之一,可以看promise/A+规范中对then方法的描述 then方法会返回一个新的promise,因此可以链式调用,下面的代码会打印出6 [代码] var p = Promise.resolve(0); p.then(function (data) { return 1; }).then(function (data) { return data + 2; }).then(function (data) { return data + 3; }).then(function (data) { console.log(data); }); [代码] 如果在then中主动返回一个promise,依旧会返回一个新的promise,只是这个promise的状态“跟随”主动返回的pormise [代码] var p1 = new Promise(function (resolve, reject) { resolve('p1'); }); var p2 = new Promise(function (resolve, reject) { resolve('p2'); }); var p3 = p2.then(function (data) { return p1; }); console.log(p3 === p1); // false p3.then(function (data) { console.log(data); // p1 }); [代码] 静态方法 Promise.resolve() Promise.resolve(value)方法返回一个以给定值解析后的 Promise 对象。 但如果这个值是个 thenable(即带有 then 方法),返回的 promise 会“跟随”这个 thenable的对象,采用它的最终状态;否则以该值为成功状态返回 promise 对象。 Promise.reject() Promise.reject(reason)方法返回一个用reason拒绝的Promise。 [代码] // 以下两个 promise 是等价的 var p1 = new Promise( (resolve,reject) => { resolve( "Oops" ); }); var p2 = Promise.resolve( "Oops" ); var p1 = new Promise( (resolve,reject) => { reject( "Oops" ); }); var p2 = Promise.reject( "Oops" ); [代码] Promise.all() Promise.all方法用于将多个 Promise 实例,包装成一个新的 Promise 实例 [代码] const p = Promise.all([p1, p2, p3]); p.then(function (posts) { // ... }).catch(function(reason){ // ... }); [代码] p的状态由p1、p2、p3决定,分成两种情况。 (1)只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。 (2)只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。 Promise.race() Promise.race方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。 [代码] const p = Promise.race([p1, p2, p3]); p.then(function (posts) { // ... }).catch(function(reason){ // ... }); [代码] 只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数 Generator 名词解释 迭代器 (Iterator) 迭代器是一种对象,它具有一些专门为迭代过程设计的专有接口,所有迭代器对象都有一个 next 方法,每次调用都返回一个结果对象。 结果对象有两个属性,一个是 value,表示下一个将要返回的值;另一个是 done,它是一个布尔类型的值,当没有更多可返回数据时返回 true。 迭代器还会保存一个内部指针,用来指向当前集合中值的位置,每调用一次 next() 方法,都会返回下一个可用的值。 可迭代对象 (Iterable) 可迭代对象具有 Symbol.iterator 属性,是一种与迭代器密切相关的对象。 Symbol.iterator 通过指定的函数可以返回一个作用于附属对象的迭代器。 在 ECMCScript 6 中,所有的集合对象(数组、Set、及 Map 集合)和字符串都是可迭代对象,这些对象中都有默认的迭代器。 生成器 (Generator) 生成器是一种返回迭代器的函数,通过 function 关键字后的 * 号来表示。 此外,由于生成器会默认为 Symbol.iterator 属性赋值,因此所有通过生成器创建的迭代器都是可迭代对象。 for-of 循环 for-of 循环每执行一次都会调用可迭代对象的迭代器接口的 next() 方法,并将迭代器返回的结果对象的 value 属性储存在一个变量中,循环将持续执行这一过程直到返回对象的属性值为 true。 生成器的一般使用形式 [代码] function *foo() { var x = yield 2 var y = x * (yield x + 1) console.log( x, y ) return x + y } var it = foo() it.next() // {value: 2, done: false} it.next(3) // {value: 4, done: false} it.next(3) // 3 9, {value: 12, done: true} [代码] 遍历器对象的next方法的运行逻辑如下: (1)遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。 (2)下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。 (3)如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。 (4)如果该函数没有return语句,则返回的对象的value属性值为undefined。 需要注意的是,yield表达式后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。 异步迭代生成器 [代码] function foo() { setTimeout(() => { it.next('success') // 恢复*main() // it.throw('error') // 向*main()抛出一个错误 }, 2000); } function *main() { try { var data = yield foo() console.log(data) } catch(e) { console.log(e) } } var it = main() it.next() // 这里启动! [代码] 本例中我们在 *main() 中发起 foo() 请求,之后暂停;又在 foo() 中相应数据恢复 *mian() 继续运行,并将 foo() 的运行结果通过 next() 传递出来。 我们在生成器内部有了看似完全同步的代码(除了 yield 关键字本身),但隐藏在背后的是,在 foo(…)内的运行可以完全异步。并且在异步代码中实现看似同步的错误处理(通过try…catch)在可读性和合理性方面也都是一个巨大的进步。 Generator + Promise 通过promise来管理异步流程 [代码] function foo() { return new Promise(function(resolve, reject){ setTimeout(() => { resolve('fai'); }, 2000); }); } function *main() { try { var data = yield foo() console.log(data) } catch(e) { console.error(e) } } var it = main(); var p = it.next().value; // p 的值是 foo() // 等待 promise p 决议 p.then( function(data) { it.next(data); // 将 data 赋值给 yield }, function(err) { it.throw(err); } ) [代码] *mian() 中执行 foo() 发起请求,返回promise 根据promise 决议结果,根据结果选择继续运行迭代器或抛出错误 如何执行有多处yield的Generator 函数? [代码] function foo(name) { return new Promise(function(resolve, reject){ setTimeout(() => { resolve('hello ' + name); }, 2000); }); } var gen = function* (){ var r1 = yield foo('jarvis'); var r2 = yield foo('hth'); console.log(r1); console.log(r2); }; var g = gen(); // 手动执行 g.next().value.then(function(data){ g.next(data).value.then(function(data){ g.next(data); }); }); [代码] 手动执行的方式,其实就是用then方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器 自动执行Generator 函数 [代码] function foo(name) { return new Promise(function(resolve, reject){ setTimeout(() => { resolve('hello ' + name); }, 2000); }); } var gen = function* (){ var r1 = yield foo('jarvis'); var r2 = yield foo('hth'); console.log(r1); console.log(r2); }; function run(gen){ var g = gen(); function next(data){ var result = g.next(data); if (result.done) return result.value; result.value.then(function(data){ next(data); }); } next(); } run(gen); [代码] 只要保证yield后面总是返回promise,就能用run函数自动执行Generator 函数 Async/Await async 函数的一般使用形式 async 函数是什么? 其实就是 promise+自动执行的Generator 函数的语法糖。类似于我们上面的实现 [代码] function foo(p) { return fetch('http://my.data?p=' + p) } async function main(p) { try { var data = await foo(p) return data } catch(e) { console.error(e) } } main(1).then(data => console.log(data)) [代码] 与 Generator 函数不同是,* 变成了async、yeild变成了await,同时我们也不用再定义 run(…) 函数来实现 Promise 与 Generator 的结合。 async 函数执行的时候,一旦遇到 await 就会先返回,等到异步操作完成,再接着执行函数体内后面的语句,并且最终返回一个 Promise 对象。 正常情况下,await 命令后面是一个 Promise 对象。如果不是,会被转成一个立即 resolve 的 Promise 对象。 await 命令后面的 Promise 对象如果变为 reject 状态,则 reject 的参数会被 catch 方法的回调函数接收到。 async 函数的使用注意点 前面已经说过,await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try…catch 代码块中。 多个 await 命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。 await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。 [代码] //getFoo 与 getBar 是两个互相独立、互不依赖的异步操作 // 错误写法 let foo = await getFoo(); let bar = await getBar(); // 正确写法一 let [foo, bar] = await Promise.all([getFoo(), getBar()]); // 正确写法二 let fooPromise = getFoo(); let barPromise = getBar(); let foo = await fooPromise; let bar = await barPromise; [代码] async 函数比Promise好在哪? 类同步写法,使得在写复杂逻辑时,可以用一种顺序的方式来书写,大大降低了理解的难度。 错误处理上,可以用try catch来捕获,同时处理同步和异步错误。 总结 JavaScript异步编程的发展历程有以下四个阶段: 回调函数: 有两个问题,回调地狱和信任缺失,回调地狱的坏处主要是代码阅读性和可维护性差,同时不好对异步逻辑进行封装。信任缺失主要体现在调用的时机,调用的次数,对异常的处理上缺乏一致性。 Promise 基于PromiseA+规范的实现解决了控制反转带来的信任问题。 Generator 使用生成器函数Generator,我们得以用同步的方式来书写异步的代码,解决了顺序性的问题,这是一种重大的突破。但是使用比较繁琐,需要手动去调用next(…)去控制流程和传参。 Async/Await Async/Await结合了Promise和Generator,并实现了自动执行生成器函数逻辑。使得使用者通添加少量关键字就可以用同步的方式书写异步代码,大大提高了开发效率和代码可维护性。 可以看到,目前Async/Await方式可以说是处理异步的终极解决方案,在项目中应该优先使用这种方式。
2019-06-11 - 如何实现快速生成朋友圈海报分享图
由于我们无法将小程序直接分享到朋友圈,但分享到朋友圈的需求又很多,业界目前的做法是利用小程序的 Canvas 功能生成一张带有小程序码的图片,然后引导用户下载图片到本地后再分享到朋友圈。相信大家在绘制分享图中应该踩到 Canvas 的各种(坑)彩dan了吧~ 这里首先推荐一个开源的组件:painter(通过该组件目前我们已经成功在支付宝小程序上也应用上了分享图功能) 咱们不多说,直接上手就是干。 [图片] 首先我们新增一个自定义组件,在该组件的json中引入painter [代码]{ "component": true, "usingComponents": { "painter": "/painter/painter" } } [代码] 然后组件的WXML (代码片段在最后) [代码]// 将该组件定位在屏幕之外,用户查看不到。 <painter style="position: absolute; top: -9999rpx;" palette="{{imgDraw}}" bind:imgOK="onImgOK" /> [代码] 重点来了 JS (代码片段在最后) [代码]Component({ properties: { // 是否开始绘图 isCanDraw: { type: Boolean, value: false, observer(newVal) { newVal && this.handleStartDrawImg() } }, // 用户头像昵称信息 userInfo: { type: Object, value: { avatarUrl: '', nickName: '' } } }, data: { imgDraw: {}, // 绘制图片的大对象 sharePath: '' // 生成的分享图 }, methods: { handleStartDrawImg() { wx.showLoading({ title: '生成中' }) this.setData({ imgDraw: { width: '750rpx', height: '1334rpx', background: 'https://qiniu-image.qtshe.com/20190506share-bg.png', views: [ { type: 'image', url: 'https://qiniu-image.qtshe.com/1560248372315_467.jpg', css: { top: '32rpx', left: '30rpx', right: '32rpx', width: '688rpx', height: '420rpx', borderRadius: '16rpx' }, }, { type: 'image', url: this.data.userInfo.avatarUrl || 'https://qiniu-image.qtshe.com/default-avatar20170707.png', css: { top: '404rpx', left: '328rpx', width: '96rpx', height: '96rpx', borderWidth: '6rpx', borderColor: '#FFF', borderRadius: '96rpx' } }, { type: 'text', text: this.data.userInfo.nickName || '青团子', css: { top: '532rpx', fontSize: '28rpx', left: '375rpx', align: 'center', color: '#3c3c3c' } }, { type: 'text', text: `邀请您参与助力活动`, css: { top: '576rpx', left: '375rpx', align: 'center', fontSize: '28rpx', color: '#3c3c3c' } }, { type: 'text', text: `宇宙最萌蓝牙耳机测评员`, css: { top: '644rpx', left: '375rpx', maxLines: 1, align: 'center', fontWeight: 'bold', fontSize: '44rpx', color: '#3c3c3c' } }, { type: 'image', url: 'https://qiniu-image.qtshe.com/20190605index.jpg', css: { top: '834rpx', left: '470rpx', width: '200rpx', height: '200rpx' } } ] } }) }, onImgErr(e) { wx.hideLoading() wx.showToast({ title: '生成分享图失败,请刷新页面重试' }) //通知外部绘制完成,重置isCanDraw为false this.triggerEvent('initData') }, onImgOK(e) { wx.hideLoading() // 展示分享图 wx.showShareImageMenu({ path: e.detail.path, fail: err => { console.log(err) } }) //通知外部绘制完成,重置isCanDraw为false this.triggerEvent('initData') } } }) [代码] 那么我们该如何引用呢? 首先json里引用我们封装好的组件share-box [代码]{ "usingComponents": { "share-box": "/components/shareBox/index" } } [代码] 以下示例为获取用户头像昵称后再生成图。 [代码]<button class="intro" bindtap="getUserInfo">点我生成分享图</button> <share-box isCanDraw="{{isCanDraw}}" userInfo="{{userInfo}}" bind:initData="handleClose" /> [代码] 调用的地方: [代码]const app = getApp() Page({ data: { isCanDraw: false }, // 组件内部关掉或者绘制完成需重置状态 handleClose() { this.setData({ isCanDraw: !this.data.isCanDraw }) }, getUserInfo(e) { wx.getUserProfile({ desc: "获取您的头像昵称信息", success: res => { const { userInfo = {} } = res this.setData({ userInfo, isCanDraw: true // 开始绘制海报图 }) }, fail: err => { console.log(err) } }) } }) [代码] 最后绘制分享图的自定义组件就完成啦~效果图如下: [图片] tips: 文字居中实现可以看下代码片段 文字换行实现(maxLines)只需要设置宽度,maxLines如果设置为1,那么超出一行将会展示为省略号 代码片段:https://developers.weixin.qq.com/s/J38pKsmK7Qw5 附上painter可视化编辑代码工具:点我直达,因为涉及网络图片,代码片段设置不了downloadFile合法域名,建议真机开启调试模式,开发者工具 详情里开启不校验合法域名进行代码片段的运行查看。 最后看下面大家评论问的较多的问题:downLoadFile合法域名在小程序后台 开发>开发设置里配置,域名为你图片的域名前缀 比如我文章里的图https://qiniu-image.qtshe.com/20190605index.jpg。配置域名时填写https://qiniu-image.qtshe.com即可。如果你图片cdn地址为https://aaa.com/xxx.png, 那你就配置https://aaa.com即可。
2022-01-20 - 云函数中生成excel并且上传到云存储中
云环境1.0.51 小程序的云开发功能为我们带来了很大的方便,于是就打算研究一下如何在云函数中拉取数据,之后生成excel到云存储中,过程中踩了些坑,这里分享给大家,希望能有所帮助。 首先了解一个node端生成excel的库excel-export 虽然已经许久未更新了,但是目前还没有什么太大的问题,所以在他的基础上进行开发,并且上手也比较容易 主要用法 引入 [代码]let nodeExcel = require('excel-export'); [代码] 创建配置对象 [代码]let conf = { stylesXmlFile, // 约束文件(不然生成的excel打开会报一些问题) cols, // 可理解为表头 [{ caption: 'columnName', type: 'string' }], 这里出于方便,type为string,具体可移步其文档查看 rows, // 可以理解为填充的数据 ['wechat', 'mp'] } [代码] 创建流对象 [代码]let result = nodeExcel.execute(conf) // 普通node后端可以直接使用 res.end(result, 'binary'); 进行下载,要记得添加相应的头,其文档里也有说明 // 最终可以使用 Buffer.from(result.toString(), 'binary') 转换为一个Buffer对象 [代码] 嵌入云开发 大致的思路就是 [拉取数据] -> [生成excel流对象] -> [上传到云存储中] -> [返回该fileID] 几个踩坑点 读入文件要使用 [代码]path.resolve(__dirname, 'xxx')[代码] 得到excel流对象 使用 [代码]Buffer.from(result.toString(), 'binary')[代码] 再配合[代码]cloud.uploadFile[代码] 生成时conf要配置[代码]stylesXmlFile[代码],不然打开文件总有个提示,很不爽![代码]styles.xml[代码]这个文件可以在[代码]node_modules/excel-export/example/styles.xml[代码]找到。 数据和表头最好是对应的,数据也可以存在空值 云函数目录结构 [代码]- testDownload - |- index.js - |- styles.xml - |- package.json - |- package-lock.json - |- node_modules (在开发工具中应该是不显示的) [代码] package.json中的依赖 [代码] "dependencies": { "excel-export": "^0.5.1", "wx-server-sdk": "latest" } [代码] index.js文件代码 [代码]// 云函数入口文件 const cloud = require('wx-server-sdk') const nodeExcel = require('excel-export') const fs = require('fs') const path = require('path') cloud.init({ env: "xxxx" // 你的环境 }) const db = cloud.database() // 生成分数项并且下载对应的excel exports.main = async (event, context) => { let collectionId = '123666' // 模拟的集合名 let openId = 'sda6248daa888764' // 模拟openid let confParams = ['姓名', '学号', '签到时间'] // 模拟表头 let jsonData = [] // 获取数据 await db.collection(collectionId).get().then(res => { jsonData = res.data }) // 转换成excel流数据 let conf = { stylesXmlFile: path.resolve(__dirname, 'styles.xml'), name: 'sheet', cols: confParams.map(param => { return { caption: param, type: 'string' } }), rows: jsonToArray(jsonData) } let result = nodeExcel.execute(conf) // result为excel二进制数据流 // 上传到云存储 return await cloud.uploadFile({ cloudPath: `download/sheet${openId}.xlsx`, // excel文件名称及路径,即云存储中的路径 fileContent: Buffer.from(result.toString(), 'binary'), }) // json对象转换成数组填充 function jsonToArray (arrData) { let arr = new Array() arrData.forEach(item => { let itemArray = new Array() for (let key in item) { if (key === '_id' || key === '_openid') { continue } itemArray.push(item[key]) } arr.push(itemArray) }) return arr } } [代码] 触发云函数,可以看到云存储中有了刚刚生成的文件 [图片]数据库中的数据, 由于表头都是一样的,所以这边的key可以适当的简化,但是要注意数据库中拉取数据顺序的问题 [图片]最终生成的excel
2019-06-05 - 如何实现小程序的强制更新
大家都知道小程序提交审核发布以后是不会马上更新版本的,用户需要下次使用才会更新到新的版本,这就是冷更新。 那么如果要做到及时生效怎么办呢?这时候就要做处理了,将下面的代码添加到app.js,提交审核,发布就会生效了 [代码]onLaunch: [代码][代码]function[代码] [代码](options) {[代码] [代码] [代码][代码]this[代码][代码].autoUpdate()[代码] [代码] [代码][代码]},[代码] [代码] [代码][代码]autoUpdate: [代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]var[代码] [代码]self = [代码][代码]this[代码] [代码] [代码][代码]// 获取小程序更新机制兼容[代码] [代码] [代码][代码]if[代码] [代码](wx.canIUse([代码][代码]'getUpdateManager'[代码][代码])) {[代码] [代码] [代码][代码]const updateManager = wx.getUpdateManager()[代码] [代码] [代码][代码]//1. 检查小程序是否有新版本发布[代码] [代码] [代码][代码]updateManager.onCheckForUpdate([代码][代码]function[代码] [代码](res) {[代码] [代码] [代码][代码]// 请求完新版本信息的回调[代码] [代码] [代码][代码]if[代码] [代码](res.hasUpdate) {[代码] [代码] [代码][代码]//检测到新版本,需要更新,给出提示[代码] [代码] [代码][代码]wx.showModal({[代码] [代码] [代码][代码]title: [代码][代码]'更新提示'[代码][代码],[代码] [代码] [代码][代码]content: [代码][代码]'检测到新版本,是否下载新版本并重启小程序?'[代码][代码],[代码] [代码] [代码][代码]success: [代码][代码]function[代码] [代码](res) {[代码] [代码] [代码][代码]if[代码] [代码](res.confirm) {[代码] [代码] [代码][代码]//2. 用户确定下载更新小程序,小程序下载及更新静默进行[代码] [代码] [代码][代码]self.downLoadAndUpdate(updateManager)[代码] [代码] [代码][代码]} [代码][代码]else[代码] [代码]if[代码] [代码](res.cancel) {[代码] [代码] [代码][代码]//用户点击取消按钮的处理,如果需要强制更新,则给出二次弹窗,如果不需要,则这里的代码都可以删掉了[代码] [代码] [代码][代码]wx.showModal({[代码] [代码] [代码][代码]title: [代码][代码]'温馨提示'[代码][代码],[代码] [代码] [代码][代码]content: [代码][代码]'本次版本更新涉及到新的功能添加,旧版本可能无法正常访问哦'[代码][代码],[代码] [代码] [代码][代码]showCancel: [代码][代码]false[代码][代码],[代码][代码]//隐藏取消按钮[代码] [代码] [代码][代码]confirmText: [代码][代码]"确定更新"[代码][代码],[代码][代码]//只保留确定更新按钮[代码] [代码] [代码][代码]success: [代码][代码]function[代码] [代码](res) {[代码] [代码] [代码][代码]if[代码] [代码](res.confirm) {[代码] [代码] [代码][代码]//下载新版本,并重新应用[代码] [代码] [代码][代码]self.downLoadAndUpdate(updateManager)[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]} [代码][代码]else[代码] [代码]{[代码] [代码] [代码][代码]// 如果希望用户在最新版本的客户端上体验您的小程序,可以这样子提示[代码] [代码] [代码][代码]wx.showModal({[代码] [代码] [代码][代码]title: [代码][代码]'提示'[代码][代码],[代码] [代码] [代码][代码]content: [代码][代码]'当前微信版本过低,无法使用该功能,请升级到最新微信版本后重试。'[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]},[代码] [代码] [代码][代码]/**[代码] [代码] [代码][代码]* 下载小程序新版本并重启应用[代码] [代码] [代码][代码]*/[代码] [代码] [代码][代码]downLoadAndUpdate: [代码][代码]function[代码] [代码](updateManager) {[代码] [代码] [代码][代码]var[代码] [代码]self = [代码][代码]this[代码] [代码] [代码][代码]wx.showLoading();[代码] [代码] [代码][代码]//静默下载更新小程序新版本[代码] [代码] [代码][代码]updateManager.onUpdateReady([代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]wx.hideLoading()[代码] [代码] [代码][代码]//新的版本已经下载好,调用 applyUpdate 应用新版本并重启[代码] [代码] [代码][代码]updateManager.applyUpdate()[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]updateManager.onUpdateFailed([代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]// 新的版本下载失败[代码] [代码] [代码][代码]wx.showModal({[代码] [代码] [代码][代码]title: [代码][代码]'已经有新版本了哟'[代码][代码],[代码] [代码] [代码][代码]content: [代码][代码]'新版本已经上线啦,请您删除当前小程序,重新搜索打开哟'[代码][代码],[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]},[代码]
2019-06-07 - 3分钟教你用java开发一个小程序后台服务器~看完你也会
上一节带领大家开发了一个可供浏览器访问的java后台,但是我们现实开发中,数据后台肯定不仅仅供浏览器访问。我们这节来教大家如何开发一个可供小程序访问的服务器后台。 同样我们这一节是建立在上一节的基上,如果你没有看上一节课程,请点击底部专栏去查看上一节。 老规矩,先看效果图 [图片] 本节知识点 java学习 springboot学习 搭建小程序后台 小程序访问后台数据 小程序数据的获取与解析展示 一,搭建java后台 [图片] 我们这里搭建了一个简单的java后台,给小程序提供上图箭头所示的一段数据。 这里的代码是建立在上一节的基础上,如果你没有看上节课程,请先去看完上一节,再来看这节内容。 手把手教你开发自己的第一个java项目(基于springboot2.1.5) 我们接下来启动服务器, [图片] [图片] 我们服务器启动成功,就代表我们可以正常的提供后台数据了。接下来教大家如何搭建小程序来请求我们的后台数据。 二,搭建小程序 我们的小程序也比较简单,毕竟是教大家入门嘛,刚开始肯定要讲的简单些。后面的课程在逐步深入。 [图片] 可以看到我们的小程序页面只简单定义了下面两件事 一个button按钮,用来点击请求数据 一个text组件,用来展示请求到的后台数据 如果你想学习小程序相关的知识,可以去老师专栏看小程序相关的教程 下面来简单的讲下我们如何实现小程序的数据请求功能。 1,定义index.wxml [图片] 2,定义index.js [图片] 其实我们的小程序端,就index.wxml和index.js这两个文件比较重要,至于这两个文件具体是干嘛用的,这里不做具体讲解,感兴趣的小程序相关的视频教程。 《10小时零基础入门小程序开发》https://edu.csdn.net/course/detail/9531 三,运行小程序,点击按钮请求java后台数据 [图片] 小程序运行成功后,在左侧的模拟器里点击绿色的button按钮。 这个时候会报如下错误:“不在以下 request 合法域名列表中,请参考文档” [图片] 这个错误很好解决,按照下图步骤操作即可 [图片] 上面错误解决好以后,再次点击一下编译 [图片] 然后点击我们的“访问后台数据”绿色按钮。在控制台就可以看到如下日志。 [图片] 在模拟器里也可以看到我们成功的展示了后台的数据 [图片] 看上图和我们后台定义的数据是不是一摸一样,你在下面的后台代码里对数据做更改,上面的小程序获取到的数据也会改变。 [图片] 到这里我们就完整的实现了后台服务器的搭建和小程序端的简单开发。 [图片] 源码已经传到网盘,如果想要源码,请在文章底部留言,或者私信老师获取。 我们下一节会把这节内容录制成视频,方便大家理解。敬请期待下一节。 我们把这节内容录制成视频,方便大家理解。 视频讲解:https://edu.csdn.net/course/detail/23443
2019-06-11 - 小程序需要https域名,不会配置HTTPS?给我5分钟,手把手教你
本文针对不会配置HTTPS或者小白开发着,请开发者社区的大佬们自动忽略。非广告,心得分享,勿喷,谢谢。 👇 推荐一个小程序商城,全开源,码云GVP项目,有兴趣的可以了解一下:【点击下载】 👇 👇 正文开始 01、关于 FreeSSL.cn FreeSSL.cn 是一个免费提供 HTTPS 证书申请、HTTPS 证书管理和 HTTPS 证书到期提醒服务的网站,旨在推进 HTTPS 证书的普及与应用,简化证书申请的流程。 当然了,我看重的不是免费(微笑~),而是 FreeSSL 使用起来非常人性化。我是一个计算机常识非常薄弱的程序员(羞愧一下),但通过 FreeSSL,我竟然可以独自完成 Tomcat 的 HTTPS 配置! 很多年以前,公司要做华夏银行的接口对接,需要 HTTPS 访问,大概花了 3000 块买的证书,最后证书还有问题,HTTPS 也没搞定。总之,坑的很! FreeSSL.cn 有很大的不同,申请非常便捷,优点很多,值得推荐一波。毕竟再也不用邮件、电话各种联系了(也许时代进步了)。 100% 永久免费;这要感谢 Let’s Encrypt 与 TrustAsia 提供的免费 SSL 证书。 在 HTTPS 证书到期前,FreeSSL.cn 会及时地提醒更换证书,免费的服务。 私钥不在网络中传播,确保 HTTPS 证书的安全。 02、使用 FreeSSL 申请证书 第一步,填写域名,点击「创建免费的 SSL 证书」 [图片] 第二步,填写邮箱,点击「创建」 [图片] 1)证书类型默认为 RSA RSA 和 ECC 有什么区别呢?可以通过下面几段文字了解一下。 HTTPS 通过 TLS 层和证书机制提供了内容加密、身份认证和数据完整性三大功能,可以有效防止数据被监听或篡改,还能抵御 MITM(中间人)攻击。TLS 在实施加密过程中,需要用到非对称密钥交换和对称内容加密两大算法。 对称内容加密强度非常高,加解密速度也很快,只是无法安全地生成和保管密钥。在 TLS 协议中,应用数据都是经过对称加密后传输的,传输中所使用的对称密钥,则是在握手阶段通过非对称密钥交换而来。常见的 AES-GCM、ChaCha20-Poly1305,都是对称加密算法。 非对称密钥交换能在不安全的数据通道中,产生只有通信双方才知道的对称加密密钥。目前最常用的密钥交换算法有 RSA 和 ECDHE:RSA 历史悠久,支持度好,但不支持 PFS(Perfect Forward Secrecy);而 ECDHE 是使用了 ECC(椭圆曲线)的 DH(Diffie-Hellman)算法,计算速度快,支持 PFS。 2)验证类型默认为 DNS DNS 和文件验证有什么区别呢?我们再来一起了解下。 首先,我们需要明白一点,CA(Certificate Authority,证书颁发机构) 需要验证我们是否拥有该域名,这样才给我们颁发证书。 文件验证(HTTP):CA 将通过访问特定 URL 地址来验证我们是否拥有域名的所有权。因此,我们需要下载给定的验证文件,并上传到您的服务器。 DNS 验证:CA 将通过查询 DNS 的 TXT 记录来确定我们对该域名的所有权。我们只需要在域名管理平台将生成的 TXT 记录名与记录值添加到该域名下,等待大约 1 分钟即可验证成功。 所以,如果对服务器操作方便的话,可以选择文件验证;如果对域名的服务器操作比较方便的话,可以选择 DNS 验证。如果两个都方便的话,请随意选啦。 3)CSR生成默认为离线生成 离线生成、浏览器生成 和 我有 CSR 又有什么区别呢?来,我们继续了解一下。 离线生成 推荐!!!:私钥在本地加密存储,更安全;公钥自动合成,支持常见证书格式转换,方便部署;支持部分 WebServer 的一键部署,非常便捷。 离线生成的时候,需要先安装 KeyManager,可以提供安全便捷的 SSL 证书申请和管理。下载地址如下: https://keymanager.org/ Windows 的话,安装的时候要选择“以管理员身份运行”。 浏览器生成:在浏览器支持 Web Cryptography 的情况下,会使用浏览器根据用户的信息生成 CSR 文件。 Web Cryptography,网络密码学,用于在 Web 应用程序中执行基本加密操作的 JavaScript API。很多浏览器并不支持 我有 CSR:可以粘贴自己的 CSR,然后创建。 第三步,选择离线生成,打开 KeyManager 填写密码后点击「开始」,稍等片刻。 第四步,返回浏览器,点击「下一步」,出现如下界面。 [图片] 第五步,下载文件,并上传至服务器指定目录下。 第六步,点击「验证」,通过后,出现以下界面。 [图片] 第七步,点击「保存到KeyManager」,可以看到证书状态变成了已颁发。 03、为 Tomcat 配置 jks 格式证书 第一步,导出证书。假如服务器选择的 Tomcat,需要导出 Java keystone (简拼为 jks)格式的证书。 [图片] 注意:私钥的密码在配置 Tomcat 的时候用到。 [图片] 第二步,上传证书至服务器。 第三步,配置 Tomcat 的 server.xml。 [代码] <Connector port="81" protocol="HTTP/1.1" maxThreads="250" maxHttpHeaderSize="8192" acceptCount="100" connectionTimeout="60000" keepAliveTimeout="200000" redirectPort="8443" useBodyEncodingForURI="true" URIEncoding="UTF-8" compression="on" compressionMinSize="2048" noCompressionUserAgents="gozilla, traviata" compressableMimeType="text/html,text/xml,application/xml,application/json,text/javascript,application/javascript,text/css,text/plain,text/json,image/png,image/gif"/> <Connector protocol="org.apache.coyote.http11.Http11NioProtocol" port="443" maxThreads="200" scheme="https" secure="true" SSLEnabled="true" keystoreFile="/home/backup/qingmiaokeji.cn.jks" keystorePass="Chenmo" clientAuth="false" sslProtocol="TLS" useBodyEncodingForURI="true" URIEncoding="UTF-8" compression="on" compressionMinSize="2048" noCompressionUserAgents="gozilla, traviata" compressableMimeType="text/html,text/xml,application/xml,application/json,text/javascript,application/javascript,text/css,text/plain,text/json,image/png,image/gif" /> [代码] 其中 keystorePass 为导出证书时私钥的加密密码。 第四步,重启 Tomcat,并在浏览器地址栏中输入 https://你的域名/ 进行测试。 注意到没,浏览器地址栏前面有一个绿色的安全锁,这说明 HTTPS 配置成功了!好了,为自己鼓个掌! 04、最后 你有没有订个五分钟的时间沙漏?如果超过五分钟 HTTPS 还没有配置成功,你过来揍我!反正你又打不来我!我在CRMEB等你! 👇 👇 👇 最后亿遍,再次发一下我的项目:全开源啊!公众号+小程序啊!商城系统啊!免费啊!了解一下啊→→→点我点我!
2019-05-10 - 微信小程序会员卡开发跳坑
在Bmob官方群,最近看好多人问,小程序里面怎么显示会员卡,然客户领取后,去对应店铺核销。 本身以为会很简单,最后费了好大心思才找到对应文档。 会员卡的文档不知道该怎么说。。。没说明参数从哪里获取。这篇文章带大家跳坑 看了一下文档,大概是这样一个函数,可以让用户领取会员卡 [代码]wx.navigateToMiniProgram({ appId: 'wxeb490c6f9b154ef9', //固定为此 appid,不可改动 extraData: data, // 包括 encrypt_card_id, outer_str, biz三个字段,须从 step3 中获得的链接中获取参数 success: function() { }, fail: function() { }, complete: function() { } }) [代码] 这里的 extraData: data, // 包括 encrypt_card_id, outer_str, biz三个字段,须从 step3 中获得的链,是关键。 extraData,值文档说的第三步,在文档里面很难找到第三步获取开卡组件参数内容。也找不多哪个接口有返回这三个参数 encrypt_card_id, outer_str, biz。 文档上面有个开卡组件文档,我们打开 https://mp.weixin.qq.com/cgi-bin/announce?action=getannouncement&key=1479824356&version=1&lang=zh_CN&platform=2&token= [图片] 既然开卡组件文档没有,那我们去公众号文档,会员卡相关文档看下。 找到卡券-小程序打通 https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1499332673_Unm7V 找到:接口1:获取开卡插件参数 [图片] 红色箭头返回的URL,就带了我们需要的encrypt_card_id, outer_str, biz 三个参数, 只是文档没有说明,这个是url里面带的值,而不是返回的参数,所以给查找带来了麻烦。 我们试试 [图片] 真的拿到了,我们需要的三参数, 然后通过url解析,得到参数。 [代码]wx.navigateToMiniProgram({ appId: 'wxeb490c6f9b154ef9', //固定为此 appid,不可改动 extraData: data, // 包括 encrypt_card_id, outer_str, biz三个字段,须从 step3 中获得的链接中获取参数 success: function() { }, fail: function() { }, complete: function() { } }) [代码] 小程序里做个按钮,领取会员卡。点击事件执行上面代码 提示此小程序未绑定公众账号 此时我们登陆公众账号,绑定这个小程序。 绑定后,继续提示错误 [代码]"navigateToMiniProgram:fail appId "wxeb490c6f9b154ef9" is not in navigateToMiniProgramAppIdList" [代码] [图片] 看英文的意思是说小程序wxeb490c6f9b154ef9未绑定此公众号。 这里wxeb490c6f9b154ef9 并不是我们自己的一个小程序appid ,而是文档规定必须填写的wxeb490c6f9b154ef9,这个是官方的一个小程序appid, 原理是我们执行调整小程序,跳转到官方小程序领取会员卡。 比较麻烦的是,绑定官方开卡这个小程序,需要官方同意才可以,这里添加了绑定,官方2天没同意,已经失效。 到此,就实现了微信小程序,跳转到卡卷小程序,领取会员卡的开发过程。
2019-03-11 - 函数构造同步或异步的使用方式
var a = function(ret,callback){ [代码]if(!!callback){ callback(ret); // 异步 } if(ret){ return ret; // 同步 } [代码] } var pRet = a({v:1},function(ret){ console.log(“ret=>”,ret); if(ret && ret.v){ console.log(“不能为真”); return false; } }) console.log(“pRet=>”,pRet); if(pRet && pRet.v){ console.log(“不能为真”); return false; } console.log(“同步或异步”);
2019-02-27 - 【求助】小程序打通卡券
我们小程序有自己的会员卡,现在希望将已有的会员卡添加到微信卡包中。在开发前,我们通过接口模拟测试流程,遇到了下面的问题: 我们通过调用API成功创建了card_id为“pPnu0v7ONhI3LLp98L9Pv2mvp8ic”的会员卡(bind_openid与use_custom _code均未传递,根据文档应该默认为false) 通过微信卡券的签名校验工具生成了签名,参与签名参数如下: api_ticket:"IpK_1T69hDhZkLQTlwsAXz9C5S2e5MsdnwpsQNrxZDxyY6MMK991lU8Tgn2FN83C5upfCutOpIR9GuegKNMa6w", timestamp:"1550026547", nonce_str:"zxcvbnmasd", card_id:"pPnu0v7ONhI3LLp98L9Pv2mvp8ic". 最终签名结果为:1f83c0511ac9591754a2c433f508561ddcb86270 小程序端参数写死,如下: [代码]var[代码] [代码]cardExt = {};[代码][代码] [代码][代码]cardExt.nonce_str = [代码][代码]"zxcvbnmasd"[代码][代码];[代码][代码] [代码][代码]cardExt.timestamp = [代码][代码]"1550026547"[代码][代码];[代码][代码] [代码][代码]cardExt.signature =[代码][代码]"1f83c0511ac9591754a2c433f508561ddcb86270"[代码][代码];[代码][代码] [代码][代码]console.log(JSON.stringify(cardExt))[代码][代码] [代码][代码]wx.addCard({[代码][代码] [代码][代码]cardList: [[代码][代码] [代码][代码]{[代码][代码] [代码][代码]cardId:[代码][代码]"pPnu0v7ONhI3LLp98L9Pv2mvp8ic"[代码][代码],[代码][代码] [代码][代码]cardExt: JSON.stringify(cardExt)[代码][代码] [代码][代码]}[代码][代码] [代码][代码]][代码][代码] [代码][代码]})[代码]但测试发现小程序端始终报“签名错误”(如图): [图片] 麻烦各位帮忙看一下,实在是找不出原因了
2019-02-13 - Wxml2Canvas -- 快速生成小程序分享图通用方案
Wxml2Canvas库,可以将指定的wxml节点直接转换成canvas元素,并且保存成分享图,极大地提升了绘制分享图的效率。目前被应用于微信游戏圈、王者荣耀、刺激战场助手等小程序中。 github地址:https://github.com/wg-front/wxml2canvas 一、背景 随着小程序应用的日渐成熟,多处场景需要能够生成分享图便于用户进行二次传播,从而提升小程序的传播率以及加强品牌效应。 对于简单的分享图,比如固定大小的背景图加几行简短文字构成的分享小图,我们可以利用官方提供的canvas接口将元素直接绘制, 虽然繁琐了些,但能满足基本要求。 对于复杂的分享图,比如用户在微信游戏圈发表完话题后,需要将图文混排的富文本内容生成分享图,对于这种长度不定,内容动态变化的图片生成需求,直接利用官方的canvas接口绘制是十分困难的,包括但不限于文字换行、表情文字图片混排、文字加粗、子标题等元素都需要一一绘制。又如王者荣耀助手小程序,需要将十人对局的详细战绩绘制成分享图,包含英雄数据、装备、技能、对局结果等信息,要绘制100多张图片和大量的文字信息,如果依旧使用官方的接口一步一步绘制,对开发者来说简直就是一场噩梦。我们急需一种通用、高效的方式完成上述的工作。 在这样的背景下,wxml2cavnas诞生了,作为一种分享图绘制的通用方案,它不仅能快速的绘制简单的固定小图,还能直接将wxml元素真实地转换成canvas元素,并且适配各种机型。无论是复杂的图文混排的富文本内容,还是展现形式多样的战绩结果页,都可以利用wxml2cavnas完美地快速绘制并生成所期望的分享图片。 二、Wxml2Canvas介绍及示例 1. 介绍 Wxml2Cavnas库,是一个生成小程序分享图的通用方案,提供了两种绘制方式: 封装基础图形的绘制接口,包括矩形、圆形、线条、图片、圆角图片、纯文本等,使用时只需要声明元素类型并提供关键数据即可,不需要再关注canvas的具体绘制过程; wxml直接转换成canvas元素,使用时传入待绘制的wxml节点的class类名,并且声明绘制此节点的类型(图片、文字等),会自动读取此节点的computedStyle,利用这些数据完成元素的绘制。 2. 生成图示例 下面是两张极端复杂的分享图。 2.1 游戏圈话题 [图片] 点击查看完整长图 2.2.2 王者荣耀战绩 [图片] 点击查看完整大图 三、小程序的特性及局限 小程序提供了如下特性,可供我们便捷使用: measureText接口能直接测量出文本的宽度; SelectorQuery可以查询到节点对应的computedStyle。 利用第一条,我们在绘制超长文本时便于文本的省略或者换行,从而避免文字溢出。 利用第二条,我们可以根据class类名,直接拿到节点的样式,然后将style转换成canvas可识别的内容。 但是和html的canvas相比,小程序的canvas局限性很多。主要体现在如下几点: 不支持base64图片; 图片必须下载到本地后才能绘制到画布上; 图片域名需要在管理平台加入downFile安全域名; canvas属于原生组件,在移动端会置于最顶层; 通过SelectorQuery只能拿到节点的style,而无法获取文本节点的内容以及图片节点的链接。 针对以上问题,我们需要将base64图片转换jpg或png格式的图片,实现图片的统一下载逻辑,并且离屏绘制内容。针对第五条,好在SelectorQuery可以获取到节点的dataset属性,所以我们需要在待绘制的节点上显示地声明其类型(imgae、text等),并且显示地传入文本内容或图片链接,后文会有示例。 四、Wxml2Canvas使用方式 1. 初始化 首先在wxml中创建canvas节点,指定宽高: [代码] <canvas canvas-id="share" style="height: {{ height * zoom }}px; width: {{ width * zoom }}px;"> </canvas> [代码] 引入代码库,创建DrawImage实例,并传入如下参数: [代码] let DrawImage = require('./wxml2canvas/index.js'); let zoom = this.device.windowWidth / 375; let width = 375; let height = width * 3; let drawImage = new DrawImage({ element: 'share', // canvas节点的id, obj: this, // 在组件中使用时,需要传入当前组件的this width: width, // 宽高 height: height, background: '#161C3A', // 默认背景色 gradientBackground: { // 默认的渐变背景色,与background互斥 color: ['#17326b', '#340821'], line: [0, 0, 0, height] }, progress (percent) { // 绘制进度 }, finish (url) { // 画完后返回url }, error (res) { console.log(res); // 画失败的原因 } }); [代码] 所有的数字参数均以iphone6为基准,其中参数width和height决定了canvas画布的大小,规定值是在iphone6机型下的固定数值; zoom参数的作用是控制画布的缩放比例,如果要求画布自适应,则应传入 windowWidth / 375,windowWidth为手机屏幕的宽度。 2. 传入数据,生成图片 执行绘制操作: [代码] drawImage.draw(data, this); [代码] 执行绘制时需要传入数据data,数据的格式分为两种,下面展开介绍。 2.1 基础图形 第一种为基础的图形、图文绘制,直接使用官方提供接口,下面代码是一个基本的格式: [代码] let data = { list: [{ type: 'image', url: 'https://xxx', class: 'background_image', // delay: true, x: 0, y: 0, style: { width: width, height: width } }, { type: 'text', text: '文字', class: 'title', x: 0, y: 0, style: { fontSize: 14, lineHeight: 20, color: '#353535', fontFamily: 'PingFangSC-Regular' } }] } [代码] 如上,type声明了要元素的类型,有image、text、rect、line、circle、redius_image(圆角图)等,能满足绝大多数情况。 class类名指定了使用的样式,需要在style中写出,符合css样式规范。 delay参数用来异步绘制元素,会把此元素放在第二个循环中绘制。 x,y用来指定元素的起始坐标。 将css样式与元素分离的目的是便于管理与复用。 此种方式每个元素都相互独立,互不影响,能够满足自由度要求高的情况,可控性高。 2.2 wxml转换 第二种方式为指定wxml元素,自动获取,下面是示例: [代码] let data = { list: [{ type: 'wxml', class: '.panel .draw_canvas', limit: '.panel' x: 0, y: 0 }] } [代码] 如上,type声明为wxml时,会查找所有类名为draw_canvas的节点,并且加入到绘制队列中。 class传入的第一个类名限定了查询的范围,可以不传,第二个用来指定查找的节点,可以定义为任意不影响样式展现的通用类名。 limit属性用来限定相对位置,例如,一个文本的位置(left, top) = (50, 80), class为panel的节点的位置为(left, top) = (20, 40),则文本canvas上实际绘制的位置(x, y) = (50 - 20, 80 -40) = (30, 40)。如果不传入limit,则以实际的位置(x, y) = (50, 80)绘制。 由于小程序节点元素查询接口的局限,无法直接获取节点的文本内容和图片标签的src属性,也无法直接区分是文本还是图片,但是可以获取到dataset,所以我们需要在节点上显示地声明data-type来指明类型,再声明data-text传入文字或data-url传入图片链接。下面是个示例: [代码] <view class="panel"> <view class="panel__img draw_canvas" data-type="image" data-url="https://xxx"></view> <view class="panel__text draw_canvas" data-type="text" data-text="文字">文字</view> </view> [代码] 如上,会查询到两个节点符合条件,第一个为image图片,第二个为text文本,利用SelectorQuery查询它们的computedStyle,分别得到left、top、width、height等数据后,转换成canvas支持的格式,完成绘制。 除此之外,下面的示例功能更加丰富: [代码] <view class="panel"> <view class="panel__text draw_canvas" data-type="background-image" data-radius="1" data-shadow="" data-border="2px solid #000"></view> <view class="panel__text draw_canvas" data-type="text" data-background="#ffffff" data-padding="2 3 0 0" data-delay="1" data-left="10" data-top="10" data-maxlength="4" data-text="这是个文字">这是个文字</view> </view> [代码] 如上,第一个data-type为background-image,表示读取此节点的背景图片,因为可以通过computedStyle直接获取图片链接,所以不需要显示传入url。声明data-radius属性,表示要将此图绘成乘圆形图片。data-border属性表示要绘制图片的边框,虽然也可以通过computedStyle直接获取,但是为了避免非预期的结果,还是要声明传入,border格式应符合css标准。此外,图片的box-shadow等样式都会根据声明绘制出来。 第二个文本节点,声明了data-background,则会根据节点的位置属性给文字增加背景。 data-padding属性用来修正背景的位置和宽高。data-delay属性用来延迟绘制,可以根据值的大小,来控制元素的层级,data-left和data-top用来修正位置,支持负值。data-maxlength用来限制文本的最大长度,超长时会截取并追加’…’。 此外,data-type还有inline-text,inline-image等行内元素的绘制,其实现较为复杂,会在后文介绍。 五、Wxml2Canvas实现原理 1. 绘制流程 整个绘制流程如下: [图片] 因为小程序的限制,只能在画布上绘制本地图片,所以统一先对图片提前下载,然后再绘制,为了避免图片重复下载,内部维护一个图片列表,会对相同的图片链接去重,减少等待时间。 2. 基本图形的实现 基础图形的绘制比较简单,内部实现只是对基础能力的封装,使用者不用再关注canvas的绘制过程,只需要提供关键数据即可,下面是一个图片绘制的实现示例: [代码] function drawImage (item, style) { if(item.delay) { this.asyncList.push({item, style}); }else { if(item.y < 0) { item.y = this.height + item.y * zoom - style.height * zoom; }else { item.y = item.y * zoom; } if(item.x < 0) { item.x = this.width + item.x * zoom - style.width * zoom; }else { item.x = item.x * zoom; } ctx.drawImage(item.url, item.x, item.y, style.width * zoom, style.height * zoom); ctx.draw(true); } } [代码] 如上,x,y值坐标支持传入负值,表示从画布的底部和右侧计算位置。 3. Wxml转Canvas元素的实现 3.1 computedStyle的获取 首先需要获取wxml的样式,代码示例如下: [代码] query.selectAll(`${item.class}`).fields({ dataset: true, size: true, rect: true, computedStyle: ['width', 'height', ...] }, (res) => { self.drawWxml(res); }) [代码] 3.2 块级元素的绘制 对于声明为image、text的元素,默认为块级元素,它们的绘制都是独立进行的,不需要考虑其他的元素的影响,以wxml节点为圆形的image为例,下面是部分代码: [代码] if(sub.dataset.type === 'image') { let r = sub.width / 2; let x = sub.left + item.x * zoom; let y = sub.top + item.y * zoom; let leftFix = +sub.dataset.left || 0; let topFix = +sub.dataset.top || 0; let borderWidth = sub.borderWidth || 0; let borderColor = sub.borderColor; // 如果是圆形图片 if(sub.dataset.radius) { // 绘制圆形的border if(borderWidth) { ctx.beginPath() ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI) ctx.setStrokeStyle(borderColor) ctx.setLineWidth(borderWidth) ctx.stroke() ctx.closePath() } // 绘制圆形图片的阴影 if(sub.boxShadow !== 'none') { ctx.beginPath() ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI) ctx.setFillStyle(borderColor); setBoxShadow(sub.boxShadow); ctx.fill() ctx.closePath() } // 最后绘制圆形图片 ctx.save(); ctx.beginPath(); ctx.arc((x + r), (y + r) - limitTop, r, 0, 2 * Math.PI); ctx.clip(); ctx.drawImage(url, x + leftFix * zoom, y + topFix * zoom, sub.width, sub.height); ctx.closePath(); ctx.restore(); }else { // 常规图片 } } [代码] 如上,块级元素的绘制和基础图形的绘制差异不大,理解起来也很容易,不再多述。 3.3 令人头疼的行内元素的绘制 当wxml的data-type声明为inline-image或者inline-text时,我们认为是行内元素。行内元素的绘制是一个难点,因为元素之前存在关联,所以不得不考虑各种临界情况。下面展开细述。 3.3.1 纯文本换行 对于长度超过一行的行内元素,需要计算出合适的换行位置,下图所示的是两种临界情况: [图片] [图片] 如上图所示,第一种情况为最后一行只有一个文字,第二种情况最后一行的文字长度和宽度相同。虽然长度不同,但都可通过下面代码绘制: [代码] let lineNum = Math.ceil(measureWidth(text) / maxWidth); // 文字行数 let sinleLineLength = Math.floor(text.length / lineNume); // 向下取整,保证多于实际每行字数 let currentIndex = 0; // 记录文字的索引位置 for(let i = 0; i < lineNum; i++) { let offset = 0; // singleLineLength并不是精确的每行文字数,要校正 let endIndex = currentIndex + sinleLineLength + offset; let single = text.substring(currentIndex, endIndex); // 截取本行文字 let singleWidth = measureWidth(single); // 超长时,左移一位,直至正好 while(singleWidth > maxWidth) { offset--; endIndex = currentIndex + sinleLineLength + offset; single = text.substring(currentIndex, endIndex); singleWidth = measureWidth(single); } currentIndex = endIndex; ctx.fillText(single, item.x, item.y + i * style.lineHeight); } // 绘制剩余的 if(currentIndex < text.length) { let last = text.substring(currentIndex, text.length); ctx.fillText(last, item.x, item.y + lineNum * style.lineHeight); } [代码] 为了避免计算太多次,首先算出大致的行数,求出每行的文字数,然后移位索引下标,求出实际的每行的字数,再下移一行继续绘制,直到结束。 3.3.2 非换行的图文混排 [图片] 上图是一个包含表情图片和加粗文字的混排内容,当使用Wxml2Canvas查询元素时,会将第一行的内容分为五部分: 文本内容:这是段文字; 表情图片:发呆表情(非系统表情,image节点展现); 表情图片:发呆表情; 文本内容:这也; 加粗文本内容:是一段文字,这也是文字。 对于这种情况,执行查询computedStyle后,会返回相同的top值。我们把top值相同的元素聚合在一起,认为它们是同一行内容,事实也是如此。因为表情大小的差异以及其他影响,默认规定top值在±2的范围内都是同一行内容。然后将top值的聚合结果按照left的大小从左往右排列,再一一绘制,即可完美还原此种情况。 3.3.3 换行的图文混排 当混排内容出现了换行情况时,如下图所示: [图片] 此时的加粗内容占据了两行,当我们依旧根据top值归类时,却发现加粗文字的left值取的是第二行的left值。这就导致加粗文字和第一部分的文字的top值和left值相同,如果直接绘制,两部分会发生重叠。 为了避免这种尴尬的情况,我们可以利用加粗文字的height值与第一部分文字的height值比较,显然前者是后者的两倍,可以得知加粗部分出现了换行情况,直接将其放在同组top列表的最后位置。换行的部分根据lineHeight下移绘制,同时做记录。 最后一部分的文本内容也出现了换行情况,同样无法得到真正的起始left值,并且其top值与上一部分换行后的top值相同。此时应该将他的left值追加加粗换行部分的宽度,正好得到真正的left值,最后再绘制。 大多数的行内元素的展现形式都能以上述的逻辑完美还原。 六、总结 基于基础图形封装和wxml转换这两种绘制方式,可以满足绝大多数的场景,能够极大地减少工作量,而不需要再关注内部实现。在实际使用中,二者并非孤立存在,而更多的是一起使用。 [图片] 如上图所示,对于列表内容我们利用wxml读取绘制,对于下部的白色区域,不是wxml节点内容,我们可以使用基础图形绘制方式实现。二者的结合更加灵活高效。 目前Wxml2Canvas已经在公司内部开源,不久会放到github上,同时也在不断完善中,旨在实现更多的样式展现与提升稳定性和绘制速度。 如果有更好的建议与想法,请联系我。
2019-02-28