让页面切换,也成为体验的一部分|uni-app x 原生路由插件
让每一次进入与返回,都自然衔接。
kongbai-native-route 原生路由 现已上线。
面向 uni-app x · iOS / Android,把页面转场、手势返回与弹窗交互放进同一套导航体验。打开详情时的层次、滑动返回时的反馈、收起面板后的衔接,都可以做得更细腻。
先看真机,再感受细节
动画有没有合适的节奏,拖动与回弹是否自然,实际操作最直观。
也可以从插件市场导入示例项目,在自己的设备上体验。
01 / 九种转场,让内容有自己的出场方式
日常页面,轻快进入;重点内容,适当强调层次。根据页面的用途选择转场,让动效服务于内容。
横向滑动 · 景深推进 · 层叠上浮
斜向旋入 · 立体翻转 · 中心展开
交叉淡入 · 顶部落入 · 纵向翻入
进入与返回的动画时长可以按需调整,也支持无动画切换。从列表到详情,从浏览到编辑,为不同操作安排合适的节奏。
02 / 跟手返回,把选择留在手指之间
从页面左侧边缘滑动,页面跟随手势响应。完成滑动,返回上一页;中途取消,回到当前页面。
对于仍保留在导航流程中的上一页,返回后可以继续之前的输入与浏览位置。看完详情接着浏览,临时查看其他内容后继续填写,让操作保持连贯。
03 / 三种弹窗,承接不同分量的内容
半屏 · 先展示重点,再展开更多
支持半屏与展开状态切换,以及下拉关闭。适合筛选条件、选项面板、内容预览和轻量编辑,让用户在当前场景中完成操作。
遮罩 · 聚焦眼前这一步
适合信息提示、操作确认与短内容展示。示例提供点击空白区域关闭的交互,用清晰的前后层次突出当前内容。
卡片 · 展开一段独立的操作
拖动顶部区域,卡片随手势变化;松手后,根据操作关闭或回弹。适合详情预览与临时操作面板,让展开和收起都有自然的反馈。
弹窗支持多层打开、逐层返回。 关闭上层后,可以继续操作仍打开的下层内容,保留其中已输入的文字与当前状态。
04 / 深浅外观,与应用保持协调
支持浅色与深色外观切换,覆盖导航栏和弹窗的相关外观,示例项目可直接体验两种主题。
接入业务时,再配合页面自身的文字、背景与组件配色,让导航和内容保持统一。
从示例开始,把体验带进自己的项目
使用 HBuilderX 5.26 + VDOM 打开示例项目,编译原生插件并运行到 iOS 或 Android 真机,即可体验页面转场、手势返回、弹窗与主题切换。
插件市场已提供完整接入说明,包含项目配置、业务页面注册、启动导航、页面跳转与返回。按文档接入后,应用可在启动页就绪时自动进入业务首页。
当前适用于 uni-app x 的 iOS / Android App,暂不支持普通 uni-app、Web、小程序、HarmonyOS 和 Vapor。 平台环境与系统版本要求请查看插件市场说明;两端的手势与外观细节请结合对应演示体验。页面关闭或应用重启后的数据保存,仍由业务自行处理。
把页面之间的衔接,也认真做好。
如果你正在打磨 uni-app x App 的交互细节,欢迎先看演示,再用示例项目体验。
查看 kongbai-native-route 原生路由 · 获取插件与接入文档 →
欢迎在评论区交流使用场景与体验建议。
让每一次进入与返回,都自然衔接。
kongbai-native-route 原生路由 现已上线。
面向 uni-app x · iOS / Android,把页面转场、手势返回与弹窗交互放进同一套导航体验。打开详情时的层次、滑动返回时的反馈、收起面板后的衔接,都可以做得更细腻。
先看真机,再感受细节
动画有没有合适的节奏,拖动与回弹是否自然,实际操作最直观。
也可以从插件市场导入示例项目,在自己的设备上体验。
01 / 九种转场,让内容有自己的出场方式
日常页面,轻快进入;重点内容,适当强调层次。根据页面的用途选择转场,让动效服务于内容。
横向滑动 · 景深推进 · 层叠上浮
斜向旋入 · 立体翻转 · 中心展开
交叉淡入 · 顶部落入 · 纵向翻入
进入与返回的动画时长可以按需调整,也支持无动画切换。从列表到详情,从浏览到编辑,为不同操作安排合适的节奏。
02 / 跟手返回,把选择留在手指之间
从页面左侧边缘滑动,页面跟随手势响应。完成滑动,返回上一页;中途取消,回到当前页面。
对于仍保留在导航流程中的上一页,返回后可以继续之前的输入与浏览位置。看完详情接着浏览,临时查看其他内容后继续填写,让操作保持连贯。
03 / 三种弹窗,承接不同分量的内容
半屏 · 先展示重点,再展开更多
支持半屏与展开状态切换,以及下拉关闭。适合筛选条件、选项面板、内容预览和轻量编辑,让用户在当前场景中完成操作。
遮罩 · 聚焦眼前这一步
适合信息提示、操作确认与短内容展示。示例提供点击空白区域关闭的交互,用清晰的前后层次突出当前内容。
卡片 · 展开一段独立的操作
拖动顶部区域,卡片随手势变化;松手后,根据操作关闭或回弹。适合详情预览与临时操作面板,让展开和收起都有自然的反馈。
弹窗支持多层打开、逐层返回。 关闭上层后,可以继续操作仍打开的下层内容,保留其中已输入的文字与当前状态。
04 / 深浅外观,与应用保持协调
支持浅色与深色外观切换,覆盖导航栏和弹窗的相关外观,示例项目可直接体验两种主题。
接入业务时,再配合页面自身的文字、背景与组件配色,让导航和内容保持统一。
从示例开始,把体验带进自己的项目
使用 HBuilderX 5.26 + VDOM 打开示例项目,编译原生插件并运行到 iOS 或 Android 真机,即可体验页面转场、手势返回、弹窗与主题切换。
插件市场已提供完整接入说明,包含项目配置、业务页面注册、启动导航、页面跳转与返回。按文档接入后,应用可在启动页就绪时自动进入业务首页。
当前适用于 uni-app x 的 iOS / Android App,暂不支持普通 uni-app、Web、小程序、HarmonyOS 和 Vapor。 平台环境与系统版本要求请查看插件市场说明;两端的手势与外观细节请结合对应演示体验。页面关闭或应用重启后的数据保存,仍由业务自行处理。
把页面之间的衔接,也认真做好。
如果你正在打磨 uni-app x App 的交互细节,欢迎先看演示,再用示例项目体验。
查看 kongbai-native-route 原生路由 · 获取插件与接入文档 →
欢迎在评论区交流使用场景与体验建议。
收起阅读 »死磕四年,投资数亿,uni-app做出了比原生渲染还快的跨平台框架
> 原文是首发于开源中国公众号的访谈,各级标题为主持人问题,原文详见:https://mp.weixin.qq.com/s/erfEFl4ZNKMkoa7MViFCpA
背景:
uni-app x 蒸汽模式,发表了比原生渲染还快的benchmark。这让业内大为震惊。
本期请来了DCloud的CEO王安,从设计者的角度聊聊,为什么做这个产品、为什么这么做而不那么做、性能提升的关键在哪里?
1. 跨平台框架赛道,并非当下VC的关注领域,DCloud投入4年,花了几个亿搞 uni-app x,这种执着和冒险的底层逻辑是什么?
我做跨平台已经二十多年了,老骨灰了。我做的产品,虽然也有很大的市场体量,但也一直被开发者们吐槽。
uni-app的第一代产品,在小程序领域是市占率第一的,不管大厂小厂都在用。因为用uni-app开发小程序不会比原生小程序性能差,还能跨平台。
但在App平台,市占率只有百分之十几,主要集中在中小开发者。原因就是App平台的性能不能满足中大开发者的需求。
所以App平台的性能问题,对我们一直是如鲠在喉。
2022年,我们内部讨论 uni-app x 的立项问题,能不能让app像小程序那样,做到与原生开发相比没有性能损失。
其实团队内部,对 uni-app x 的立项是有反对声音的。如果我们守住原有盘子,做好服务和商业化,能让团队成员的日子过的更好点。
但一旦要立项解决跨平台框架的性能问题,这注定不是一个短期能解决的问题,投入肯定不少,机会风险很大。
当时觉得就是不服气,觉得这辈子不能就一直解决不了跨平台的性能问题,活得太憋屈了,想赌一把。
其实当时我也不知道这个项目一做就是4年,我以为2年能搞定。当初如果知道要4年,那承受的压力会非常大,不一定能下得了决心。
2. 这4年中间,有过犹豫和后悔吗?
中间是走了很多弯路啊。有的路走了很久才发现不通,肯定是非常郁闷的。
但并不会因为走错路而后悔。你在登一个没人登过的山,走错路是正常的。
要说后悔的话,从开始就应该建立一个更高效的探路机制,能更快的验证路线的正确性。
这个机制,我们直到2025年才完善,这是我最大的教训吧。
中间有段时间非常焦虑,大约25年初的时候,当时觉得搞了快3年了,不达预期怎么办。
幸好几个月后,25年夏天,一系列技术突破后,终于重拾信心,知道自己搞对了。
3. 你们建的这个高效的探路机制,是什么?
类似实验室。
就是别着急做产品,别着急提代码。
就是先做实验,体系化的、严谨的做实验。
但是然后呢,你又会发现还不能着急做实验,为了能严谨的输出各种实验报告,其实需要做很多基建,你要先投入大量资源去建实验室的基建。
我们以前吃了不少亏,调研报告不准确,误导了技术决策。这都需要一套良好的实验基建、严谨的实验规范流程才能解决。
有很长一段时间,我看不到产品里提交代码,我也不关注这个指标。我每天只看实验报告。
这个过程可能很多公司都没有,大多数公司的技术考核,可能是产品需求的实现工期、完成度、bug数量。
但对于uni-app x蒸汽模式这种产品来讲,实验室机制是它最终能做出来的核心原因之一。
4. 非常延迟满足啊。不做产品先做实验,不做实验先搞批量实验的基建。那这注定不是短期投入。然后我有点好奇,当初是怎么说服团队成员同意这个指标的:比原生渲染还快。
没有。当初如果定一个比原生渲染快2~3倍的目标,别说团队成员了,我自己都不信。
当初的目标,就是参考小程序,uni和原生小程序没有性能差别。然后app也做到和原生app一样,没有性能差别就行。
就这,当时团队成员都不同意,说做不到,最多能做到15%的性能差距,就是比原生慢15%。为了这15%拉扯了半天。
但后来发现,实现到15%的性能差距,仍然困难重重。只能一路往底层追,从系统组件源码、追到系统底层渲染原理,往下又追到编程语言原理,又往下追到硬件层操作原理。
直到你把整个事情都搞明白了,这时候你才发现,传统的原生渲染一层一层套轮子,叠了太多层了,性能优化空间太大了。
不要说达到原生渲染的性能了,比原生渲染快几倍都没问题。
以前如果我diss一个团队成员,说你做的这个东西性能太差了,怎么连原生的同类组件的性能都拼不过。这个人肯定会觉得这个老板有病。
但现在我可以随便diss,在我们团队,做到比原生快并不难,已经是团队共识了。
当然一个新入职的人,还需要一段时间才能适应这种文化。
5. 原生渲染那么多年的优化,真的是流程很长吗?能举个例子吗?
新的挑战者打破旧的,心里是需要这个世界都是草台班子的信念的。虽然这个新的挑战者也会被后来的挑战者视为草台班子。
计算机工业发展了这么多年,长成了庞然大物,轮子一层叠一层,减肥的空间其实挺多的。
我们公司有个面试题,手机左上角的那个返回箭头,怎么绘制是性能最高的。
很多人在项目中使用字体图标,矢量缩放不失真,暗黑能变色,跨平台都能用,不同平台渲染结果一致,听着挺好。
但是一个字体图标,从你写代码加载字体那刻,到屏幕上最终显示出来,经过了非常多环节。
你可能觉得你就写了几行代码,还好吧。但实际上,
- 首先系统需要从IO里加载你的字体库到内存里,
- 然后需要构造字体管理对象,解析字体头文件,确定字体的很多参数。
- 然后要去字体中的众多字里匹配,你想要显示哪个字符。
- 然后要做文字塑型,确定连字等字间距处理。
- 然后做换行、双向文本流(有从左到右的文字、也有从右到左的,还有混合的)。
- 然后做文字测量,把高宽定下来。
- 然后做光栅化或三角形化。
- 然后提交给GPU。
- 最后整体合成上屏。
这里面提到的每个步骤,都有专业的库负责。这些库的源码如果你看过的话,逻辑都非常多。
在Android和鸿蒙上,这些逻辑还涉及原生的kt/arkts和c库的反复交互。
你只看到你写的几行代码,但实际上背后是几十年的文字系统各种库的协作配合。
你知道了背后的流程多长,你肯定想跳过一些流程,比如就一个返回箭头图标,它理应不涉及换行和双向文本流,但这里又有另一个坑。
不同os,上述流程的可控性是不一样的,有的os提供了专门的api可以跳过换行和双向文本流流程,有的os没有提供。
如果你对文字系统有研究兴趣,可以去看看harfbuzz、freetype、icu这些c库的源码,都是几十年的老库了,每个库都是不同的顶级大牛写的。
但作为一个应用开发者,想优化这块,真没有ROI。
5.1. 所以uni蒸汽模式中的返回箭头、文字渲染,都比原生自带的要更快?
是的。首先要投入大量精力研究每个系统的底层,然后优化,然后再给开发者暴露简单易用的跨平台写法。
6. 你们提供了一个benchmark,一个界面中创建了4050个view和text,渲染耗时比原生短2~3倍。这个测试例让很多人不解、甚至不信。不知道这个访谈中,王老师能否透露一些细节原因。比如这个测试例,是因为uni蒸汽的文字系统更快吗?有些人觉得这可能是你们的测试例定向优化,能解释下吗?[^1]
没有定向优化。是反过来的,如何测试一个渲染系统在处理view、text这种基础组件时速度更快?带着这个问题,我们才在一个屏幕内大量创建view和text来做实验。
你想创建多少个、怎么排布,都可以,文字是数字还是汉字都可以,结论都是 uni-app x 蒸汽模式更快。
这种快,来源于很多方面。uni的文字系统真的更快,如果你把4050的例子改一下,在里面放置更多text组件,你会发现uni比原生快了不止2~3倍,会快更多,界面上文字越多,uni的优势越明显。
除了文字系统快,整个template和Style区域,执行的都比原生快。在4050的例子里,是模板里的v-for创建这些view和text,这些代码都是编译成字节码/机器码的,这些高度优化的二进制码性能更高。
uni蒸汽的排版系统也更快,不管是什么组件,view、text还是其他组件,都要排版。uni蒸汽统一是flex排版,这套排版系统的性能也比原生提供的那些排版布局系统快。并且这套排版也不会为4050这种示例做定向优化。
4050这个例子有拍平和非拍平2套测试数据,都是uni蒸汽快。有些人认为uni蒸汽的4050快是拍平的功劳。并不是的,这2套数据之差,已经说明了拍平在这里能提供的价值。
当然拍平也是我们给开发者提供的一个挺好的性能优化方案,让开发者可以方便构造更高性能的UI。一个简单的flatten属性,这种开发方式比原生高效非常多。
7. 有的原生工程师表示不服,他们也可以在原生的基础上优化代码,跑出更快的原生版数据。你怎么看?
我们需要牢记初心,这个测试例比的是view和text的创建速度,不是比4千多个方格子固定排放后如何快速渲染。
uni提供的就是标准的view和text组件,没有定向优化。原生的对比测试例,也不能为了这个4050的测试例而定向优化。
有的原生工程师说,我用draw的方式,只创建1个view,在上面自绘出4050个方格子出来,不测量不排版,所有格子等宽。
那这就是原生为这个测试例定向优化了。这就没法比较原生的view、text,和uni的view、text到底谁快了、排版系统到底谁快了。
如果你基于draw,自己实现文字测量、布局排版,能布任何界面,那其实就是自己做了一套渲染系统。其实这就是 Compose UI 在Android上做的事情。
首先真的做出来这套系统是非常难的,其次做出来性能也很难匹敌uni蒸汽。
Compose UI 的view和text同屏4050测试例,我们是在benchmark里公开过的,比Android原生view还慢,更比不过uni蒸汽。[^1]
如果某个原生工程师有兴趣钻研此道,自己一个人搞是不行的,这是大工程,还是加入DCloud一起干吧,哈哈。
8. 我有个疑惑,为什么操作系统那么多专业的人,不去把原生UI优化的更快呢?给三方这么大的超越空间
我不是操作系统团队的人,我也不知道他们怎么想的。我可以推测下,随便聊了。
可能有的系统厂商,觉得自己已经够快了,没有竞争压力给他们。因为要动这些底层的东西,代价不低,立项时总会有人问花这么多钱能带来什么财务回报?
很多领先的厂商,有一个产业义务,就是推进用户买新设备。这对用户来说听着有点恶心。但产业链的兄弟们持续有钱赚也很重要,大家有钱可以持续创新,推进产业进步。
然后就是组织大了,跨部门协调也难。
比如Android,原生的kotlin是一拨人,各种c库是另一拨人,现有的框子是一层一层叠起来的,想压穿,需要协调非常多人。
还有的技术栈喜欢自举,比如java/kotlin,很多重要的库是java写的、而不是c或汇编写的。当你的产品哲学是自举优先而不是性能优先,就很难把性能做到极致。
你看v8的源码,那不止是优秀的c代码,很多地方要上汇编的,比如json解析,这就是性能优先。
当然了,如果操作系统的人真的愿意好好优化,肯定是可以反超uni蒸汽现在的benchmark数据的。
不过呢,其实uni蒸汽,也还可以再提升性能,只不过我们也一样,觉得快了2~3倍已经足以。现在立项投很多人力财力再让uni蒸汽快4倍5倍,没有性价比。
如果原生优化的更快了,我们也会立项再追。
还是那句话,现代计算机工业这么多年,这个庞然大物减肥的空间挺多的。
9. AI时代,跨平台框架的价值是否要重新看待了。最近shopify放弃react native,转原生。这是不是未来的趋势?你们搞了4年多,这个跨平台框架是不是在AI时代生不逢时了?
AI刚来时,我也有点焦虑,担心AI会颠覆掉跨平台框架。
但后来实践、观察、深入思考,心就定了。
首先看数据,AI Coding后,uni的应用数量进一步增长,并不是下降。
然后看我们内部的实践,我们除了框架,也做应用,比如uni-ai、uni-im,一个是开源的AI聊天客户端,一个是开源的IM系统,我们和我们的开发者沟通,都是在uni-im上。
如果没有 uni-app x 和uniCloud,这些项目在我们内部根本无法立项。是因为实际这2个项目,只有一个前端工程师。
如果要用原生来做,2个应用系统,5个客户端平台,Android、iOS、鸿蒙、web、微信小程序,客户端+服务器+测试,即使每个人都在用ai,最少也是十几个人。
除了开发,还要考虑。不同平台,不但代码不同,测试例、测试工具链也不同。但在uni下,不止代码统一了、测试例也是一套。
uni这边,真的从前到后,从开发到测试,所有平台,只有一个人。他用着AI并行开发,快速完成了2个系统。这生产效率和成本,其他方案真的比不了。
主持人:有的人说,我不要6个人,我一个人就让ai干活,写5个平台的原生代码加服务器和测试例,不行吗?
AI只是工具,它需要人用,需要人背锅。工具不背锅。进度延期、线上出bug,你无法甩锅给AI。只有懂AI写的代码,才能hold住,才能担起责任。
尤其是面向广大用户的、涉及重要权益的系统,AI写的代码有问题,造成线上损失,是需要你担责的。
做一些压力不大的系统,比如自用的、有限人使用的内部管理只读报表,可以不看代码。但真要上线,要面向用户承担责任,那人得把关。
回到上一个问题,我们做的uni-ai,它的性能比原生开发的AI客户端都好,和ChatGPT、豆包、deepseek相比帧率都更稳。
如果使用原生开发,每个平台都达到这个性能水平,那我刚才说的十几个人都还不够,那投资得非常大了。
所以uni系产品带来的价值,生产力,非常大。
AI虽然是个工具,但你让它干活时,它也想用好工具,它不会用好工具它就不是个好AI,对吧。
在AI时代,写代码并不是不需要框架,只是需要好框架。
比如ts、vue,这些都是框架,你也可以让AI去写原始的js,但人们为什么不这么做呢?
因为明显是用ts、vue这些好框架,写代码更轻松、更容易保障性能和质量,也更省时间和token。
在AI时代,并不是跨平台框架没有价值了。而是需要更好的跨平台框架。
一个跨平台框架,只跨2个平台,性能比原生差了不少,那么shopify认为AI降低了我的成本,原生带来了更高性能,这ROI是合适的,就会转原生。
假使,我说的是假使,react native的渲染性能比原生更快,那shopify怎么会切原生去?闲的慌啊。
所以AI时代,那些问题比较多的跨平台框架,不管是性能、质量、跨平台数量,如果做的不好,就会没落。
我个人觉得,AI Coding时代,在App平台,可能未来只有原生和uni-app x。其他跨平台框架估计要走下坡路。
这会和小程序领域类似,拼到最后只剩原生和uni-app了。
我这话说的,听起来有点满,其实我是深入思考过的。
新的编程框架规范,对AI非常不友好,很难冒出来。
老的这几家,比如flutter、react native、CMP,如果能解决做到即跨平台、又不比原生性能差,那自然也不可能被AI淘汰。
但flutter的自渲染,包括CMP在iOS上也是自渲染,这种在混合原生渲染时有硬伤。这个在另一篇文章专门讨论过自渲染和原生渲染的问题。[^2]
而react native,从meta挪到基金会后,这个机制有好有坏,不好的就是很难找到一个强力的人做大改,但如果不大改,它解决不了比原生性能差的问题。
shopify本来是基金会的重要赞助者,出力做了不少关键库,更是给基金会出钱。它的撤出,对react native的打击很大。
当然难归难,也不是绝对的,不知道谁家会天降猛人改变局面。
说回来,我认为,AI会淘汰一些简易、低质量的轮子。但复杂、高质量的轮子,永远都受欢迎。AI想要好轮子。
10. uni-app x 刚出来的时候推的是uts语言,现在蒸汽模式又改成了普通的js/ts。为什么会这样调整?怎么考量的?
uni-app x 立项的时候,是参考我们在小程序上的做法,小程序就是把uni-app的写法,编译成了小程序的原生写法,重编译轻运行时。
那么怎么把uni-app x也编译成各个平台的原生应用?比如在Android上,把uni的写法就编译成一个纯的kotlin应用。
这就需要先做一个基建,跨平台的编译型语言,这就是uts,一个语言可以编译成kotlin、Swift、ArkTS。
一个语言,做出来难,做好更难。
但是语言搞定后,我们发现渲染系统做的不好,拖累了整体表现,又开始研究渲染系统。
语言是一个大关,渲染系统又是一个大关,我们连续磕了2个大难关。
当初立项新版渲染系统时,搭配的语言还是uts。因为当时没有预期蒸汽渲染系统这么快。
新版渲染系统出来后,结果是大幅超过我们预期的。我也没想到这块的优化余地这么大,在这么多测试场景中,都能比原生能快2~3倍。[^1]
然后这个时候AI来了,uts语言作为一个新语言,国外模型不熟悉,也没有模型为uts单独做强化学习,给我们带来了巨大的压力。
其实AI在某种程度上,是不利于创新的。不止uts,其他新的编程语言也一样难。
这逼着我们思考,既然渲染系统能快这么多,那么把语言改回到普通ts/js,合体版本是不是也可以做到比原生快?是不是这样的产品才是AI时代更适合开发者的。
这个时候我们团队内部是转不过来弯的,辛辛苦苦搞了几年uts,投入大量心血,真的要尝试换回js吗?自己的孩子,怎么能不心疼?
我自己也给自己做了很多心理建设。
最后是分期立项,优化跨语言通信。先在鸿蒙和iOS上,确认js+蒸汽渲染仍然可以做到比原生渲染快,然后才全体坚定信心,大力推进Android的js更换。
外部看不到,内部其实经历了一个过渡期才把弯转过来。
优化跨语言通信,这个立项最初目标是js+蒸汽渲染系统,能和原生一样快就行。
最终这个项目的结果,也超过了当初立项的预期。
也得益于这几年对语言底层的持续研究,搞明白很多原理,优化后,跨语言通信只损失了百分之几,但蒸汽渲染系统比原生渲染快200%~300%,
最后整合的效果,基本没变,那百分之几在各种评测实验数据上几乎看不出来,还是比原生快2~3倍。[^1]
跨语言通信有个极限例子,就是canvas,在js中绘制碰撞的小球,绘制逻辑都是在js里。最终这个测试例大家也看到benchmark里的数据了,uni蒸汽下,可以绘制几十万个小球同屏碰撞而不掉帧。
最后js+蒸汽渲染引擎这个方案就定下来了,AI熟悉、开发者门槛低、还能热更新,还比原生渲染快,这不就是最佳方案了吗?夫复何求,对吧。
有最佳方案就行,你过去投入的心血,不要遮蔽住你为用户寻找最佳方案的眼睛。
11. 那uts以后怎么办呢?不管是uts语言,还是你们的HBuilder,都是国产自主创新的代表,最近你们基于vscode搞了HBuilderV,感觉这些国产创新的东西好不容易做出来,要被丢掉吗?
国产化,除非是政府资助,对于一个市场化的产品来讲,还是需要在市场中让用户买单。
我们也会觉得可惜,但以国产创新的理由做技术自嗨,得不到用户的认可,没用。
反正我们自己从来不说:flutter、react native不是国产的,请大家支持国产的uni-app。
uts语言不会消失,我们内部也有大量代码是基于uts的,而且我们内部的uts代码还在增加,它仍然是一个不错的跨平台语言。
会的人可以继续用,不会的人,不学也行,原生插件开发也可以使用原生混编。
其实我们还研究过uts直接编译成机器码,发现可行。只是,做出来容易,但想做好,又得几年、好几亿的投入。
除非是政府主导,一定要搞国产化语言,对于市场主体来讲,这事没有性价比。
编译机器码不难,但编译出的机器码非常高效,这特别难。
我们不能假设只要编译成机器码,性能就一定高。
很多语言都尝试过这事,kotlin也有k/n方案,但同样是机器码,它还得处理gc,它的机器码优化程度,和c语言差太多了。
我们也可以很快拿出一个uts编译机器码的方案,但性能想达到第一梯队,就是达到c、zig的水平,需要很长时间和很多资金投入。
至于HBuilderV,这个产品得感谢AI时代,没有AI迁移,这个产品很难立项。
我们的逻辑是这样,给习惯vscode的开发者提供一种选择,有助于大家更接受uni蒸汽,且在AI的帮助下这个产品的成本可接受,那就做了。
目前HBuilderX和HBuilderV会一起演进。
至于未来,HBuilderX是基于QT的,QT以后不会再用了。
其实我们掌握了优秀的渲染优化技术,在PC上也是一样的,后续我们会出uni蒸汽的pc版,届时也会是最快的跨PC平台渲染引擎,体积也会很小。
在uni蒸汽的pc版出来后,我们会再重构HBuilder。
当然也可能换名字。未来AI持续发展,IDE的传统功能都不一定适用了。可能会是一个全新形态。
11.1 那很期待。至于HBuilderV,你们为什么不做vscode插件,而是和cursor一样整体套壳修改?
大家可能对vscode的插件api的丰富度有过高预期。
当初cursor之所以基于vscode套壳二开,就是因为vscode的插件api不够用,AI所需要的功能,插件无法实现,只能整体二开。
对于HBuilderV也一样,HBuilderX里很简单的一个常用需求,就是同时运行多个平台,在底部开一堆控制台,这功能插件就做不了。
还有各处弹出的界面,比如运行、打包,以及我们也有一个AI产品,叫uni-agent,大量的功能需求,用vscode插件无法实现。只能整体二开。
12. 你刚才提到了c、zig,没有提到rust,现在rust风生水起,bun也从zig迁移到rust了。你们的组件不用rust,还使用c语言吗?
rust在pc上发展的更顺利些,手机上用的要少一些。手机系统的c api、ndk编译链,都是围绕c、c++构建的。
不管是c、zig、rust,核心差别是内存谁来管。
其实放大看,所有语言都有这个终极问题,内存到底怎么管。用这点,可以把所有编程语言放在一个坐标体系里看待。
最开始的c语言,典型的手动管理内存。
但人们觉得,我就是开发业务,你让我研究内存怎么管,还经常泄漏,这门槛太高了,不利于编程普及。
然后就出现了gc,就是垃圾回收,java、js,这些语言凭借一点,你别再操心内存了,专注你的业务就完事,帮助这个世界产生了更多的程序员。
然而gc从来都是有代价的,会影响性能。于是有人又想出点子,能不能让编译器来管内存,运行时不要gc了,然后就设计了一套复杂的借用机制,在编译阶段确保内存安全,这就是rust。
但这套机制,学习门槛很高,为了高性能有时还得用unsafe。
于是又有人想,那能不能还是人手动管理内存,但管理的方便点。zig就出现了。
当然zig不止这一个优势,但通过这个对比,可以简单的明白这些编程语言的定位差异。
就我个人喜好,其实我喜欢zig。当然zig的成熟度、生态确实不如rust。
bun的压力来自于基于它的claude code,用户量巨大,带来了很多边界问题。
但bun的问题,并非zig下不能解。
只不过在zig下,需要投很多人来解。但对于一个AI模型公司来讲,能让AI解更符合他们的飞轮。
当然站在第三方来看,折腾这事干嘛,想求稳,应该直接把bun换成nodejs,而不是把bun从zig改成rust。
13. 你也提到一个成熟的产品,需要处理掉各种边界问题,才能获得开发者的认可。那么uni蒸汽刚出来,是否短期内也面临成熟度问题?
这是肯定的。所有新产品都有成熟过程。对于uni蒸汽来讲,就要想各种办法加速它的成熟。
我可以分享下我们怎么做的。
uni-app x 从2023年起第一个版本就开发了自动化测试系统。
并且 uni-app x 坚持一个原则,修复的每个开发者报的issues,都要配套自动化测试例。每个版本发布,都要过测所有测试例才能发版。
我们专门做了一套issues系统,开发人员在提测issues的时候,表单里就有自动化测试例地址。
这几年来,已经积累几万个自动化测试例,包括功能、性能、UI截图对比、内存泄漏、崩溃等多种维度。
每天晚上,在我们内部的持续集成系统里,都要跑几万个测试例,在各种手机os、各种版本上跑,几十万次,做断言比对、截图对比。。。
uni-app x 蒸汽模式 虽然是2026年推出的,但它每次发版,都得通过 uni-app x 积累的所有自动化测试例。
uni蒸汽的质量管理压力,其实对我们来讲,轻了很多。
以前在vdom模式时,我们还要操心uts语言的质量,现在语言的稳定性交给js引擎处理了。
我们就可以集中精力在渲染系统的质量上。
uni蒸汽还有一个特点:不同平台的组件内部实现代码基本都是一套,都是跨平台的c或uts。所以它的平台差异很小。这也让我们的质量管理压力变小。
精力不分散后,我们也有更多人力投在响应开发者反馈的bug上,现在uni蒸汽的修bug速度还是很快的。
在AI时代,AI其实也在帮助我们把控质量,分析隐患、生成更多自动化测试例。这些都让一个新产品的成熟度比以往更快。
我不能说uni蒸汽已经成熟了,但我认为它的质量是可商用的。
好的,感谢王总的坦诚分享。与王总交流,总能感受到一些新的观点和角度。相信读者们,除了更深度的了解uni相关产品外,还可以从这个访谈中学到一些其他东西,比如一个复杂项目如何立项、如何管理、如何提升性能和质量。更多问题,大家可以留言,我们尽量协调王总回答哈。
没问题。
关联阅读
1. uni-app x 蒸汽模式 性能测试基准报告 Benchmark【鸿蒙版】
2. uni-app x 蒸汽模式性能测试基准报告 Benchmark【 iOS版】
3. uni-app x 蒸汽模式 性能测试基准报告 Benchmark【Android版】
> 原文是首发于开源中国公众号的访谈,各级标题为主持人问题,原文详见:https://mp.weixin.qq.com/s/erfEFl4ZNKMkoa7MViFCpA
背景:
uni-app x 蒸汽模式,发表了比原生渲染还快的benchmark。这让业内大为震惊。
本期请来了DCloud的CEO王安,从设计者的角度聊聊,为什么做这个产品、为什么这么做而不那么做、性能提升的关键在哪里?
1. 跨平台框架赛道,并非当下VC的关注领域,DCloud投入4年,花了几个亿搞 uni-app x,这种执着和冒险的底层逻辑是什么?
我做跨平台已经二十多年了,老骨灰了。我做的产品,虽然也有很大的市场体量,但也一直被开发者们吐槽。
uni-app的第一代产品,在小程序领域是市占率第一的,不管大厂小厂都在用。因为用uni-app开发小程序不会比原生小程序性能差,还能跨平台。
但在App平台,市占率只有百分之十几,主要集中在中小开发者。原因就是App平台的性能不能满足中大开发者的需求。
所以App平台的性能问题,对我们一直是如鲠在喉。
2022年,我们内部讨论 uni-app x 的立项问题,能不能让app像小程序那样,做到与原生开发相比没有性能损失。
其实团队内部,对 uni-app x 的立项是有反对声音的。如果我们守住原有盘子,做好服务和商业化,能让团队成员的日子过的更好点。
但一旦要立项解决跨平台框架的性能问题,这注定不是一个短期能解决的问题,投入肯定不少,机会风险很大。
当时觉得就是不服气,觉得这辈子不能就一直解决不了跨平台的性能问题,活得太憋屈了,想赌一把。
其实当时我也不知道这个项目一做就是4年,我以为2年能搞定。当初如果知道要4年,那承受的压力会非常大,不一定能下得了决心。
2. 这4年中间,有过犹豫和后悔吗?
中间是走了很多弯路啊。有的路走了很久才发现不通,肯定是非常郁闷的。
但并不会因为走错路而后悔。你在登一个没人登过的山,走错路是正常的。
要说后悔的话,从开始就应该建立一个更高效的探路机制,能更快的验证路线的正确性。
这个机制,我们直到2025年才完善,这是我最大的教训吧。
中间有段时间非常焦虑,大约25年初的时候,当时觉得搞了快3年了,不达预期怎么办。
幸好几个月后,25年夏天,一系列技术突破后,终于重拾信心,知道自己搞对了。
3. 你们建的这个高效的探路机制,是什么?
类似实验室。
就是别着急做产品,别着急提代码。
就是先做实验,体系化的、严谨的做实验。
但是然后呢,你又会发现还不能着急做实验,为了能严谨的输出各种实验报告,其实需要做很多基建,你要先投入大量资源去建实验室的基建。
我们以前吃了不少亏,调研报告不准确,误导了技术决策。这都需要一套良好的实验基建、严谨的实验规范流程才能解决。
有很长一段时间,我看不到产品里提交代码,我也不关注这个指标。我每天只看实验报告。
这个过程可能很多公司都没有,大多数公司的技术考核,可能是产品需求的实现工期、完成度、bug数量。
但对于uni-app x蒸汽模式这种产品来讲,实验室机制是它最终能做出来的核心原因之一。
4. 非常延迟满足啊。不做产品先做实验,不做实验先搞批量实验的基建。那这注定不是短期投入。然后我有点好奇,当初是怎么说服团队成员同意这个指标的:比原生渲染还快。
没有。当初如果定一个比原生渲染快2~3倍的目标,别说团队成员了,我自己都不信。
当初的目标,就是参考小程序,uni和原生小程序没有性能差别。然后app也做到和原生app一样,没有性能差别就行。
就这,当时团队成员都不同意,说做不到,最多能做到15%的性能差距,就是比原生慢15%。为了这15%拉扯了半天。
但后来发现,实现到15%的性能差距,仍然困难重重。只能一路往底层追,从系统组件源码、追到系统底层渲染原理,往下又追到编程语言原理,又往下追到硬件层操作原理。
直到你把整个事情都搞明白了,这时候你才发现,传统的原生渲染一层一层套轮子,叠了太多层了,性能优化空间太大了。
不要说达到原生渲染的性能了,比原生渲染快几倍都没问题。
以前如果我diss一个团队成员,说你做的这个东西性能太差了,怎么连原生的同类组件的性能都拼不过。这个人肯定会觉得这个老板有病。
但现在我可以随便diss,在我们团队,做到比原生快并不难,已经是团队共识了。
当然一个新入职的人,还需要一段时间才能适应这种文化。
5. 原生渲染那么多年的优化,真的是流程很长吗?能举个例子吗?
新的挑战者打破旧的,心里是需要这个世界都是草台班子的信念的。虽然这个新的挑战者也会被后来的挑战者视为草台班子。
计算机工业发展了这么多年,长成了庞然大物,轮子一层叠一层,减肥的空间其实挺多的。
我们公司有个面试题,手机左上角的那个返回箭头,怎么绘制是性能最高的。
很多人在项目中使用字体图标,矢量缩放不失真,暗黑能变色,跨平台都能用,不同平台渲染结果一致,听着挺好。
但是一个字体图标,从你写代码加载字体那刻,到屏幕上最终显示出来,经过了非常多环节。
你可能觉得你就写了几行代码,还好吧。但实际上,
- 首先系统需要从IO里加载你的字体库到内存里,
- 然后需要构造字体管理对象,解析字体头文件,确定字体的很多参数。
- 然后要去字体中的众多字里匹配,你想要显示哪个字符。
- 然后要做文字塑型,确定连字等字间距处理。
- 然后做换行、双向文本流(有从左到右的文字、也有从右到左的,还有混合的)。
- 然后做文字测量,把高宽定下来。
- 然后做光栅化或三角形化。
- 然后提交给GPU。
- 最后整体合成上屏。
这里面提到的每个步骤,都有专业的库负责。这些库的源码如果你看过的话,逻辑都非常多。
在Android和鸿蒙上,这些逻辑还涉及原生的kt/arkts和c库的反复交互。
你只看到你写的几行代码,但实际上背后是几十年的文字系统各种库的协作配合。
你知道了背后的流程多长,你肯定想跳过一些流程,比如就一个返回箭头图标,它理应不涉及换行和双向文本流,但这里又有另一个坑。
不同os,上述流程的可控性是不一样的,有的os提供了专门的api可以跳过换行和双向文本流流程,有的os没有提供。
如果你对文字系统有研究兴趣,可以去看看harfbuzz、freetype、icu这些c库的源码,都是几十年的老库了,每个库都是不同的顶级大牛写的。
但作为一个应用开发者,想优化这块,真没有ROI。
5.1. 所以uni蒸汽模式中的返回箭头、文字渲染,都比原生自带的要更快?
是的。首先要投入大量精力研究每个系统的底层,然后优化,然后再给开发者暴露简单易用的跨平台写法。
6. 你们提供了一个benchmark,一个界面中创建了4050个view和text,渲染耗时比原生短2~3倍。这个测试例让很多人不解、甚至不信。不知道这个访谈中,王老师能否透露一些细节原因。比如这个测试例,是因为uni蒸汽的文字系统更快吗?有些人觉得这可能是你们的测试例定向优化,能解释下吗?[^1]
没有定向优化。是反过来的,如何测试一个渲染系统在处理view、text这种基础组件时速度更快?带着这个问题,我们才在一个屏幕内大量创建view和text来做实验。
你想创建多少个、怎么排布,都可以,文字是数字还是汉字都可以,结论都是 uni-app x 蒸汽模式更快。
这种快,来源于很多方面。uni的文字系统真的更快,如果你把4050的例子改一下,在里面放置更多text组件,你会发现uni比原生快了不止2~3倍,会快更多,界面上文字越多,uni的优势越明显。
除了文字系统快,整个template和Style区域,执行的都比原生快。在4050的例子里,是模板里的v-for创建这些view和text,这些代码都是编译成字节码/机器码的,这些高度优化的二进制码性能更高。
uni蒸汽的排版系统也更快,不管是什么组件,view、text还是其他组件,都要排版。uni蒸汽统一是flex排版,这套排版系统的性能也比原生提供的那些排版布局系统快。并且这套排版也不会为4050这种示例做定向优化。
4050这个例子有拍平和非拍平2套测试数据,都是uni蒸汽快。有些人认为uni蒸汽的4050快是拍平的功劳。并不是的,这2套数据之差,已经说明了拍平在这里能提供的价值。
当然拍平也是我们给开发者提供的一个挺好的性能优化方案,让开发者可以方便构造更高性能的UI。一个简单的flatten属性,这种开发方式比原生高效非常多。
7. 有的原生工程师表示不服,他们也可以在原生的基础上优化代码,跑出更快的原生版数据。你怎么看?
我们需要牢记初心,这个测试例比的是view和text的创建速度,不是比4千多个方格子固定排放后如何快速渲染。
uni提供的就是标准的view和text组件,没有定向优化。原生的对比测试例,也不能为了这个4050的测试例而定向优化。
有的原生工程师说,我用draw的方式,只创建1个view,在上面自绘出4050个方格子出来,不测量不排版,所有格子等宽。
那这就是原生为这个测试例定向优化了。这就没法比较原生的view、text,和uni的view、text到底谁快了、排版系统到底谁快了。
如果你基于draw,自己实现文字测量、布局排版,能布任何界面,那其实就是自己做了一套渲染系统。其实这就是 Compose UI 在Android上做的事情。
首先真的做出来这套系统是非常难的,其次做出来性能也很难匹敌uni蒸汽。
Compose UI 的view和text同屏4050测试例,我们是在benchmark里公开过的,比Android原生view还慢,更比不过uni蒸汽。[^1]
如果某个原生工程师有兴趣钻研此道,自己一个人搞是不行的,这是大工程,还是加入DCloud一起干吧,哈哈。
8. 我有个疑惑,为什么操作系统那么多专业的人,不去把原生UI优化的更快呢?给三方这么大的超越空间
我不是操作系统团队的人,我也不知道他们怎么想的。我可以推测下,随便聊了。
可能有的系统厂商,觉得自己已经够快了,没有竞争压力给他们。因为要动这些底层的东西,代价不低,立项时总会有人问花这么多钱能带来什么财务回报?
很多领先的厂商,有一个产业义务,就是推进用户买新设备。这对用户来说听着有点恶心。但产业链的兄弟们持续有钱赚也很重要,大家有钱可以持续创新,推进产业进步。
然后就是组织大了,跨部门协调也难。
比如Android,原生的kotlin是一拨人,各种c库是另一拨人,现有的框子是一层一层叠起来的,想压穿,需要协调非常多人。
还有的技术栈喜欢自举,比如java/kotlin,很多重要的库是java写的、而不是c或汇编写的。当你的产品哲学是自举优先而不是性能优先,就很难把性能做到极致。
你看v8的源码,那不止是优秀的c代码,很多地方要上汇编的,比如json解析,这就是性能优先。
当然了,如果操作系统的人真的愿意好好优化,肯定是可以反超uni蒸汽现在的benchmark数据的。
不过呢,其实uni蒸汽,也还可以再提升性能,只不过我们也一样,觉得快了2~3倍已经足以。现在立项投很多人力财力再让uni蒸汽快4倍5倍,没有性价比。
如果原生优化的更快了,我们也会立项再追。
还是那句话,现代计算机工业这么多年,这个庞然大物减肥的空间挺多的。
9. AI时代,跨平台框架的价值是否要重新看待了。最近shopify放弃react native,转原生。这是不是未来的趋势?你们搞了4年多,这个跨平台框架是不是在AI时代生不逢时了?
AI刚来时,我也有点焦虑,担心AI会颠覆掉跨平台框架。
但后来实践、观察、深入思考,心就定了。
首先看数据,AI Coding后,uni的应用数量进一步增长,并不是下降。
然后看我们内部的实践,我们除了框架,也做应用,比如uni-ai、uni-im,一个是开源的AI聊天客户端,一个是开源的IM系统,我们和我们的开发者沟通,都是在uni-im上。
如果没有 uni-app x 和uniCloud,这些项目在我们内部根本无法立项。是因为实际这2个项目,只有一个前端工程师。
如果要用原生来做,2个应用系统,5个客户端平台,Android、iOS、鸿蒙、web、微信小程序,客户端+服务器+测试,即使每个人都在用ai,最少也是十几个人。
除了开发,还要考虑。不同平台,不但代码不同,测试例、测试工具链也不同。但在uni下,不止代码统一了、测试例也是一套。
uni这边,真的从前到后,从开发到测试,所有平台,只有一个人。他用着AI并行开发,快速完成了2个系统。这生产效率和成本,其他方案真的比不了。
主持人:有的人说,我不要6个人,我一个人就让ai干活,写5个平台的原生代码加服务器和测试例,不行吗?
AI只是工具,它需要人用,需要人背锅。工具不背锅。进度延期、线上出bug,你无法甩锅给AI。只有懂AI写的代码,才能hold住,才能担起责任。
尤其是面向广大用户的、涉及重要权益的系统,AI写的代码有问题,造成线上损失,是需要你担责的。
做一些压力不大的系统,比如自用的、有限人使用的内部管理只读报表,可以不看代码。但真要上线,要面向用户承担责任,那人得把关。
回到上一个问题,我们做的uni-ai,它的性能比原生开发的AI客户端都好,和ChatGPT、豆包、deepseek相比帧率都更稳。
如果使用原生开发,每个平台都达到这个性能水平,那我刚才说的十几个人都还不够,那投资得非常大了。
所以uni系产品带来的价值,生产力,非常大。
AI虽然是个工具,但你让它干活时,它也想用好工具,它不会用好工具它就不是个好AI,对吧。
在AI时代,写代码并不是不需要框架,只是需要好框架。
比如ts、vue,这些都是框架,你也可以让AI去写原始的js,但人们为什么不这么做呢?
因为明显是用ts、vue这些好框架,写代码更轻松、更容易保障性能和质量,也更省时间和token。
在AI时代,并不是跨平台框架没有价值了。而是需要更好的跨平台框架。
一个跨平台框架,只跨2个平台,性能比原生差了不少,那么shopify认为AI降低了我的成本,原生带来了更高性能,这ROI是合适的,就会转原生。
假使,我说的是假使,react native的渲染性能比原生更快,那shopify怎么会切原生去?闲的慌啊。
所以AI时代,那些问题比较多的跨平台框架,不管是性能、质量、跨平台数量,如果做的不好,就会没落。
我个人觉得,AI Coding时代,在App平台,可能未来只有原生和uni-app x。其他跨平台框架估计要走下坡路。
这会和小程序领域类似,拼到最后只剩原生和uni-app了。
我这话说的,听起来有点满,其实我是深入思考过的。
新的编程框架规范,对AI非常不友好,很难冒出来。
老的这几家,比如flutter、react native、CMP,如果能解决做到即跨平台、又不比原生性能差,那自然也不可能被AI淘汰。
但flutter的自渲染,包括CMP在iOS上也是自渲染,这种在混合原生渲染时有硬伤。这个在另一篇文章专门讨论过自渲染和原生渲染的问题。[^2]
而react native,从meta挪到基金会后,这个机制有好有坏,不好的就是很难找到一个强力的人做大改,但如果不大改,它解决不了比原生性能差的问题。
shopify本来是基金会的重要赞助者,出力做了不少关键库,更是给基金会出钱。它的撤出,对react native的打击很大。
当然难归难,也不是绝对的,不知道谁家会天降猛人改变局面。
说回来,我认为,AI会淘汰一些简易、低质量的轮子。但复杂、高质量的轮子,永远都受欢迎。AI想要好轮子。
10. uni-app x 刚出来的时候推的是uts语言,现在蒸汽模式又改成了普通的js/ts。为什么会这样调整?怎么考量的?
uni-app x 立项的时候,是参考我们在小程序上的做法,小程序就是把uni-app的写法,编译成了小程序的原生写法,重编译轻运行时。
那么怎么把uni-app x也编译成各个平台的原生应用?比如在Android上,把uni的写法就编译成一个纯的kotlin应用。
这就需要先做一个基建,跨平台的编译型语言,这就是uts,一个语言可以编译成kotlin、Swift、ArkTS。
一个语言,做出来难,做好更难。
但是语言搞定后,我们发现渲染系统做的不好,拖累了整体表现,又开始研究渲染系统。
语言是一个大关,渲染系统又是一个大关,我们连续磕了2个大难关。
当初立项新版渲染系统时,搭配的语言还是uts。因为当时没有预期蒸汽渲染系统这么快。
新版渲染系统出来后,结果是大幅超过我们预期的。我也没想到这块的优化余地这么大,在这么多测试场景中,都能比原生能快2~3倍。[^1]
然后这个时候AI来了,uts语言作为一个新语言,国外模型不熟悉,也没有模型为uts单独做强化学习,给我们带来了巨大的压力。
其实AI在某种程度上,是不利于创新的。不止uts,其他新的编程语言也一样难。
这逼着我们思考,既然渲染系统能快这么多,那么把语言改回到普通ts/js,合体版本是不是也可以做到比原生快?是不是这样的产品才是AI时代更适合开发者的。
这个时候我们团队内部是转不过来弯的,辛辛苦苦搞了几年uts,投入大量心血,真的要尝试换回js吗?自己的孩子,怎么能不心疼?
我自己也给自己做了很多心理建设。
最后是分期立项,优化跨语言通信。先在鸿蒙和iOS上,确认js+蒸汽渲染仍然可以做到比原生渲染快,然后才全体坚定信心,大力推进Android的js更换。
外部看不到,内部其实经历了一个过渡期才把弯转过来。
优化跨语言通信,这个立项最初目标是js+蒸汽渲染系统,能和原生一样快就行。
最终这个项目的结果,也超过了当初立项的预期。
也得益于这几年对语言底层的持续研究,搞明白很多原理,优化后,跨语言通信只损失了百分之几,但蒸汽渲染系统比原生渲染快200%~300%,
最后整合的效果,基本没变,那百分之几在各种评测实验数据上几乎看不出来,还是比原生快2~3倍。[^1]
跨语言通信有个极限例子,就是canvas,在js中绘制碰撞的小球,绘制逻辑都是在js里。最终这个测试例大家也看到benchmark里的数据了,uni蒸汽下,可以绘制几十万个小球同屏碰撞而不掉帧。
最后js+蒸汽渲染引擎这个方案就定下来了,AI熟悉、开发者门槛低、还能热更新,还比原生渲染快,这不就是最佳方案了吗?夫复何求,对吧。
有最佳方案就行,你过去投入的心血,不要遮蔽住你为用户寻找最佳方案的眼睛。
11. 那uts以后怎么办呢?不管是uts语言,还是你们的HBuilder,都是国产自主创新的代表,最近你们基于vscode搞了HBuilderV,感觉这些国产创新的东西好不容易做出来,要被丢掉吗?
国产化,除非是政府资助,对于一个市场化的产品来讲,还是需要在市场中让用户买单。
我们也会觉得可惜,但以国产创新的理由做技术自嗨,得不到用户的认可,没用。
反正我们自己从来不说:flutter、react native不是国产的,请大家支持国产的uni-app。
uts语言不会消失,我们内部也有大量代码是基于uts的,而且我们内部的uts代码还在增加,它仍然是一个不错的跨平台语言。
会的人可以继续用,不会的人,不学也行,原生插件开发也可以使用原生混编。
其实我们还研究过uts直接编译成机器码,发现可行。只是,做出来容易,但想做好,又得几年、好几亿的投入。
除非是政府主导,一定要搞国产化语言,对于市场主体来讲,这事没有性价比。
编译机器码不难,但编译出的机器码非常高效,这特别难。
我们不能假设只要编译成机器码,性能就一定高。
很多语言都尝试过这事,kotlin也有k/n方案,但同样是机器码,它还得处理gc,它的机器码优化程度,和c语言差太多了。
我们也可以很快拿出一个uts编译机器码的方案,但性能想达到第一梯队,就是达到c、zig的水平,需要很长时间和很多资金投入。
至于HBuilderV,这个产品得感谢AI时代,没有AI迁移,这个产品很难立项。
我们的逻辑是这样,给习惯vscode的开发者提供一种选择,有助于大家更接受uni蒸汽,且在AI的帮助下这个产品的成本可接受,那就做了。
目前HBuilderX和HBuilderV会一起演进。
至于未来,HBuilderX是基于QT的,QT以后不会再用了。
其实我们掌握了优秀的渲染优化技术,在PC上也是一样的,后续我们会出uni蒸汽的pc版,届时也会是最快的跨PC平台渲染引擎,体积也会很小。
在uni蒸汽的pc版出来后,我们会再重构HBuilder。
当然也可能换名字。未来AI持续发展,IDE的传统功能都不一定适用了。可能会是一个全新形态。
11.1 那很期待。至于HBuilderV,你们为什么不做vscode插件,而是和cursor一样整体套壳修改?
大家可能对vscode的插件api的丰富度有过高预期。
当初cursor之所以基于vscode套壳二开,就是因为vscode的插件api不够用,AI所需要的功能,插件无法实现,只能整体二开。
对于HBuilderV也一样,HBuilderX里很简单的一个常用需求,就是同时运行多个平台,在底部开一堆控制台,这功能插件就做不了。
还有各处弹出的界面,比如运行、打包,以及我们也有一个AI产品,叫uni-agent,大量的功能需求,用vscode插件无法实现。只能整体二开。
12. 你刚才提到了c、zig,没有提到rust,现在rust风生水起,bun也从zig迁移到rust了。你们的组件不用rust,还使用c语言吗?
rust在pc上发展的更顺利些,手机上用的要少一些。手机系统的c api、ndk编译链,都是围绕c、c++构建的。
不管是c、zig、rust,核心差别是内存谁来管。
其实放大看,所有语言都有这个终极问题,内存到底怎么管。用这点,可以把所有编程语言放在一个坐标体系里看待。
最开始的c语言,典型的手动管理内存。
但人们觉得,我就是开发业务,你让我研究内存怎么管,还经常泄漏,这门槛太高了,不利于编程普及。
然后就出现了gc,就是垃圾回收,java、js,这些语言凭借一点,你别再操心内存了,专注你的业务就完事,帮助这个世界产生了更多的程序员。
然而gc从来都是有代价的,会影响性能。于是有人又想出点子,能不能让编译器来管内存,运行时不要gc了,然后就设计了一套复杂的借用机制,在编译阶段确保内存安全,这就是rust。
但这套机制,学习门槛很高,为了高性能有时还得用unsafe。
于是又有人想,那能不能还是人手动管理内存,但管理的方便点。zig就出现了。
当然zig不止这一个优势,但通过这个对比,可以简单的明白这些编程语言的定位差异。
就我个人喜好,其实我喜欢zig。当然zig的成熟度、生态确实不如rust。
bun的压力来自于基于它的claude code,用户量巨大,带来了很多边界问题。
但bun的问题,并非zig下不能解。
只不过在zig下,需要投很多人来解。但对于一个AI模型公司来讲,能让AI解更符合他们的飞轮。
当然站在第三方来看,折腾这事干嘛,想求稳,应该直接把bun换成nodejs,而不是把bun从zig改成rust。
13. 你也提到一个成熟的产品,需要处理掉各种边界问题,才能获得开发者的认可。那么uni蒸汽刚出来,是否短期内也面临成熟度问题?
这是肯定的。所有新产品都有成熟过程。对于uni蒸汽来讲,就要想各种办法加速它的成熟。
我可以分享下我们怎么做的。
uni-app x 从2023年起第一个版本就开发了自动化测试系统。
并且 uni-app x 坚持一个原则,修复的每个开发者报的issues,都要配套自动化测试例。每个版本发布,都要过测所有测试例才能发版。
我们专门做了一套issues系统,开发人员在提测issues的时候,表单里就有自动化测试例地址。
这几年来,已经积累几万个自动化测试例,包括功能、性能、UI截图对比、内存泄漏、崩溃等多种维度。
每天晚上,在我们内部的持续集成系统里,都要跑几万个测试例,在各种手机os、各种版本上跑,几十万次,做断言比对、截图对比。。。
uni-app x 蒸汽模式 虽然是2026年推出的,但它每次发版,都得通过 uni-app x 积累的所有自动化测试例。
uni蒸汽的质量管理压力,其实对我们来讲,轻了很多。
以前在vdom模式时,我们还要操心uts语言的质量,现在语言的稳定性交给js引擎处理了。
我们就可以集中精力在渲染系统的质量上。
uni蒸汽还有一个特点:不同平台的组件内部实现代码基本都是一套,都是跨平台的c或uts。所以它的平台差异很小。这也让我们的质量管理压力变小。
精力不分散后,我们也有更多人力投在响应开发者反馈的bug上,现在uni蒸汽的修bug速度还是很快的。
在AI时代,AI其实也在帮助我们把控质量,分析隐患、生成更多自动化测试例。这些都让一个新产品的成熟度比以往更快。
我不能说uni蒸汽已经成熟了,但我认为它的质量是可商用的。
好的,感谢王总的坦诚分享。与王总交流,总能感受到一些新的观点和角度。相信读者们,除了更深度的了解uni相关产品外,还可以从这个访谈中学到一些其他东西,比如一个复杂项目如何立项、如何管理、如何提升性能和质量。更多问题,大家可以留言,我们尽量协调王总回答哈。
没问题。
关联阅读
1. uni-app x 蒸汽模式 性能测试基准报告 Benchmark【鸿蒙版】
2. uni-app x 蒸汽模式性能测试基准报告 Benchmark【 iOS版】
3. uni-app x 蒸汽模式 性能测试基准报告 Benchmark【Android版】
uniappx本地打包微信登录分享提示无提供商
build.gradle.kts新增
buildConfigField("String", "UTSRegisterProviders", "\"[{\\"name\\":\\"weixin\\",\\"service\\":\\"oauth\\",\\"class\\":\\"uts.sdk.modules.DCloudUniOauthWeixin.UniOAuthWeixinProviderImpl\\"},{\\"name\\":\\"weixin\\",\\"service\\":\\"share\\",\\"class\\":\\"uts.sdk.modules.DCloudUniShareWeixin.UniShareWeixinProviderImpl\\"}]\"")
build.gradle.kts新增
buildConfigField("String", "UTSRegisterProviders", "\"[{\\"name\\":\\"weixin\\",\\"service\\":\\"oauth\\",\\"class\\":\\"uts.sdk.modules.DCloudUniOauthWeixin.UniOAuthWeixinProviderImpl\\"},{\\"name\\":\\"weixin\\",\\"service\\":\\"share\\",\\"class\\":\\"uts.sdk.modules.DCloudUniShareWeixin.UniShareWeixinProviderImpl\\"}]\"")
#插件需求# uniapp x实现APP热更新
uniapp x蒸汽模式希望官方出一个 wgt热更新,或是出一个热更新的解决方案,便于APP后期持续开发
uniapp x蒸汽模式希望官方出一个 wgt热更新,或是出一个热更新的解决方案,便于APP后期持续开发
希望官方开放 uniappx 的 页面路由
例如页面跳转 uni.navigateTo
原生的页面转场很多丝滑表现,
例如 安卓里面的转场很丝滑,包含他头部的 标题栏 就像是增量切换一样,而uniappx 和uniapp里面的就像是一块布硬生生的拖过去
iOS 的 页面转场则更多丝滑效果
等等 路由api,就那么几个动画,方式,很基础,很多原生的特性度无法使用,希望官方能把 页面路由的切换开放,提供给我们开发者自定义处理页面的路由跳转
例如页面跳转 uni.navigateTo
原生的页面转场很多丝滑表现,
例如 安卓里面的转场很丝滑,包含他头部的 标题栏 就像是增量切换一样,而uniappx 和uniapp里面的就像是一块布硬生生的拖过去
iOS 的 页面转场则更多丝滑效果
等等 路由api,就那么几个动画,方式,很基础,很多原生的特性度无法使用,希望官方能把 页面路由的切换开放,提供给我们开发者自定义处理页面的路由跳转
收起阅读 »全平台(安卓,iOS,鸿蒙,微信小程序,h5)自定义+动态Tabbar+tabbar页面导航栏样式+tabbr数字角标 +动态主题+3种可选tabbar点击类型
点击查看插件
sunrains-tabbar 自定义 TabBar 插件使用指南
点击体验h5端
点击查看安卓端录屏效果
点击查看安卓端演示安装包
目录
- [1. 基础配置]
- [2. pages.json 配置]
- [3. 动态配置 TabBar]
- [4. 数字角标功能]
- [5. 小程序端特殊配置]
- [6. 主题配置]
- [7. page页跳转自定义tab页]
- [8. 拓展底部tabbar类型,(切换、弹出、进入)页面]
1. 基础配置
App.uvue onLaunch 初始化
import {initDefaultTabItems} from '@/uni_modules/sunrains-tabbar/tabbar-config.uts'
onLaunch(() => {
console.log('App Launch')
initDefaultTabItems()
}
1.1 配置 tabbar-config.uts 文件
在插件根目录 tabbar-config.uts 文件中,配置注册全局tab组件和导航栏设置。
完整示例:
import { setNavbarConfig, NavBarConfig, registerGlobalComponent, setDefaultTabItems, TabItem } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
import { getStoreLoginItems,storeLoginItems } from './utssdk/handle'
//导入所有自定义tab页
import Recommend from '@/pages/home/recommend.uvue'
import Order from '@/pages/order/order.uvue'
import Mine from '@/pages/mine/mine.uvue'
import Detail from '@/pages/detail/detail.uvue'
//注册所有需要切换的tab页或需要弹出的页面 即类型为 tabPage:tab页面 tabPop:弹窗组件
registerGlobalComponent('home', Recommend)
registerGlobalComponent('order', Order)
registerGlobalComponent('mine', Mine)
registerGlobalComponent('detail', Detail)
// 2. 配置导航栏(不设置会使用默认配置)
setNavbarConfig(new NavBarConfig(true, 34, 'bold', '#333333', '#ffffff', "", 88))
//初始化默认tabbar区域按钮
export function initDefaultTabItems(){
/**
*
* 3. 设置默认 Tab 列表(至少设置一个固定的,也可以全部设置,其它可通过请求服务端接口动态配置),
* 或通过网络请求初始化 游客 显示哪些 有 角色的 按角色 初始化
* App.uvue onLaunch 初始化
import {initDefaultTabItems} from '@/uni_modules/sunrains-tabbar/tabbar-config.uts'
onLaunch(() => {
console.log('App Launch')
initDefaultTabItems()
}
*/
let home = new TabItem('home',"", 12, "red", "yellow", "bold", '首页', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
let mine = new TabItem('mine',"", 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
let items = getStoreLoginItems();
//判断是否登录
if(false || items != null){
setDefaultTabItems(items)
}else{
setDefaultTabItems([
home,mine
])
storeLoginItems([home,mine])
}
}
关键步骤说明:
- 导入组件:将所有需要作为 Tab 的页面组件导入
- 注册组件:使用
registerGlobalComponent注册每个组件(第一个参数为唯一标识) - 配置导航栏:可选配置,根据项目需求调整
- 设置默认 Tab:至少配置一个固定 Tab,其余可动态添加
2. pages.json 配置
2.1 必需配置
必须在 pages.json 中配置自定义 TabBar 入口页面(必须放在第一个位置),tab页无需在pages.json中配置:
{
"path": "uni_modules/sunrains-tabbar/index",
"style": {
"navigationStyle": "custom"
}
}
2.2 完整示例
{
"pages": [
{
"path": "uni_modules/sunrains-tabbar/utssdk/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/detail/detail",
"style": {
"navigationBarTitleText": "详情"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app x",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"uniIdRouter": {}
}
注意事项:
- 自定义 TabBar 入口页面必须配置在
pages数组的第一位 - 所有 Tab 页面不需要在
pages中声明 navigationStyle设置为"custom"以隐藏原生导航栏
3. 动态配置 TabBar
3.1 使用场景
当需要根据用户权限、服务端配置等动态调整 TabBar 时,可以在任意页面触发刷新。
3.2 实现示例
以下面文档 mine.uvue 页面为例:
关键点:
- 使用 refreshTabbar方法触发 TabBar 刷新
- 确保所有动态 Tab 对应的组件已在
tabbar-config.uts中注册
4. 数字角标功能
4.1 功能说明
支持为任意 Tab 设置数字角标,用于显示未读消息数、待处理事项等。
特性:
- ✅ 动态设置/更新角标
- ✅ 超过 99 自动显示 "99+"
- ✅ 角标为 0 或 null 时不显示
- ✅ 响应式更新,立即生效
4.2 使用方法
方法一:通过事件设置(推荐)
在任意页面通过 调用 setTabBadge 方法:
import { setTabBadge} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 设置算法 tab 的角标为 5
setTabBadge("order",5)
// 清除角标(设置为 0)
setTabBadge("order",0)
// 设置超过 99 显示 99+
setTabBadge("order",150)
方法二:初始化时设置
在创建 TabItem 时传入 badge 参数:
import { TabItem } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
// 带角标的 tab
new TabItem(
'order',
'算法',
'/static/tab/main2.png',
'/static/tab/main2_active.png'
10 // badge 参数
)
// 不带角标的 tab
new TabItem(
'home',
'首页',
'/static/tab/main1.png',
'/static/tab/main1_active.png'
)
4.3 完整示例
以 文档后面 mine.uvue 页面为例,实现动态设置角标功能:
4.4 实际应用场景
场景 1:算法未读数
// 从服务端获取未读算法数
async function loadUnreadOrderCount() {
const res = await getOrderCount()
setTabBadge("order",res.data.count)
}
// 页面加载时调用
onShow(() => {
loadUnreadOrderCount()
})
场景 2:消息通知
// WebSocket 收到新消息
websocket.onMessage((msg) => {
if (msg.type === 'new_message') {
const currentBadge = getCurrentBadge('message')
setTabBadge("newMessage",currentBadge + 1)
}
})
场景 3:购物车数量
// 添加商品到购物车后更新角标
function addToCart(product: Product) {
cartItems.push(product)
setTabBadge("cart",cartItems.length)
}
5. 小程序端特殊配置
4.1 配置 tab-content-renderer.uvue
注意: 仅在需要支持微信小程序时需要配置,App 端可忽略此步骤。
在 uni_modules/sunrains-tabbar/tab-content-renderer.uvue 中配置:
<<template>
<view style="width: 100%; height: 100%;">
<Recommend v-if="currentTab === 'home'" style="width: 100%; height: 100%;" />
<Order v-if="currentTab === 'order'" style="width: 100%; height: 100%;" />
<Mine v-if="currentTab === 'mine'" style="width: 100%; height: 100%;" />
<Detail v-if="currentTab === 'detail'" style="width: 100%; height: 100%;" />
</view>
</template>
<script setup lang="uts">
import Recommend from '@/pages/home/recommend.uvue'
import Order from '@/pages/order/order.uvue'
import Mine from '@/pages/mine/mine.uvue'
import Detail from '@/pages/detail/detail.uvue'
interface Props {
currentTab: string
}
defineProps({
currentTab:{
type:String
}
})
</script>
配置说明:
- 条件编译:使用
#ifndef MP-WEIXIN和#ifdef MP-WEIXIN区分平台 - App 端:使用
<component :is="...">动态渲染组件 - 小程序端:必须静态导入并声明所有可能的 Tab 组件
- 导入位置:在
#ifdef MP-WEIXIN块内导入组件
6. 主题配置
6.1 主题功能说明
插件支持自定义 TabBar 的主题样式,支持网络背景图、本地图片背景图、纯颜色背景(支持rgba透明)、导航栏、tabbar区域背景、图标大小、图标(支持网络图标)、文字颜色、文字大小、等属性 满足不同项目的视觉需求。
主题特性:
- ✅ 支持全局主题配置
- ✅ 支持动态切换主题
- ✅ 支持自定义主题参数
6.2 初始化时配置主题
在 tabbar-config.uts 文件中配置主题:
import {
setNavbarConfig,
NavBarConfig,
registerGlobalComponent,
setDefaultTabItems,
TabItem,
setTabBarTheme,
TabBarTheme
} from '@/uni_modules/sunrains-tabbar/types.uts'
import { setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
let bgType : BgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/2.png"
//配置导航栏(可选,不设置会使用默认配置)
let navBarConfig : NavBarConfig = new NavBarConfig(
true, // 是否显示导航栏
34, // 字体大小
'bold', // 字体粗细
'#FFA500', // 文字颜色
'',// 背景颜色
"", //背景图片(支持网络图片)
90) // 导航栏高度
let tabbarIcon = new TabbarIcon(
"mine",//自定义组件唯一标识
//非选中图标
"https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4",
//选中图标
"/static/tab/main3_active.png",
24,//图标宽 单位 px
24,//图标高 单位 px
//文字颜色
12,
//非选中文字大小
"green",
//选择文字颜色
"white",
//选择文字粗细
"bold"
)
let tabbarIcons : Map<string,TabbarIcon> = new Map();
tabbarIcons.set("mine",tabbarIcon)
let tabbarStyle : TabbarStyle = new TabbarStyle(
//tabbar区域高度
120,
//tabbar区域背景
"rgba(21, 20, 31, 0.8)",
//tabbar图标文字样式
tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
6.3 动态修改主题
在任意页面通过调用 setTabPageTheme 方法动态修改主题:
import { setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 动态修改背景色和选中颜色
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
6.4 在页面中动态切换主题
以 mine.uvue 页面为例,实现主题切换功能:
<template>
<scroll-view direction="vertical" class="mine-page"
show-scrollbar = "false"
enable-back-to-top="true"
refresher-enabled="true"
refresher-default-style="none"
refresher-background="rgba(232, 201, 183, 0.5)"
:refresher-triggered="data.refreshing2"
refresher-max-drag-distance="200px"
@refresherpulling="onRefresherpulling2"
@refresherrefresh="onRefresherrefresh2"
@refresherrestore="onRefresherrestore2"
>
<uni-refresh-box
slot="refresher"
:pulling-distance="data.pullingDistance2"
:refreshing="data.refreshing2"
loading-class="loading-dark"
text-class="uni-text-class-buildin"
style="flex-direction: column;height: 46px;padding-top: 6px;"
pulling-text="继续下拉可刷新"
loosing-text="释放后会刷新"
loading-text="奋力加载中..."
/>
<view class="page-title">
<text style="color:#ff4d40">动态设置中心</text>
</view>
<view class="tabbar">
<text class="title-text">tabbar设置</text>
<button class="tabbar-btn" @click="refresh1">动态tabbar(我的)</button>
<button class="tabbar-btn" @click="refresh2">动态tabbar(首页、我的)</button>
<button class="tabbar-btn" @click="refresh3">动态tabbar(算法、我的)</button>
<button class="tabbar-btn" @click="refresh4">动态tabbar(我的、算法、首页、弹出详情、进入详情)</button>
<text class="title-text">设置角标</text>
<input class="input-item" v-model="tabbar" placeholder="请输入tabbar" />
<input class="input-item" v-model="badge" placeholder="请输入角标" />
<button class="tabbar-btn" @click="setBadge">确定</button>
<button class="tabbar-btn" @click="theme">主题1</button>
<button class="tabbar-btn" @click="theme2">主题2</button>
<button class="tabbar-btn" @click="theme3">主题3</button>
<button class="tabbar-btn" @click="clear">clear</button>
<!-- <button class="tabbar-btn" type="default" @click="refresh">刷新</button> -->
<!-- <button type="default" @click="cPop">返回</button> -->
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref,onMounted,onUnmounted } from 'vue'
import { TabItem, GLOBAL_COMPONENT_REGISTRY, CustomTheme, TabbarIcon, TabbarStyle, NavBarConfig } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
import { setTabBadge,setTabbarColorHeight,refreshTabbar ,setTabPageTheme,closeTabPop,clearStorage} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
function cPop(){
closeTabPop()
}
onMounted(() => {
console.log('【组件挂载====mine===】')
})
onUnmounted(() => {
console.log('【组件销毁====mine===】')
})
// 用于自动化测试的数据暴露 - 使用 reactive 将所有 ref 状态集中暴露
const data = reactive({
refreshing2: false,
pullingDistance2: 0
})
function onRefresherpulling2(e: RefresherEvent) {
data.pullingDistance2 = e.detail.dy
}
const refreshing = ref(false)
function onRefresherrefresh2() {
data.refreshing2 = true
console.log('触发刷新')
setTimeout(() => {
data.refreshing2 = false
console.log('刷新完成')
}, 1500)
}
function onRefresherrestore2() {
data.pullingDistance2 = 0
}
function onRefresherabort2() {
data.pullingDistance2 = 0
}
// 上拉触底加载
const loadMore = () => {
console.log("滚动到底部", "info")
}
function clear() {
clearStorage("all")
}
const bgColor = ref<string>('pink')
const tabHeight = ref<string>('120')
const badge = ref<string>('0')
const ss = ref<any>("");
// tab 选项配置
const tabbar = ref<string | null>(null)
const selectedTab = ref<string>('mine')
const selectedTabIndex = ref<number>(0)
function setBadge() {
//动态设置指定tabbar 角标
const badgeNum = parseInt(badge.value)
if (isNaN(badgeNum)) {
uni.showToast({
title: '请输入有效数字',
icon: 'none'
})
return
}
setTabBadge(tabbar.value,badgeNum)
uni.showToast({
title: `已设置 ${tabbar.value} 角标为 ${badgeNum}`,
icon: 'success'
})
}
function setStyle() {
setTabbarColorHeight(bgColor.value,parseInt(tabHeight.value) )
}
function refresh1() {
console.log("动态tabbar(我的)")
refreshTabbar([
new TabItem('mine', "",12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
]);
}
function refresh2() {
console.log("动态tabbar(首页、我的)")
refreshTabbar([
new TabItem('home', "",12, "red", "yellow", "bold", '首页', '/static/tab/main1.png', '/static/tab/main1_active.png',24,24,"tabPage"),
new TabItem('mine', "",12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
])
}
function refresh3() {
console.log("动态tabbar(算法、我的)")
refreshTabbar( [
new TabItem('order', "",12, "red", "yellow", "bold", '算法', '/static/tab/main2.png', '/static/tab/main2_active.png',24,24,"tabPage"),
new TabItem('mine', "",12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
])
}
function refresh4() {
console.log("动态tabbar(我的、算法、首页、弹出详情、进入详情)")
refreshTabbar([
new TabItem('mine', "",12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage"),
new TabItem('order', "",12, "red", "yellow", "bold", '算法', '/static/tab/main2.png', '/static/tab/main2_active.png',24,24,"tabPage"),
new TabItem('home', "",12, "red", "yellow", "bold", '首页', '/static/tab/main1.png',
'https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4',
24,24,"tabPage"
),
new TabItem('detail', "",12, "red", "yellow", "bold", '弹出详情', '/static/tab/main1.png',
'https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4',
24,24,"tabPop"
),
new TabItem('detail', "/pages/detail/detail",12, "red", "yellow", "bold", '进入详情', '/static/tab/main1.png',
'https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4',
24,24,"page"
)
])
}
function theme() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/2.png"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(true, 34, 'bold', '#FFA500', "rgba(191, 191, 191, 0.5)", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4",
"/static/tab/main3_active.png",
24,24,
12,
"green",
"white",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
let tabbarStyle : TabbarStyle = new TabbarStyle(120, "rgba(21, 20, 31, 0.8)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function theme2() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/sm.jpg"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(false, 34, 'bold', '#0c0c0c', "", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"/static/aj.png",
"/static/aj1.png",
24,24,
20,
"green",
"red",
"bold"
)
let tabbarIcon1 = new TabbarIcon("home",
"https://static-mp-4c53170b-e5f8-453b-a634-afcb5cf07d59.next.bspapp.com/img/icon/豪横.gif",
"https://static-mp-4c53170b-e5f8-453b-a634-afcb5cf07d59.next.bspapp.com/img/icon/豪横.gif",
50,24,
16,
"black",
"yellow",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
tabbarIcons.set("home", tabbarIcon1)
let tabbarStyle : TabbarStyle = new TabbarStyle(120, "rgba(21, 20, 31, 0.4)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function theme3() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "https://static-mp-4c53170b-e5f8-453b-a634-afcb5cf07d59.next.bspapp.com/img/bizhi/端午1.png"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(true, 34, 'bold', '#0c0c0c', "", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"/static/aj.png",
"/static/aj1.png",
24,24,
16,
"white",
"red",
"bold"
)
let tabbarIcon1 = new TabbarIcon("home",
"/static/sy1.png",
"/static/sy.png",
50,24,
16,
"white",
"red",
"bold"
)
let tabbarIcon2 = new TabbarIcon("detail",
"/static/tab/main2_active.png",
"/static/tab/main2_active.png",
24,24,
16,
"red",
"red",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
tabbarIcons.set("home", tabbarIcon1)
tabbarIcons.set("detail", tabbarIcon2)
let tabbarStyle : TabbarStyle = new TabbarStyle(150, "rgba(10, 13, 31, 0.8)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function init() {
uni.stopPullDownRefresh()
console.log('stop onPullDownRefresh=====');
}
onPullDownRefresh(() => {
console.log('onPullDownRefresh=====');
init()
})
function refresh() {
uni.startPullDownRefresh({
success: () => {
console.log("1111")
}
});
init()
}
</script>
<style scoped lang="scss">
.mine-page {
height: 100%;
// border: 12px solid red;
margin: 5px;
}
.page-title {
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.title-text {
font-size: 18px;
font-weight: bold;
color: #333;
}
.tabbar {
// border: 2px solid cyan;
padding: 15px;
display: flex;
flex-direction: column;
}
.input-item {
height: 45px;
border: 1px solid #ccc;
border-radius: 6px;
padding-left: 10px;
background: #fff;
margin: 10px;
padding-left: 20px;
}
.picker-item {
height: 45px;
border: 1px solid #ccc;
border-radius: 6px;
background: #fff;
margin: 10px;
display: flex;
align-items: center;
}
.picker-display {
padding-left: 20px;
font-size: 16px;
color: #333;
}
.tabbar-btn {
margin-bottom: 10px;
border-radius: 8px;
background: #f5f5f5;
border: 1px solid #e5e5e5;
padding: 16px 0;
font-size: 16px;
}
.tabbar-btn:last-child {
margin-bottom: 0;
}
</style>
6.5 主题配置注意事项
- 颜色格式:颜色值必须为有效的 CSS 颜色格式(如
#ffffff、rgb(255,255,255)等) - tabbar区域高度单位:高度值为数字类型,单位为 rpx
- 动态修改:通过
uni.$emit('setTheme', new CustomTheme(bgType, bg, navBarConfig, tabbarStyle))动态修改的主题会立即生效
7 page页跳转自定义tab页
使用 pageToTab 方法 跳转到tab页
以 detail.uvue 页面为例,实现主题切换功能:
<template>
<view class="detail-root">
<view class="detail-content">
<!--
<view>
<input v-model="content" placeholder="请输入。。" />
<text>{{sbase64}}</text>
<text>{{base64yostr}}</text>
</view> -->
<view class = "con">
<view class = "com">
<text>网络请求</text>
</view>
<view>
<button class="btn" type="primary" @click="req">访问testvoid</button>
<button class="btn" type="primary" @click="reqSm2">访问testSm2</button>
<button class="btn" type="primary" @click="reqSm4">访问testSm4</button>
<button class="btn" type="primary" @click="reqSm2Sm4">访问testreqSm2Sm4</button>
</view>
</view>
<view class = "con">
<view class = "com">
<text>跳转tab页</text>
</view>
<view>
<input style="height: 50px; border:1px solid grey;margin: 5px 0;" v-model="addr" placeholder="输入跳转tabbar key"/>
<button type="warn" @click="tz">跳转tab页</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import { stringToBase64, base64ToString, ReqApi } from "@/uni_modules/sunrains-request"
import { Response, FrontRequestVo } from "@/uni_modules/sunrains-common-type"
import { CustFailError, getComErrorInfo } from "@/uni_modules/sunrains-common-err"
import { pageToTab} from "@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts"
onMounted(() => {
console.log('【组件挂载====detail===】')
})
onUnmounted(() => {
console.log('【组件销毁====detail===】')
})
const addr = ref("")
function tz(){
if(addr.value.length<1){
uni.showToast({
title: '请输入跳转的tabbar key'
});
}else{
pageToTab(addr.value)
}
}
function fh(){
uni.navigateBack()
}
let priKey = "4a4a1d48fc284fe2fc43970641880554aed2f69fdfbea63257ea417af4e65802"
let pubKey = "0460e86263ec52b38a3da24c7a605055d14f8aa5c7855e4f4e19819f4f8a5b72181afd7aa4ab19255eb871eaedaba30fa18cd5c71693d3d76b030a04969bd1edfe"
const BASEURL = "http://192.168.1.9:8081"
type IRootType = {
name : string;
age : number;
}
type Builds = {
buildingNo : string,
sortNum : number,
buildAreaId : string,
id : string
}
async function req() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'GET',
url: BASEURL + "/test/get/cs1",
data: data,
header: null,
reqFlag: "void",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqvoid==", res.data)
// console.log("####==",res)
} catch (err) {
uni.showToast({
title: err.message ?? "请求异常",
icon: "error"
})
console.log("-----", err)
return
};
}
//"sign"|"sm4Encrypt"|"signwithenSM2-Sm4Encrypt"|"void";
async function reqSm2() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'GET',
url: BASEURL + "/test/get/cs1",
data: data,
header: null,
reqFlag: "sign",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm2==", res.data)
} catch (err) {
uni.showToast({
title: err.message ?? "请求异常",
icon: "error"
})
//#ifndef APP-ANDROID
const errMsg = (err as any).errMsg ?? "未知错误";
const errCode = (err as any).errCode ?? -1;
console.log('标准化错误码:', errMsg);
console.log('标准化错误信息:', errCode);
//#else
if (err instanceof UniError) {
let sa = err as UniError
console.log('sa==:', sa);
}
//#endif
console.log("-----", err)
return
};
}
async function reqSm4() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'POST',
url: BASEURL + "/test/post/cs1",
data: data,
header: null,
reqFlag: "sm4Encrypt",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm4==", res.data)
} catch (err) {
console.log("####reqSm4 err==", err)
uni.showToast({
title: err.message??"",
icon: "error"
})
return
};
}
async function reqSm2Sm4() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'POST',
url: BASEURL + "/test/post/cs1",
data: data,
header: null,
reqFlag: "signwithenSM2-Sm4Encrypt",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm2Sm4==", res.data)
} catch (err) {
console.log("-----", err)
return
};
}
const orderId = ref<string>('')
const orderStatus = ref<string>('')
const content = ref<string>('')
const sbase64 = computed(() => {
return stringToBase64(content.value)
})
const base64yostr = computed(() => {
return base64ToString(sbase64.value)
})
onLoad((options) => {
const opts = options as UTSJSONObject
const id = options?.["orderId"] as string | null
const status = options?.["status"] as string | null
if (id != null) {
orderId.value = id
}
if (status != null) {
orderStatus.value = status
}
console.log(`接收算法参数:ID=${orderId.value},状态=${orderStatus.value}`)
})
const goBack = () => {
uni.navigateBack()
}
function init(){
// uni.stopPullDownRefresh()
// console.log('stop onPullDownRefresh=====');
}
// onPullDownRefresh(() => {
// console.log('onPullDownRefresh=====');
// init()
// })
function refresh(){
uni.removeStorageSync("LOGINTABBARITEMS")
// uni.startPullDownRefresh({
// success:()=>{
// console.log("1111")
// }
// });
// init()
}
</script>
<style scoped>
.detail-root {
/* flex: 1;
display: flex;
flex-direction: column; */
background-color: #fff;
}
.status-bar {
height: 44px;
background-color: #ffffff;
}
.detail-header {
height: 44px;
background-color: #fff;
flex-direction: row;
align-items: center;
padding-left: 15px;
padding-right: 15px;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #eee;
}
.back-icon {
font-size: 18px;
color: #007aff;
margin-right: 10px;
}
.detail-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.detail-content {
flex: 1;
padding: 20px;
}
.detail-item {
flex-direction: row;
margin-bottom: 20px;
align-items: center;
}
.label {
font-size: 15px;
color: #666;
width: 80px;
}
.value {
font-size: 15px;
color: #333;
font-weight: bold;
}
.detail-desc {
margin-top: 30px;
}
.com{
display:flex;
align-items: center;
margin:10px 0;
}
.con{
margin:10px 0;
padding:10px;
background-color: #e3eda1;
}
.btn{
margin: 5px;
}
</style>
8 拓展底部tabbar类型,(切换、弹出、进入)页面
let mine = new TabItem(
'mine', //组件或页面唯一标识
"", //组件或页面路径 当类型为 page 时不可为空,其它传空字符串即可
12,
"red",
"yellow",
"bold",
'我的',
'/static/tab/main3.png',
'/static/tab/main3_active.png',
24,//图标宽
24,//图标高
"tabPage" //tabbar 类型 tabPage:tab页面 tabPop:弹窗组件 page:普通页面
)
注:
类型为 tabPop 打开页面 可 右滑手势(当手机开启右滑手势时)关闭、 或点击虚拟返回按键关闭 或 页面中调用方法 closeTabPop()关闭
方法导入 import { pageToTab} from "@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts"
⚠️ 常见问题
Q1: 为什么小程序端需要单独配置?
A: 微信小程序不支持动态组件渲染,必须静态声明所有可能使用的组件。
Q2: 动态 Tab 刷新失败怎么办?
A: 检查以下几点:
- 组件是否在
tabbar-config.uts中正确注册 - 是否正确使用了
GLOBAL_COMPONENT_REGISTRY.get()获取组件 - TabItem 的参数是否完整且类型正确
Q3: 导航栏配置不生效?
A: 确保 setNavbarConfig() 在 setDefaultTabItems() 之前调用,或在应用启动时尽早调用。
Q4: 如何设置数字角标?
A: 使用 uni.$emit('setTabBadge', {"key": "tab_key", "badge": number}) 即可,详见第 4 章。
Q5: 角标设置后不显示怎么办?
A: 检查以下几点:
- badge 值是否大于 0(0 或负数不显示)
- tab key 是否正确(必须与 TabItem 的 key 一致)
- 是否重新赋值了整个数组(直接修改对象属性不会触发响应式)
Q6: 退出登录如何清除 tabbar相关缓存
A: 调用 noticetab.uts里的 clearStorage 方法
/**
* 清除tabbar相关缓存
* all 所有
* theme 主题
* tabbarItems 所有tabbar
* badge 角标
*/
export function clearStorage(type:string){
switch (type){
case "theme":
uni.removeStorageSync("sunrains-tabbar-customtheme")
break;
case "tabbarItems":
uni.removeStorageSync("sunrains-tabbar-logintabbaritems")
break;
case "badge":
uni.removeStorageSync("sunrains-tabbar-tabbarbadges")
break;
default:
uni.removeStorageSync("sunrains-tabbar-customtheme")
uni.removeStorageSync("sunrains-tabbar-logintabbaritems")
uni.removeStorageSync("sunrains-tabbar-tabbarbadges")
}
}
点击查看插件
sunrains-tabbar 自定义 TabBar 插件使用指南
点击体验h5端
点击查看安卓端录屏效果
点击查看安卓端演示安装包
目录
- [1. 基础配置]
- [2. pages.json 配置]
- [3. 动态配置 TabBar]
- [4. 数字角标功能]
- [5. 小程序端特殊配置]
- [6. 主题配置]
- [7. page页跳转自定义tab页]
- [8. 拓展底部tabbar类型,(切换、弹出、进入)页面]
1. 基础配置
App.uvue onLaunch 初始化
import {initDefaultTabItems} from '@/uni_modules/sunrains-tabbar/tabbar-config.uts'
onLaunch(() => {
console.log('App Launch')
initDefaultTabItems()
}
1.1 配置 tabbar-config.uts 文件
在插件根目录 tabbar-config.uts 文件中,配置注册全局tab组件和导航栏设置。
完整示例:
import { setNavbarConfig, NavBarConfig, registerGlobalComponent, setDefaultTabItems, TabItem } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
import { getStoreLoginItems,storeLoginItems } from './utssdk/handle'
//导入所有自定义tab页
import Recommend from '@/pages/home/recommend.uvue'
import Order from '@/pages/order/order.uvue'
import Mine from '@/pages/mine/mine.uvue'
import Detail from '@/pages/detail/detail.uvue'
//注册所有需要切换的tab页或需要弹出的页面 即类型为 tabPage:tab页面 tabPop:弹窗组件
registerGlobalComponent('home', Recommend)
registerGlobalComponent('order', Order)
registerGlobalComponent('mine', Mine)
registerGlobalComponent('detail', Detail)
// 2. 配置导航栏(不设置会使用默认配置)
setNavbarConfig(new NavBarConfig(true, 34, 'bold', '#333333', '#ffffff', "", 88))
//初始化默认tabbar区域按钮
export function initDefaultTabItems(){
/**
*
* 3. 设置默认 Tab 列表(至少设置一个固定的,也可以全部设置,其它可通过请求服务端接口动态配置),
* 或通过网络请求初始化 游客 显示哪些 有 角色的 按角色 初始化
* App.uvue onLaunch 初始化
import {initDefaultTabItems} from '@/uni_modules/sunrains-tabbar/tabbar-config.uts'
onLaunch(() => {
console.log('App Launch')
initDefaultTabItems()
}
*/
let home = new TabItem('home',"", 12, "red", "yellow", "bold", '首页', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
let mine = new TabItem('mine',"", 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
let items = getStoreLoginItems();
//判断是否登录
if(false || items != null){
setDefaultTabItems(items)
}else{
setDefaultTabItems([
home,mine
])
storeLoginItems([home,mine])
}
}
关键步骤说明:
- 导入组件:将所有需要作为 Tab 的页面组件导入
- 注册组件:使用
registerGlobalComponent注册每个组件(第一个参数为唯一标识) - 配置导航栏:可选配置,根据项目需求调整
- 设置默认 Tab:至少配置一个固定 Tab,其余可动态添加
2. pages.json 配置
2.1 必需配置
必须在 pages.json 中配置自定义 TabBar 入口页面(必须放在第一个位置),tab页无需在pages.json中配置:
{
"path": "uni_modules/sunrains-tabbar/index",
"style": {
"navigationStyle": "custom"
}
}
2.2 完整示例
{
"pages": [
{
"path": "uni_modules/sunrains-tabbar/utssdk/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/detail/detail",
"style": {
"navigationBarTitleText": "详情"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app x",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"uniIdRouter": {}
}
注意事项:
- 自定义 TabBar 入口页面必须配置在
pages数组的第一位 - 所有 Tab 页面不需要在
pages中声明 navigationStyle设置为"custom"以隐藏原生导航栏
3. 动态配置 TabBar
3.1 使用场景
当需要根据用户权限、服务端配置等动态调整 TabBar 时,可以在任意页面触发刷新。
3.2 实现示例
以下面文档 mine.uvue 页面为例:
关键点:
- 使用 refreshTabbar方法触发 TabBar 刷新
- 确保所有动态 Tab 对应的组件已在
tabbar-config.uts中注册
4. 数字角标功能
4.1 功能说明
支持为任意 Tab 设置数字角标,用于显示未读消息数、待处理事项等。
特性:
- ✅ 动态设置/更新角标
- ✅ 超过 99 自动显示 "99+"
- ✅ 角标为 0 或 null 时不显示
- ✅ 响应式更新,立即生效
4.2 使用方法
方法一:通过事件设置(推荐)
在任意页面通过 调用 setTabBadge 方法:
import { setTabBadge} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 设置算法 tab 的角标为 5
setTabBadge("order",5)
// 清除角标(设置为 0)
setTabBadge("order",0)
// 设置超过 99 显示 99+
setTabBadge("order",150)
方法二:初始化时设置
在创建 TabItem 时传入 badge 参数:
import { TabItem } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
// 带角标的 tab
new TabItem(
'order',
'算法',
'/static/tab/main2.png',
'/static/tab/main2_active.png'
10 // badge 参数
)
// 不带角标的 tab
new TabItem(
'home',
'首页',
'/static/tab/main1.png',
'/static/tab/main1_active.png'
)
4.3 完整示例
以 文档后面 mine.uvue 页面为例,实现动态设置角标功能:
4.4 实际应用场景
场景 1:算法未读数
// 从服务端获取未读算法数
async function loadUnreadOrderCount() {
const res = await getOrderCount()
setTabBadge("order",res.data.count)
}
// 页面加载时调用
onShow(() => {
loadUnreadOrderCount()
})
场景 2:消息通知
// WebSocket 收到新消息
websocket.onMessage((msg) => {
if (msg.type === 'new_message') {
const currentBadge = getCurrentBadge('message')
setTabBadge("newMessage",currentBadge + 1)
}
})
场景 3:购物车数量
// 添加商品到购物车后更新角标
function addToCart(product: Product) {
cartItems.push(product)
setTabBadge("cart",cartItems.length)
}
5. 小程序端特殊配置
4.1 配置 tab-content-renderer.uvue
注意: 仅在需要支持微信小程序时需要配置,App 端可忽略此步骤。
在 uni_modules/sunrains-tabbar/tab-content-renderer.uvue 中配置:
<<template>
<view style="width: 100%; height: 100%;">
<Recommend v-if="currentTab === 'home'" style="width: 100%; height: 100%;" />
<Order v-if="currentTab === 'order'" style="width: 100%; height: 100%;" />
<Mine v-if="currentTab === 'mine'" style="width: 100%; height: 100%;" />
<Detail v-if="currentTab === 'detail'" style="width: 100%; height: 100%;" />
</view>
</template>
<script setup lang="uts">
import Recommend from '@/pages/home/recommend.uvue'
import Order from '@/pages/order/order.uvue'
import Mine from '@/pages/mine/mine.uvue'
import Detail from '@/pages/detail/detail.uvue'
interface Props {
currentTab: string
}
defineProps({
currentTab:{
type:String
}
})
</script>
配置说明:
- 条件编译:使用
#ifndef MP-WEIXIN和#ifdef MP-WEIXIN区分平台 - App 端:使用
<component :is="...">动态渲染组件 - 小程序端:必须静态导入并声明所有可能的 Tab 组件
- 导入位置:在
#ifdef MP-WEIXIN块内导入组件
6. 主题配置
6.1 主题功能说明
插件支持自定义 TabBar 的主题样式,支持网络背景图、本地图片背景图、纯颜色背景(支持rgba透明)、导航栏、tabbar区域背景、图标大小、图标(支持网络图标)、文字颜色、文字大小、等属性 满足不同项目的视觉需求。
主题特性:
- ✅ 支持全局主题配置
- ✅ 支持动态切换主题
- ✅ 支持自定义主题参数
6.2 初始化时配置主题
在 tabbar-config.uts 文件中配置主题:
import {
setNavbarConfig,
NavBarConfig,
registerGlobalComponent,
setDefaultTabItems,
TabItem,
setTabBarTheme,
TabBarTheme
} from '@/uni_modules/sunrains-tabbar/types.uts'
import { setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
let bgType : BgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/2.png"
//配置导航栏(可选,不设置会使用默认配置)
let navBarConfig : NavBarConfig = new NavBarConfig(
true, // 是否显示导航栏
34, // 字体大小
'bold', // 字体粗细
'#FFA500', // 文字颜色
'',// 背景颜色
"", //背景图片(支持网络图片)
90) // 导航栏高度
let tabbarIcon = new TabbarIcon(
"mine",//自定义组件唯一标识
//非选中图标
"https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4",
//选中图标
"/static/tab/main3_active.png",
24,//图标宽 单位 px
24,//图标高 单位 px
//文字颜色
12,
//非选中文字大小
"green",
//选择文字颜色
"white",
//选择文字粗细
"bold"
)
let tabbarIcons : Map<string,TabbarIcon> = new Map();
tabbarIcons.set("mine",tabbarIcon)
let tabbarStyle : TabbarStyle = new TabbarStyle(
//tabbar区域高度
120,
//tabbar区域背景
"rgba(21, 20, 31, 0.8)",
//tabbar图标文字样式
tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
6.3 动态修改主题
在任意页面通过调用 setTabPageTheme 方法动态修改主题:
import { setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 动态修改背景色和选中颜色
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
6.4 在页面中动态切换主题
以 mine.uvue 页面为例,实现主题切换功能:
<template>
<scroll-view direction="vertical" class="mine-page"
show-scrollbar = "false"
enable-back-to-top="true"
refresher-enabled="true"
refresher-default-style="none"
refresher-background="rgba(232, 201, 183, 0.5)"
:refresher-triggered="data.refreshing2"
refresher-max-drag-distance="200px"
@refresherpulling="onRefresherpulling2"
@refresherrefresh="onRefresherrefresh2"
@refresherrestore="onRefresherrestore2"
>
<uni-refresh-box
slot="refresher"
:pulling-distance="data.pullingDistance2"
:refreshing="data.refreshing2"
loading-class="loading-dark"
text-class="uni-text-class-buildin"
style="flex-direction: column;height: 46px;padding-top: 6px;"
pulling-text="继续下拉可刷新"
loosing-text="释放后会刷新"
loading-text="奋力加载中..."
/>
<view class="page-title">
<text style="color:#ff4d40">动态设置中心</text>
</view>
<view class="tabbar">
<text class="title-text">tabbar设置</text>
<button class="tabbar-btn" @click="refresh1">动态tabbar(我的)</button>
<button class="tabbar-btn" @click="refresh2">动态tabbar(首页、我的)</button>
<button class="tabbar-btn" @click="refresh3">动态tabbar(算法、我的)</button>
<button class="tabbar-btn" @click="refresh4">动态tabbar(我的、算法、首页、弹出详情、进入详情)</button>
<text class="title-text">设置角标</text>
<input class="input-item" v-model="tabbar" placeholder="请输入tabbar" />
<input class="input-item" v-model="badge" placeholder="请输入角标" />
<button class="tabbar-btn" @click="setBadge">确定</button>
<button class="tabbar-btn" @click="theme">主题1</button>
<button class="tabbar-btn" @click="theme2">主题2</button>
<button class="tabbar-btn" @click="theme3">主题3</button>
<button class="tabbar-btn" @click="clear">clear</button>
<!-- <button class="tabbar-btn" type="default" @click="refresh">刷新</button> -->
<!-- <button type="default" @click="cPop">返回</button> -->
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref,onMounted,onUnmounted } from 'vue'
import { TabItem, GLOBAL_COMPONENT_REGISTRY, CustomTheme, TabbarIcon, TabbarStyle, NavBarConfig } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
import { setTabBadge,setTabbarColorHeight,refreshTabbar ,setTabPageTheme,closeTabPop,clearStorage} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
function cPop(){
closeTabPop()
}
onMounted(() => {
console.log('【组件挂载====mine===】')
})
onUnmounted(() => {
console.log('【组件销毁====mine===】')
})
// 用于自动化测试的数据暴露 - 使用 reactive 将所有 ref 状态集中暴露
const data = reactive({
refreshing2: false,
pullingDistance2: 0
})
function onRefresherpulling2(e: RefresherEvent) {
data.pullingDistance2 = e.detail.dy
}
const refreshing = ref(false)
function onRefresherrefresh2() {
data.refreshing2 = true
console.log('触发刷新')
setTimeout(() => {
data.refreshing2 = false
console.log('刷新完成')
}, 1500)
}
function onRefresherrestore2() {
data.pullingDistance2 = 0
}
function onRefresherabort2() {
data.pullingDistance2 = 0
}
// 上拉触底加载
const loadMore = () => {
console.log("滚动到底部", "info")
}
function clear() {
clearStorage("all")
}
const bgColor = ref<string>('pink')
const tabHeight = ref<string>('120')
const badge = ref<string>('0')
const ss = ref<any>("");
// tab 选项配置
const tabbar = ref<string | null>(null)
const selectedTab = ref<string>('mine')
const selectedTabIndex = ref<number>(0)
function setBadge() {
//动态设置指定tabbar 角标
const badgeNum = parseInt(badge.value)
if (isNaN(badgeNum)) {
uni.showToast({
title: '请输入有效数字',
icon: 'none'
})
return
}
setTabBadge(tabbar.value,badgeNum)
uni.showToast({
title: `已设置 ${tabbar.value} 角标为 ${badgeNum}`,
icon: 'success'
})
}
function setStyle() {
setTabbarColorHeight(bgColor.value,parseInt(tabHeight.value) )
}
function refresh1() {
console.log("动态tabbar(我的)")
refreshTabbar([
new TabItem('mine', "",12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
]);
}
function refresh2() {
console.log("动态tabbar(首页、我的)")
refreshTabbar([
new TabItem('home', "",12, "red", "yellow", "bold", '首页', '/static/tab/main1.png', '/static/tab/main1_active.png',24,24,"tabPage"),
new TabItem('mine', "",12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
])
}
function refresh3() {
console.log("动态tabbar(算法、我的)")
refreshTabbar( [
new TabItem('order', "",12, "red", "yellow", "bold", '算法', '/static/tab/main2.png', '/static/tab/main2_active.png',24,24,"tabPage"),
new TabItem('mine', "",12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage")
])
}
function refresh4() {
console.log("动态tabbar(我的、算法、首页、弹出详情、进入详情)")
refreshTabbar([
new TabItem('mine', "",12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png',24,24,"tabPage"),
new TabItem('order', "",12, "red", "yellow", "bold", '算法', '/static/tab/main2.png', '/static/tab/main2_active.png',24,24,"tabPage"),
new TabItem('home', "",12, "red", "yellow", "bold", '首页', '/static/tab/main1.png',
'https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4',
24,24,"tabPage"
),
new TabItem('detail', "",12, "red", "yellow", "bold", '弹出详情', '/static/tab/main1.png',
'https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4',
24,24,"tabPop"
),
new TabItem('detail', "/pages/detail/detail",12, "red", "yellow", "bold", '进入详情', '/static/tab/main1.png',
'https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4',
24,24,"page"
)
])
}
function theme() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/2.png"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(true, 34, 'bold', '#FFA500', "rgba(191, 191, 191, 0.5)", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4",
"/static/tab/main3_active.png",
24,24,
12,
"green",
"white",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
let tabbarStyle : TabbarStyle = new TabbarStyle(120, "rgba(21, 20, 31, 0.8)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function theme2() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/sm.jpg"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(false, 34, 'bold', '#0c0c0c', "", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"/static/aj.png",
"/static/aj1.png",
24,24,
20,
"green",
"red",
"bold"
)
let tabbarIcon1 = new TabbarIcon("home",
"https://static-mp-4c53170b-e5f8-453b-a634-afcb5cf07d59.next.bspapp.com/img/icon/豪横.gif",
"https://static-mp-4c53170b-e5f8-453b-a634-afcb5cf07d59.next.bspapp.com/img/icon/豪横.gif",
50,24,
16,
"black",
"yellow",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
tabbarIcons.set("home", tabbarIcon1)
let tabbarStyle : TabbarStyle = new TabbarStyle(120, "rgba(21, 20, 31, 0.4)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function theme3() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "https://static-mp-4c53170b-e5f8-453b-a634-afcb5cf07d59.next.bspapp.com/img/bizhi/端午1.png"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(true, 34, 'bold', '#0c0c0c', "", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"/static/aj.png",
"/static/aj1.png",
24,24,
16,
"white",
"red",
"bold"
)
let tabbarIcon1 = new TabbarIcon("home",
"/static/sy1.png",
"/static/sy.png",
50,24,
16,
"white",
"red",
"bold"
)
let tabbarIcon2 = new TabbarIcon("detail",
"/static/tab/main2_active.png",
"/static/tab/main2_active.png",
24,24,
16,
"red",
"red",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
tabbarIcons.set("home", tabbarIcon1)
tabbarIcons.set("detail", tabbarIcon2)
let tabbarStyle : TabbarStyle = new TabbarStyle(150, "rgba(10, 13, 31, 0.8)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function init() {
uni.stopPullDownRefresh()
console.log('stop onPullDownRefresh=====');
}
onPullDownRefresh(() => {
console.log('onPullDownRefresh=====');
init()
})
function refresh() {
uni.startPullDownRefresh({
success: () => {
console.log("1111")
}
});
init()
}
</script>
<style scoped lang="scss">
.mine-page {
height: 100%;
// border: 12px solid red;
margin: 5px;
}
.page-title {
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.title-text {
font-size: 18px;
font-weight: bold;
color: #333;
}
.tabbar {
// border: 2px solid cyan;
padding: 15px;
display: flex;
flex-direction: column;
}
.input-item {
height: 45px;
border: 1px solid #ccc;
border-radius: 6px;
padding-left: 10px;
background: #fff;
margin: 10px;
padding-left: 20px;
}
.picker-item {
height: 45px;
border: 1px solid #ccc;
border-radius: 6px;
background: #fff;
margin: 10px;
display: flex;
align-items: center;
}
.picker-display {
padding-left: 20px;
font-size: 16px;
color: #333;
}
.tabbar-btn {
margin-bottom: 10px;
border-radius: 8px;
background: #f5f5f5;
border: 1px solid #e5e5e5;
padding: 16px 0;
font-size: 16px;
}
.tabbar-btn:last-child {
margin-bottom: 0;
}
</style>
6.5 主题配置注意事项
- 颜色格式:颜色值必须为有效的 CSS 颜色格式(如
#ffffff、rgb(255,255,255)等) - tabbar区域高度单位:高度值为数字类型,单位为 rpx
- 动态修改:通过
uni.$emit('setTheme', new CustomTheme(bgType, bg, navBarConfig, tabbarStyle))动态修改的主题会立即生效
7 page页跳转自定义tab页
使用 pageToTab 方法 跳转到tab页
以 detail.uvue 页面为例,实现主题切换功能:
<template>
<view class="detail-root">
<view class="detail-content">
<!--
<view>
<input v-model="content" placeholder="请输入。。" />
<text>{{sbase64}}</text>
<text>{{base64yostr}}</text>
</view> -->
<view class = "con">
<view class = "com">
<text>网络请求</text>
</view>
<view>
<button class="btn" type="primary" @click="req">访问testvoid</button>
<button class="btn" type="primary" @click="reqSm2">访问testSm2</button>
<button class="btn" type="primary" @click="reqSm4">访问testSm4</button>
<button class="btn" type="primary" @click="reqSm2Sm4">访问testreqSm2Sm4</button>
</view>
</view>
<view class = "con">
<view class = "com">
<text>跳转tab页</text>
</view>
<view>
<input style="height: 50px; border:1px solid grey;margin: 5px 0;" v-model="addr" placeholder="输入跳转tabbar key"/>
<button type="warn" @click="tz">跳转tab页</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import { stringToBase64, base64ToString, ReqApi } from "@/uni_modules/sunrains-request"
import { Response, FrontRequestVo } from "@/uni_modules/sunrains-common-type"
import { CustFailError, getComErrorInfo } from "@/uni_modules/sunrains-common-err"
import { pageToTab} from "@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts"
onMounted(() => {
console.log('【组件挂载====detail===】')
})
onUnmounted(() => {
console.log('【组件销毁====detail===】')
})
const addr = ref("")
function tz(){
if(addr.value.length<1){
uni.showToast({
title: '请输入跳转的tabbar key'
});
}else{
pageToTab(addr.value)
}
}
function fh(){
uni.navigateBack()
}
let priKey = "4a4a1d48fc284fe2fc43970641880554aed2f69fdfbea63257ea417af4e65802"
let pubKey = "0460e86263ec52b38a3da24c7a605055d14f8aa5c7855e4f4e19819f4f8a5b72181afd7aa4ab19255eb871eaedaba30fa18cd5c71693d3d76b030a04969bd1edfe"
const BASEURL = "http://192.168.1.9:8081"
type IRootType = {
name : string;
age : number;
}
type Builds = {
buildingNo : string,
sortNum : number,
buildAreaId : string,
id : string
}
async function req() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'GET',
url: BASEURL + "/test/get/cs1",
data: data,
header: null,
reqFlag: "void",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqvoid==", res.data)
// console.log("####==",res)
} catch (err) {
uni.showToast({
title: err.message ?? "请求异常",
icon: "error"
})
console.log("-----", err)
return
};
}
//"sign"|"sm4Encrypt"|"signwithenSM2-Sm4Encrypt"|"void";
async function reqSm2() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'GET',
url: BASEURL + "/test/get/cs1",
data: data,
header: null,
reqFlag: "sign",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm2==", res.data)
} catch (err) {
uni.showToast({
title: err.message ?? "请求异常",
icon: "error"
})
//#ifndef APP-ANDROID
const errMsg = (err as any).errMsg ?? "未知错误";
const errCode = (err as any).errCode ?? -1;
console.log('标准化错误码:', errMsg);
console.log('标准化错误信息:', errCode);
//#else
if (err instanceof UniError) {
let sa = err as UniError
console.log('sa==:', sa);
}
//#endif
console.log("-----", err)
return
};
}
async function reqSm4() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'POST',
url: BASEURL + "/test/post/cs1",
data: data,
header: null,
reqFlag: "sm4Encrypt",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm4==", res.data)
} catch (err) {
console.log("####reqSm4 err==", err)
uni.showToast({
title: err.message??"",
icon: "error"
})
return
};
}
async function reqSm2Sm4() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'POST',
url: BASEURL + "/test/post/cs1",
data: data,
header: null,
reqFlag: "signwithenSM2-Sm4Encrypt",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm2Sm4==", res.data)
} catch (err) {
console.log("-----", err)
return
};
}
const orderId = ref<string>('')
const orderStatus = ref<string>('')
const content = ref<string>('')
const sbase64 = computed(() => {
return stringToBase64(content.value)
})
const base64yostr = computed(() => {
return base64ToString(sbase64.value)
})
onLoad((options) => {
const opts = options as UTSJSONObject
const id = options?.["orderId"] as string | null
const status = options?.["status"] as string | null
if (id != null) {
orderId.value = id
}
if (status != null) {
orderStatus.value = status
}
console.log(`接收算法参数:ID=${orderId.value},状态=${orderStatus.value}`)
})
const goBack = () => {
uni.navigateBack()
}
function init(){
// uni.stopPullDownRefresh()
// console.log('stop onPullDownRefresh=====');
}
// onPullDownRefresh(() => {
// console.log('onPullDownRefresh=====');
// init()
// })
function refresh(){
uni.removeStorageSync("LOGINTABBARITEMS")
// uni.startPullDownRefresh({
// success:()=>{
// console.log("1111")
// }
// });
// init()
}
</script>
<style scoped>
.detail-root {
/* flex: 1;
display: flex;
flex-direction: column; */
background-color: #fff;
}
.status-bar {
height: 44px;
background-color: #ffffff;
}
.detail-header {
height: 44px;
background-color: #fff;
flex-direction: row;
align-items: center;
padding-left: 15px;
padding-right: 15px;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #eee;
}
.back-icon {
font-size: 18px;
color: #007aff;
margin-right: 10px;
}
.detail-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.detail-content {
flex: 1;
padding: 20px;
}
.detail-item {
flex-direction: row;
margin-bottom: 20px;
align-items: center;
}
.label {
font-size: 15px;
color: #666;
width: 80px;
}
.value {
font-size: 15px;
color: #333;
font-weight: bold;
}
.detail-desc {
margin-top: 30px;
}
.com{
display:flex;
align-items: center;
margin:10px 0;
}
.con{
margin:10px 0;
padding:10px;
background-color: #e3eda1;
}
.btn{
margin: 5px;
}
</style>
8 拓展底部tabbar类型,(切换、弹出、进入)页面
let mine = new TabItem(
'mine', //组件或页面唯一标识
"", //组件或页面路径 当类型为 page 时不可为空,其它传空字符串即可
12,
"red",
"yellow",
"bold",
'我的',
'/static/tab/main3.png',
'/static/tab/main3_active.png',
24,//图标宽
24,//图标高
"tabPage" //tabbar 类型 tabPage:tab页面 tabPop:弹窗组件 page:普通页面
)
注:
类型为 tabPop 打开页面 可 右滑手势(当手机开启右滑手势时)关闭、 或点击虚拟返回按键关闭 或 页面中调用方法 closeTabPop()关闭
方法导入 import { pageToTab} from "@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts"
⚠️ 常见问题
Q1: 为什么小程序端需要单独配置?
A: 微信小程序不支持动态组件渲染,必须静态声明所有可能使用的组件。
Q2: 动态 Tab 刷新失败怎么办?
A: 检查以下几点:
- 组件是否在
tabbar-config.uts中正确注册 - 是否正确使用了
GLOBAL_COMPONENT_REGISTRY.get()获取组件 - TabItem 的参数是否完整且类型正确
Q3: 导航栏配置不生效?
A: 确保 setNavbarConfig() 在 setDefaultTabItems() 之前调用,或在应用启动时尽早调用。
Q4: 如何设置数字角标?
A: 使用 uni.$emit('setTabBadge', {"key": "tab_key", "badge": number}) 即可,详见第 4 章。
Q5: 角标设置后不显示怎么办?
A: 检查以下几点:
- badge 值是否大于 0(0 或负数不显示)
- tab key 是否正确(必须与 TabItem 的 key 一致)
- 是否重新赋值了整个数组(直接修改对象属性不会触发响应式)
Q6: 退出登录如何清除 tabbar相关缓存
A: 调用 noticetab.uts里的 clearStorage 方法
/**
* 清除tabbar相关缓存
* all 所有
* theme 主题
* tabbarItems 所有tabbar
* badge 角标
*/
export function clearStorage(type:string){
switch (type){
case "theme":
uni.removeStorageSync("sunrains-tabbar-customtheme")
break;
case "tabbarItems":
uni.removeStorageSync("sunrains-tabbar-logintabbaritems")
break;
case "badge":
uni.removeStorageSync("sunrains-tabbar-tabbarbadges")
break;
default:
uni.removeStorageSync("sunrains-tabbar-customtheme")
uni.removeStorageSync("sunrains-tabbar-logintabbaritems")
uni.removeStorageSync("sunrains-tabbar-tabbarbadges")
}
}
收起阅读 »
客户端网络请求(SM2签名,SM4加密)与java服务端之前加密通信
sunrains-request
注:客户端与服务端之间通信 数据 加密,签名,保证数据安全与防串改(完整功能依赖付费插件sunrains-smutil)
客户端与服务端(java)之间通信数据加密、签名,保证数据安全与防篡改
支持平台:微信小程序、H5、Android、iOS、鸿蒙
请求方式支持:GET、POST(application/json、application/x-www-form-urlencoded、multipart/form-data(计划中))
服务端(java)点击 sunrains-java
模块目录结构
sunrains-request/
├── index.uts # 模块入口,统一导出
├── js_sdk/
│ ├── inutils/
│ │ └── requestApi.uts # 底层网络请求封装(uni.request / uni.uploadFile)
│ └── openutil/
│ ├── base64Util.uts # Base64 编解码工具
│ ├── randomUtil.uts # 随机字符串生成
│ └── request/
│ ├── Request.uts # 请求入口(ReqApi)
│ └── reqHandle/
│ ├── ReqHandle.uts # 请求处理抽象基类
│ ├── handle.uts # 策略工厂(根据 reqFlag 选择 Handler)
│ └── handle/
│ ├── VoidHandle.uts # 无加密无签名模式
│ ├── SignHandle.uts # 仅签名模式
│ ├── EncryptHandle.uts # 仅SM4加密模式
│ └── SignWithEncryptHandle.uts # 签名+SM4加密模式
导出 API 一览
| 导出函数 | 来源文件 | 说明 |
|---|---|---|
ReqApi |
request/Request.uts |
高层请求入口,自动根据 reqFlag 完成签名/加密/发送/解密 |
RequestApi |
inutils/requestApi.uts |
底层请求入口,直接发起 uni.request 并返回 Promise\<Response> |
uploadFile |
inutils/requestApi.uts |
文件上传,返回 Promise\<Response> |
stringToBase64 |
openutil/base64Util.uts |
字符串 → Base64 编码 |
base64ToString |
openutil/base64Util.uts |
Base64 → 字符串解码 |
random |
openutil/randomUtil.uts |
生成指定长度的随机字符串(字母+数字),默认16位 |
网络请求架构(核心)
整体流程
调用方
│
▼
ReqApi(frontVo: FrontRequestVo) ← 高层入口
│
├─ 1. selectHandle(reqFlag) ← 根据模式选择 Handler
│
├─ 2. handle.handleHeaderAndBody(vo) ← 构造请求头 + 处理请求体(签名/加密)
│
├─ 3. RequestApi(req) ← 底层发起 HTTP 请求
│
└─ 4. handle.handleRes(req, res) ← 处理响应(解密/验签)
│
▼
返回 Response
四种请求模式(ReqFlag)
通过 FrontRequestVo.reqFlag 指定,决定请求的安全处理策略:
| reqFlag | Handler | 签名 | 请求体SM4加密 | 响应SM4解密 | 需要priKey | 需要pubKey |
|---|---|---|---|---|---|---|
"void" |
VoidHandle | ✗ | ✗ | ✗ | ✗ | ✗ |
"sign" |
SignHandle | ✓ SM2签名 | ✗ | ✗ | ✓ | ✗ |
"sm4Encrypt" |
EncryptHandle | ✗ | ✓ SM4加密 | ✓ SM4解密 | ✗ | ✓ |
"signwithenSM2-Sm4Encrypt" |
SignWithEncryptHandle | ✓ SM2签名 | ✓ SM4加密 | ✓ SM4解密 | ✓ | ✓ |
请求头处理(所有模式共用)
无论哪种模式,handleSignHeader 都会执行以下操作:
- SM2加密SM4密钥:生成随机 SM4 key + iv,用服务端公钥(pubKey)通过 SM2 加密后放入
header["key"] - 条件签名(sign / signwithenSM2-Sm4Encrypt 模式):
- 生成
timestamp(时间戳)和nonceStr(16位随机串) - 将所有业务参数 + timestamp + nonceStr 按 key 字典序排列,拼接为
key=value&格式 - 使用客户端私钥(priKey)对拼接字符串进行 SM2 签名
- 签名结果放入
header["sign"],同时放入header["timestamp"]和header["nonceStr"]
- 生成
请求体处理(按 HTTP Method + contentType)
GET 请求:
void/sign模式:参数拼接到 URL query stringsm4Encrypt/signwithenSM2-Sm4Encrypt模式:参数 JSON 序列化后 SM4 加密,拼接到?data=加密结果
POST 请求(按 contentType):
| contentType | 处理方式 |
|---|---|
"json" |
Content-Type: application/json,加密模式下 body 整体 SM4 加密为 {"data":"加密结果"} |
"urlencoded" |
Content-Type: application/x-www-form-urlencoded,加密模式下 data=加密结果 |
"form-data" |
Content-Type: multipart/form-data(暂未实现加密) |
响应处理
- 无加密模式(void / sign):直接返回 Response
- 加密模式(sm4Encrypt / signwithenSM2-Sm4Encrypt):
- 先校验外层
res.code == resSuccessCode()(当前定义为0) - 取出
res.data(SM4 加密的字符串) - 用本次请求生成的 SM4 key + iv 解密
- 解密结果为内层 Response,返回给调用方
- 先校验外层
底层请求(RequestApi)
RequestApi(req: RequestVo) → Promise<Response>
- 基于
uni.request封装 - 超时时间:
req.timeout ?? 6000ms - 成功(statusCode=200):resolve Response 对象
- Android 平台特殊处理:通过
JSON.parse(JSON.stringify(res.data))转换 - 其他平台:直接
res.data as Response
- Android 平台特殊处理:通过
- 失败(非200):reject
CustOtherFailError(包含 code + errorMsg) - 网络异常:reject
CustOtherFailError(包含 errCode + errMsg)
文件上传(uploadFile)
uploadFile(req: UploadFileOptions) → Promise<Response>
- 基于
uni.uploadFile封装 - 超时时间:
req.timeout ?? 12000ms - 成功时解析
res.data为 Response - 失败时 reject
err.errMsg
类型定义(依赖 sunrains-common-type)
FrontRequestVo(前置请求参数)
type FrontRequestVo = {
method: 'GET' | 'POST', // HTTP 方法
url: string, // 请求地址
data: UTSJSONObject | null, // 请求数据
header: UTSJSONObject | null, // 自定义请求头(会与签名/加密头合并)
reqFlag: ReqFlag, // 安全模式标识
priKey: string, // SM2 私钥(签名时使用)
pubKey: string, // SM2 公钥(加密SM4密钥时使用)
contentType: "json" | "urlencoded" | "form-data" | null, // POST内容类型
timeout: number | null, // 超时时间(ms)
filePathParamName: string | null // 上传文件时,文件路径的参数名
}
Response(统一响应)
type Response = {
success: boolean | null, // 是否成功
msg: string | null, // 提示信息
code: number, // 状态码(0=成功)
errorMsg: string | null, // 错误详情
data: any | null // 业务数据(加密模式下为密文字符串)
}
RequestVo(底层请求参数)
type RequestVo = {
url: string,
method?: 'GET' | 'POST' | null,
header: UTSJSONObject,
timeout?: number | null,
data: any
}
Sm4Key(SM4密钥)
type Sm4Key = {
key: string,
iv: string
}
错误处理(依赖 sunrains-common-err)
所有请求错误统一通过 UniError 抛出:
| 错误来源 | errSubject | errCode | message |
|---|---|---|---|
| SM2私钥为空 | SM2_PRI_KEY_BLANK | 预定义码 | 预定义信息 |
| SM2公钥为空 | SM2_PUB_KEY_BLANK | 预定义码 | 预定义信息 |
| SM2签名失败 | SM2_SIGN_FAIL | 预定义码 | 预定义信息 |
| HTTP非200 | "请求失败" | 服务端code | errorMsg / msg |
| 网络异常 | "请求失败" | err.errCode | err.errMsg |
| 加密模式响应码非0 | "请求失败" | res.code | errorMsg / msg |
调用方 catch 示例:
try {
const res = await ReqApi(frontVo)
} catch (err) {
// 跨平台安全取值(避免 iOS 上 err.message 崩溃)
console.log("请求异常:", `${err}`)
// 或通过 err.message 获取错误信息(err 为 UniError 实例)
}
工具函数
Base64 编解码
import { stringToBase64, base64ToString } from "@/uni_modules/sunrains-request"
const encoded = stringToBase64("Hello") // "SGVsbG8="
const decoded = base64ToString("SGVsbG8=") // "Hello"
随机字符串
import { random } from "@/uni_modules/sunrains-request"
const nonce = random(16) // 生成16位随机字符串
依赖模块
| 模块 | 用途 |
|---|---|
sunrains-smutil |
SM2签名/加解密、SM4加解密 |
sunrains-common-type |
类型定义(FrontRequestVo、Response、RequestVo、Sm4Key、ReqFlag) |
sunrains-common-err |
统一错误类(CustFailError、CustOtherFailError) |
使用示例
1. 无加密请求(void)
import { ReqApi } from "@/uni_modules/sunrains-request"
const frontVo: FrontRequestVo = {
method: "POST",
url: "http://your-api.com/test",
data: { name: "张三", age: 18 },
header: null,
reqFlag: "void",
priKey: "",
pubKey: "",
contentType: "json",
timeout: null,
filePathParamName: null
}
const res = await ReqApi(frontVo)
2. SM2签名请求(sign)
const frontVo: FrontRequestVo = {
method: "POST",
url: "http://your-api.com/test",
data: { name: "张三", age: 18 },
header: null,
reqFlag: "sign",
priKey: "客户端SM2私钥",
pubKey: "服务端SM2公钥",
contentType: "json",
timeout: null,
filePathParamName: null
}
const res = await ReqApi(frontVo)
3. 签名 + SM4加密请求(signwithenSM2-Sm4Encrypt)
const frontVo: FrontRequestVo = {
method: "POST",
url: "http://your-api.com/test",
data: { name: "张三", age: 18 },
header: null,
reqFlag: "signwithenSM2-Sm4Encrypt",
priKey: "客户端SM2私钥",
pubKey: "服务端SM2公钥",
contentType: "json",
timeout: null,
filePathParamName: null
}
const res = await ReqApi(frontVo)
// res.data 已自动解密
4. 文件上传(计划中)
sunrains-request
注:客户端与服务端之间通信 数据 加密,签名,保证数据安全与防串改(完整功能依赖付费插件sunrains-smutil)
客户端与服务端(java)之间通信数据加密、签名,保证数据安全与防篡改
支持平台:微信小程序、H5、Android、iOS、鸿蒙
请求方式支持:GET、POST(application/json、application/x-www-form-urlencoded、multipart/form-data(计划中))
服务端(java)点击 sunrains-java
模块目录结构
sunrains-request/
├── index.uts # 模块入口,统一导出
├── js_sdk/
│ ├── inutils/
│ │ └── requestApi.uts # 底层网络请求封装(uni.request / uni.uploadFile)
│ └── openutil/
│ ├── base64Util.uts # Base64 编解码工具
│ ├── randomUtil.uts # 随机字符串生成
│ └── request/
│ ├── Request.uts # 请求入口(ReqApi)
│ └── reqHandle/
│ ├── ReqHandle.uts # 请求处理抽象基类
│ ├── handle.uts # 策略工厂(根据 reqFlag 选择 Handler)
│ └── handle/
│ ├── VoidHandle.uts # 无加密无签名模式
│ ├── SignHandle.uts # 仅签名模式
│ ├── EncryptHandle.uts # 仅SM4加密模式
│ └── SignWithEncryptHandle.uts # 签名+SM4加密模式
导出 API 一览
| 导出函数 | 来源文件 | 说明 |
|---|---|---|
ReqApi |
request/Request.uts |
高层请求入口,自动根据 reqFlag 完成签名/加密/发送/解密 |
RequestApi |
inutils/requestApi.uts |
底层请求入口,直接发起 uni.request 并返回 Promise\<Response> |
uploadFile |
inutils/requestApi.uts |
文件上传,返回 Promise\<Response> |
stringToBase64 |
openutil/base64Util.uts |
字符串 → Base64 编码 |
base64ToString |
openutil/base64Util.uts |
Base64 → 字符串解码 |
random |
openutil/randomUtil.uts |
生成指定长度的随机字符串(字母+数字),默认16位 |
网络请求架构(核心)
整体流程
调用方
│
▼
ReqApi(frontVo: FrontRequestVo) ← 高层入口
│
├─ 1. selectHandle(reqFlag) ← 根据模式选择 Handler
│
├─ 2. handle.handleHeaderAndBody(vo) ← 构造请求头 + 处理请求体(签名/加密)
│
├─ 3. RequestApi(req) ← 底层发起 HTTP 请求
│
└─ 4. handle.handleRes(req, res) ← 处理响应(解密/验签)
│
▼
返回 Response
四种请求模式(ReqFlag)
通过 FrontRequestVo.reqFlag 指定,决定请求的安全处理策略:
| reqFlag | Handler | 签名 | 请求体SM4加密 | 响应SM4解密 | 需要priKey | 需要pubKey |
|---|---|---|---|---|---|---|
"void" |
VoidHandle | ✗ | ✗ | ✗ | ✗ | ✗ |
"sign" |
SignHandle | ✓ SM2签名 | ✗ | ✗ | ✓ | ✗ |
"sm4Encrypt" |
EncryptHandle | ✗ | ✓ SM4加密 | ✓ SM4解密 | ✗ | ✓ |
"signwithenSM2-Sm4Encrypt" |
SignWithEncryptHandle | ✓ SM2签名 | ✓ SM4加密 | ✓ SM4解密 | ✓ | ✓ |
请求头处理(所有模式共用)
无论哪种模式,handleSignHeader 都会执行以下操作:
- SM2加密SM4密钥:生成随机 SM4 key + iv,用服务端公钥(pubKey)通过 SM2 加密后放入
header["key"] - 条件签名(sign / signwithenSM2-Sm4Encrypt 模式):
- 生成
timestamp(时间戳)和nonceStr(16位随机串) - 将所有业务参数 + timestamp + nonceStr 按 key 字典序排列,拼接为
key=value&格式 - 使用客户端私钥(priKey)对拼接字符串进行 SM2 签名
- 签名结果放入
header["sign"],同时放入header["timestamp"]和header["nonceStr"]
- 生成
请求体处理(按 HTTP Method + contentType)
GET 请求:
void/sign模式:参数拼接到 URL query stringsm4Encrypt/signwithenSM2-Sm4Encrypt模式:参数 JSON 序列化后 SM4 加密,拼接到?data=加密结果
POST 请求(按 contentType):
| contentType | 处理方式 |
|---|---|
"json" |
Content-Type: application/json,加密模式下 body 整体 SM4 加密为 {"data":"加密结果"} |
"urlencoded" |
Content-Type: application/x-www-form-urlencoded,加密模式下 data=加密结果 |
"form-data" |
Content-Type: multipart/form-data(暂未实现加密) |
响应处理
- 无加密模式(void / sign):直接返回 Response
- 加密模式(sm4Encrypt / signwithenSM2-Sm4Encrypt):
- 先校验外层
res.code == resSuccessCode()(当前定义为0) - 取出
res.data(SM4 加密的字符串) - 用本次请求生成的 SM4 key + iv 解密
- 解密结果为内层 Response,返回给调用方
- 先校验外层
底层请求(RequestApi)
RequestApi(req: RequestVo) → Promise<Response>
- 基于
uni.request封装 - 超时时间:
req.timeout ?? 6000ms - 成功(statusCode=200):resolve Response 对象
- Android 平台特殊处理:通过
JSON.parse(JSON.stringify(res.data))转换 - 其他平台:直接
res.data as Response
- Android 平台特殊处理:通过
- 失败(非200):reject
CustOtherFailError(包含 code + errorMsg) - 网络异常:reject
CustOtherFailError(包含 errCode + errMsg)
文件上传(uploadFile)
uploadFile(req: UploadFileOptions) → Promise<Response>
- 基于
uni.uploadFile封装 - 超时时间:
req.timeout ?? 12000ms - 成功时解析
res.data为 Response - 失败时 reject
err.errMsg
类型定义(依赖 sunrains-common-type)
FrontRequestVo(前置请求参数)
type FrontRequestVo = {
method: 'GET' | 'POST', // HTTP 方法
url: string, // 请求地址
data: UTSJSONObject | null, // 请求数据
header: UTSJSONObject | null, // 自定义请求头(会与签名/加密头合并)
reqFlag: ReqFlag, // 安全模式标识
priKey: string, // SM2 私钥(签名时使用)
pubKey: string, // SM2 公钥(加密SM4密钥时使用)
contentType: "json" | "urlencoded" | "form-data" | null, // POST内容类型
timeout: number | null, // 超时时间(ms)
filePathParamName: string | null // 上传文件时,文件路径的参数名
}
Response(统一响应)
type Response = {
success: boolean | null, // 是否成功
msg: string | null, // 提示信息
code: number, // 状态码(0=成功)
errorMsg: string | null, // 错误详情
data: any | null // 业务数据(加密模式下为密文字符串)
}
RequestVo(底层请求参数)
type RequestVo = {
url: string,
method?: 'GET' | 'POST' | null,
header: UTSJSONObject,
timeout?: number | null,
data: any
}
Sm4Key(SM4密钥)
type Sm4Key = {
key: string,
iv: string
}
错误处理(依赖 sunrains-common-err)
所有请求错误统一通过 UniError 抛出:
| 错误来源 | errSubject | errCode | message |
|---|---|---|---|
| SM2私钥为空 | SM2_PRI_KEY_BLANK | 预定义码 | 预定义信息 |
| SM2公钥为空 | SM2_PUB_KEY_BLANK | 预定义码 | 预定义信息 |
| SM2签名失败 | SM2_SIGN_FAIL | 预定义码 | 预定义信息 |
| HTTP非200 | "请求失败" | 服务端code | errorMsg / msg |
| 网络异常 | "请求失败" | err.errCode | err.errMsg |
| 加密模式响应码非0 | "请求失败" | res.code | errorMsg / msg |
调用方 catch 示例:
try {
const res = await ReqApi(frontVo)
} catch (err) {
// 跨平台安全取值(避免 iOS 上 err.message 崩溃)
console.log("请求异常:", `${err}`)
// 或通过 err.message 获取错误信息(err 为 UniError 实例)
}
工具函数
Base64 编解码
import { stringToBase64, base64ToString } from "@/uni_modules/sunrains-request"
const encoded = stringToBase64("Hello") // "SGVsbG8="
const decoded = base64ToString("SGVsbG8=") // "Hello"
随机字符串
import { random } from "@/uni_modules/sunrains-request"
const nonce = random(16) // 生成16位随机字符串
依赖模块
| 模块 | 用途 |
|---|---|
sunrains-smutil |
SM2签名/加解密、SM4加解密 |
sunrains-common-type |
类型定义(FrontRequestVo、Response、RequestVo、Sm4Key、ReqFlag) |
sunrains-common-err |
统一错误类(CustFailError、CustOtherFailError) |
使用示例
1. 无加密请求(void)
import { ReqApi } from "@/uni_modules/sunrains-request"
const frontVo: FrontRequestVo = {
method: "POST",
url: "http://your-api.com/test",
data: { name: "张三", age: 18 },
header: null,
reqFlag: "void",
priKey: "",
pubKey: "",
contentType: "json",
timeout: null,
filePathParamName: null
}
const res = await ReqApi(frontVo)
2. SM2签名请求(sign)
const frontVo: FrontRequestVo = {
method: "POST",
url: "http://your-api.com/test",
data: { name: "张三", age: 18 },
header: null,
reqFlag: "sign",
priKey: "客户端SM2私钥",
pubKey: "服务端SM2公钥",
contentType: "json",
timeout: null,
filePathParamName: null
}
const res = await ReqApi(frontVo)
3. 签名 + SM4加密请求(signwithenSM2-Sm4Encrypt)
const frontVo: FrontRequestVo = {
method: "POST",
url: "http://your-api.com/test",
data: { name: "张三", age: 18 },
header: null,
reqFlag: "signwithenSM2-Sm4Encrypt",
priKey: "客户端SM2私钥",
pubKey: "服务端SM2公钥",
contentType: "json",
timeout: null,
filePathParamName: null
}
const res = await ReqApi(frontVo)
// res.data 已自动解密
4. 文件上传(计划中)
收起阅读 »
uniapp x 自定义+动态Tabbar+自定义tabbar 支持服务端动态配置的自定义Tabbar 主题切换(tabbar切换无闪烁问题)
sunrains-tabbar 自定义 TabBar 插件使用指南
点击体验h5端
📋 目录
- [1. 基础配置]
- [2. pages.json 配置]
- [3. 动态配置 TabBar]
- [4. 数字角标功能]
- [5. 小程序端特殊配置]
- [6. 主题配置]
- [7. page页跳转自定义tab页]
1. 基础配置
App.uvue onLaunch 初始化
import {initDefaultTabItems} from '@/uni_modules/sunrains-tabbar/tabbar-config.uts'
onLaunch(() => {
console.log('App Launch')
initDefaultTabItems()
}
1.1 创建 tabbar-config.uts 文件
在项目根目录创建 tabbar-config.uts 文件,用于注册全局组件和配置导航栏。
完整示例:
// 1. 导入所有自定义 tabbar 页面组件并注册
import Recommend from '@/pages/home/recommend.uvue'
import Order from '@/pages/order/order.uvue'
import Mine from '@/pages/mine/mine.uvue'
import {
setNavbarConfig,
NavBarConfig,
registerGlobalComponent,
setDefaultTabItems,
TabItem
} from '@/uni_modules/sunrains-tabbar/types.uts'
// 2. 注册全局组件
registerGlobalComponent('home', Recommend)
registerGlobalComponent('order', Order)
registerGlobalComponent('mine', Mine)
// 3. 配置导航栏(可选,不设置会使用默认配置)
setNavbarConfig(new NavBarConfig(
true, // 是否显示导航栏
17, // 字体大小
'bold', // 字体粗细
'#333333', // 文字颜色
'#ffffff', // 背景颜色
'', // 背景图片(支持网络图片)
56, // 导航栏高度
))
// 4. 设置默认 Tab 列表(至少设置一个固定项)
// 其他 Tab 可通过服务端接口动态配置
setDefaultTabItems([
new TabItem(
'home', // 唯一标识
'首页', // 显示名称
'/static/tab/main1.png', // 未选中图标
'/static/tab/main1_active.png', // 选中图标
'@/pages/home/recommend.uvue', // 页面路径
Recommend // 组件引用
),
new TabItem(
'mine',
'我的',
'/static/tab/main3.png',
'/static/tab/main3_active.png'
)
])
关键步骤说明:
- 导入组件:将所有需要作为 Tab 的页面组件导入
- 注册组件:使用
registerGlobalComponent注册每个组件(第一个参数为唯一标识) - 配置导航栏:可选配置,根据项目需求调整
- 设置默认 Tab:至少配置一个固定 Tab,其余可动态添加
2. pages.json 配置
2.1 必需配置
必须在 pages.json 中配置自定义 TabBar 入口页面(必须放在第一个位置):
{
"path": "uni_modules/sunrains-tabbar/index",
"style": {
"navigationBarTitleText": "自定义tabbar",
"navigationStyle": "custom"
}
}
2.2 完整示例
{
"pages": [
{
"path": "uni_modules/sunrains-tabbar/index",
"style": {
"navigationBarTitleText": "自定义tabbar",
"navigationStyle": "custom"
}
},
{
"path": "pages/home/recommend",
"style": {
"navigationBarTitleText": "首页"
}
},
{
"path": "pages/mine/mine",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/order/order",
"style": {
"navigationBarTitleText": "订单"
}
},
{
"path": "pages/detail/detail",
"style": {
"navigationBarTitleText": "详情"
}
}
],
"globalStyle": {
"backgroundColor": "#ffffff"
}
}
注意事项:
- 自定义 TabBar 入口页面必须配置在
pages数组的第一位 - 所有 Tab 页面都需要在
pages中声明 navigationStyle设置为"custom"以隐藏原生导航栏
3. 动态配置 TabBar
3.1 使用场景
当需要根据用户权限、服务端配置等动态调整 TabBar 时,可以在任意页面触发刷新。
3.2 实现示例
以下面文档 mine.uvue 页面为例:
关键点:
- 使用 refreshTabbar方法触发 TabBar 刷新
- 确保所有动态 Tab 对应的组件已在
tabbar-config.uts中注册
4. 数字角标功能
4.1 功能说明
支持为任意 Tab 设置数字角标,用于显示未读消息数、待处理事项等。
特性:
- ✅ 动态设置/更新角标
- ✅ 超过 99 自动显示 "99+"
- ✅ 角标为 0 或 null 时不显示
- ✅ 响应式更新,立即生效
4.2 使用方法
方法一:通过事件设置(推荐)
在任意页面通过 调用 setTabBadge 方法:
import { setTabBadge} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 设置订单 tab 的角标为 5
setTabBadge("order",5)
// 清除角标(设置为 0)
setTabBadge("order",0)
// 设置超过 99 显示 99+
setTabBadge("order",150)
方法二:初始化时设置
在创建 TabItem 时传入 badge 参数:
import { TabItem } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
// 带角标的 tab
new TabItem(
'order',
'订单',
'/static/tab/main2.png',
'/static/tab/main2_active.png'
10 // badge 参数
)
// 不带角标的 tab
new TabItem(
'home',
'首页',
'/static/tab/main1.png',
'/static/tab/main1_active.png'
)
4.3 完整示例
以 文档后面 mine.uvue 页面为例,实现动态设置角标功能:
4.4 实际应用场景
场景 1:订单未读数
// 从服务端获取未读订单数
async function loadUnreadOrderCount() {
const res = await getOrderCount()
setTabBadge("order",res.data.count)
}
// 页面加载时调用
onShow(() => {
loadUnreadOrderCount()
})
场景 2:消息通知
// WebSocket 收到新消息
websocket.onMessage((msg) => {
if (msg.type === 'new_message') {
const currentBadge = getCurrentBadge('message')
setTabBadge("newMessage",currentBadge + 1)
}
})
场景 3:购物车数量
// 添加商品到购物车后更新角标
function addToCart(product: Product) {
cartItems.push(product)
setTabBadge("cart",cartItems.length)
}
5. 小程序端特殊配置
4.1 配置 tab-content-renderer.uvue
注意: 仅在需要支持微信小程序时需要配置,App 端可忽略此步骤。
在 uni_modules/sunrains-tabbar/tab-content-renderer.uvue 中配置:
<template>
<view class="renderer-wrap">
<!-- App 端:使用动态组件 -->
<!-- #ifndef MP-WEIXIN -->
<template v-for="item in items" :key="item.key">
<component
v-if="currentTab === item.key"
:is="item.component"
:key="`${item.key}-${renderKey}`"
/>
</template>
<!-- #endif -->
<!-- 微信小程序端:使用静态声明 -->
<!-- #ifdef MP-WEIXIN -->
<Recommend v-if="currentTab === 'home'" style="width: 100%; height: 100%;" />
<Order v-if="currentTab === 'order'" style="width: 100%; height: 100%;" />
<Mine v-if="currentTab === 'mine'" style="width: 100%; height: 100%;" />
<!-- #endif -->
</view>
</template>
<script setup lang="uts">
import { TabItem } from './utssdk/types.uts'
// #ifdef MP-WEIXIN
// 只有在小程序端才需要这些导入,App 端会自动忽略
import Recommend from '@/pages/home/recommend.uvue'
import Order from '@/pages/order/order.uvue'
import Mine from '@/pages/mine/mine.uvue'
// #endif
interface Props {
currentTab: string
items: TabItem[]
renderKey: number
}
defineProps<Props>()
</script>
<style scoped>
.renderer-wrap {
width: 100%;
height: 100%;
}
</style>
配置说明:
- 条件编译:使用
#ifndef MP-WEIXIN和#ifdef MP-WEIXIN区分平台 - App 端:使用
<component :is="...">动态渲染组件 - 小程序端:必须静态导入并声明所有可能的 Tab 组件
- 导入位置:在
#ifdef MP-WEIXIN块内导入组件
6. 主题配置
6.1 主题功能说明
插件支持自定义 TabBar 的主题样式,支持网络背景图、本地图片背景图、纯颜色背景(支持rgba透明)、导航栏、tabbar区域背景、图标大小、图标(支持网络图标)、文字颜色、文字大小、等属性 满足不同项目的视觉需求。
主题特性:
- ✅ 支持全局主题配置
- ✅ 支持动态切换主题
- ✅ 支持自定义主题参数
6.2 初始化时配置主题
在 tabbar-config.uts 文件中配置主题:
import {
setNavbarConfig,
NavBarConfig,
registerGlobalComponent,
setDefaultTabItems,
TabItem,
setTabBarTheme,
TabBarTheme
} from '@/uni_modules/sunrains-tabbar/types.uts'
import { setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
let bgType : BgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/2.png"
//配置导航栏(可选,不设置会使用默认配置)
let navBarConfig : NavBarConfig = new NavBarConfig(
true, // 是否显示导航栏
34, // 字体大小
'bold', // 字体粗细
'#FFA500', // 文字颜色
'',// 背景颜色
"", //背景图片(支持网络图片)
90) // 导航栏高度
let tabbarIcon = new TabbarIcon(
"mine",//自定义组件唯一标识
//非选中图标
"https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4",
//选中图标
"/static/tab/main3_active.png",
//文字颜色
12,
//非选中文字大小
"green",
//选择文字颜色
"white",
//选择文字粗细
"bold"
)
let tabbarIcons : Map<string,TabbarIcon> = new Map();
tabbarIcons.set("mine",tabbarIcon)
let tabbarStyle : TabbarStyle = new TabbarStyle(
//tabbar区域高度
120,
//tabbar区域背景
"rgba(21, 20, 31, 0.8)",
//tabbar图标文字样式
tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
6.3 动态修改主题
在任意页面通过调用 setTabPageTheme 方法动态修改主题:
import { setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 动态修改背景色和选中颜色
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
6.4 在页面中动态切换主题
以 mine.uvue 页面为例,实现主题切换功能:
<template>
<scroll-view direction="vertical" class="mine-page"
show-scrollbar = "false"
enable-back-to-top="true"
refresher-enabled="true"
refresher-default-style="none"
refresher-background="rgba(232, 201, 183, 0.5)"
:refresher-triggered="data.refreshing2"
refresher-max-drag-distance="200px"
@refresherpulling="onRefresherpulling2"
@refresherrefresh="onRefresherrefresh2"
@refresherrestore="onRefresherrestore2"
>
<uni-refresh-box
slot="refresher"
:pulling-distance="data.pullingDistance2"
:refreshing="data.refreshing2"
loading-class="loading-dark"
text-class="uni-text-class-buildin"
style="flex-direction: column;height: 46px;padding-top: 6px;"
pulling-text="继续下拉可刷新"
loosing-text="释放后会刷新"
loading-text="奋力加载中..."
/>
<view class="page-title">
<text style="color:#ff4d40">动态设置中心</text>
</view>
<view class="tabbar">
<text class="title-text">tabbar设置</text>
<button class="tabbar-btn" @click="refresh1">动态tabbar(我的)</button>
<button class="tabbar-btn" @click="refresh2">动态tabbar(首页、我的)</button>
<button class="tabbar-btn" @click="refresh3">动态tabbar(订单、我的)</button>
<button class="tabbar-btn" @click="refresh4">动态tabbar(我的、订单、首页)</button>
<text class="title-text">设置角标</text>
<input class="input-item" v-model="tabbar" placeholder="请输入tabbar" />
<input class="input-item" v-model="badge" placeholder="请输入角标" />
<button class="tabbar-btn" @click="setBadge">确定</button>
<button class="tabbar-btn" @click="theme">主题1</button>
<button class="tabbar-btn" @click="theme2">主题2</button>
<button class="tabbar-btn" @click="theme3">主题3</button>
<button class="tabbar-btn" @click="clear">clear</button>
<button class="tabbar-btn" type="default" @click="refresh">刷新</button>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { TabItem, GLOBAL_COMPONENT_REGISTRY, CustomTheme, TabbarIcon, TabbarStyle, NavBarConfig } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
import { setTabBadge,setTabbarColorHeight,refreshTabbar ,setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 用于自动化测试的数据暴露 - 使用 reactive 将所有 ref 状态集中暴露
const data = reactive({
refreshing2: false,
pullingDistance2: 0
})
function onRefresherpulling2(e: RefresherEvent) {
data.pullingDistance2 = e.detail.dy
}
const refreshing = ref(false)
function onRefresherrefresh2() {
data.refreshing2 = true
console.log('触发刷新')
setTimeout(() => {
data.refreshing2 = false
console.log('刷新完成')
}, 1500)
}
function onRefresherrestore2() {
data.pullingDistance2 = 0
}
function onRefresherabort2() {
data.pullingDistance2 = 0
}
// 上拉触底加载
const loadMore = () => {
console.log("滚动到底部", "info")
}
function clear() {
uni.removeStorageSync("CUSTOMTHEME")
}
const bgColor = ref<string>('pink')
const tabHeight = ref<string>('120')
const badge = ref<string>('0')
const ss = ref<any>("");
// tab 选项配置
const tabbar = ref<string | null>(null)
const selectedTab = ref<string>('mine')
const selectedTabIndex = ref<number>(0)
function setBadge() {
//动态设置指定tabbar 角标
const badgeNum = parseInt(badge.value)
if (isNaN(badgeNum)) {
uni.showToast({
title: '请输入有效数字',
icon: 'none'
})
return
}
setTabBadge(tabbar.value,badgeNum)
uni.showToast({
title: `已设置 ${tabbar.value} 角标为 ${badgeNum}`,
icon: 'success'
})
}
function setStyle() {
setTabbarColorHeight(bgColor.value,parseInt(tabHeight.value) )
}
function refresh1() {
console.log("动态tabbar(我的)")
refreshTabbar([
new TabItem('mine', 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png')
]);
}
function refresh2() {
console.log("动态tabbar(首页、我的)")
refreshTabbar([
new TabItem('home', 12, "red", "yellow", "bold", '首页', '/static/tab/main1.png', '/static/tab/main1_active.png'),
new TabItem('mine', 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png')
])
}
function refresh3() {
console.log("动态tabbar(订单、我的)")
refreshTabbar( [
new TabItem('order', 12, "red", "yellow", "bold", '订单', '/static/tab/main2.png', '/static/tab/main2_active.png'),
new TabItem('mine', 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png')
])
}
function refresh4() {
console.log("动态tabbar(我的、订单、首页)")
refreshTabbar([
new TabItem('mine', 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png'),
new TabItem('order', 12, "red", "yellow", "bold", '订单', '/static/tab/main2.png', '/static/tab/main2_active.png'),
new TabItem('home', 12, "red", "yellow", "bold", '首页', '/static/tab/main1.png',
'https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4')
])
}
function theme() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/2.png"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(true, 34, 'bold', '#FFA500', "rgba(191, 191, 191, 0.5)", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4",
"/static/tab/main3_active.png",
12,
"green",
"white",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
let tabbarStyle : TabbarStyle = new TabbarStyle(120, "rgba(21, 20, 31, 0.8)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function theme2() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/sm.jpg"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(false, 34, 'bold', '#0c0c0c', "", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"/static/aj.png",
"/static/aj1.png",
20,
"green",
"red",
"bold"
)
let tabbarIcon1 = new TabbarIcon("home",
"/static/sy1.png",
"/static/sy.png",
16,
"black",
"yellow",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
tabbarIcons.set("home", tabbarIcon1)
let tabbarStyle : TabbarStyle = new TabbarStyle(120, "rgba(21, 20, 31, 0.4)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function theme3() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/777.png"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(true, 34, 'bold', '#0c0c0c', "", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"/static/aj.png",
"/static/aj1.png",
20,
"green",
"red",
"bold"
)
let tabbarIcon1 = new TabbarIcon("home",
"/static/sy1.png",
"/static/sy.png",
16,
"#1afa29",
"yellow",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
tabbarIcons.set("home", tabbarIcon1)
let tabbarStyle : TabbarStyle = new TabbarStyle(200, "rgba(10, 13, 31, 0.8)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function init() {
uni.stopPullDownRefresh()
console.log('stop onPullDownRefresh=====');
}
onPullDownRefresh(() => {
console.log('onPullDownRefresh=====');
init()
})
function refresh() {
uni.startPullDownRefresh({
success: () => {
console.log("1111")
}
});
init()
}
</script>
<style scoped lang="scss">
.mine-page {
height: 100%;
// border: 12px solid red;
margin: 5px;
}
.page-title {
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.title-text {
font-size: 18px;
font-weight: bold;
color: #333;
}
.tabbar {
// border: 2px solid cyan;
padding: 15px;
display: flex;
flex-direction: column;
}
.input-item {
height: 45px;
border: 1px solid #ccc;
border-radius: 6px;
padding-left: 10px;
background: #fff;
margin: 10px;
padding-left: 20px;
}
.picker-item {
height: 45px;
border: 1px solid #ccc;
border-radius: 6px;
background: #fff;
margin: 10px;
display: flex;
align-items: center;
}
.picker-display {
padding-left: 20px;
font-size: 16px;
color: #333;
}
.tabbar-btn {
margin-bottom: 10px;
border-radius: 8px;
background: #f5f5f5;
border: 1px solid #e5e5e5;
padding: 16px 0;
font-size: 16px;
}
.tabbar-btn:last-child {
margin-bottom: 0;
}
</style>
6.5 主题配置注意事项
- 颜色格式:颜色值必须为有效的 CSS 颜色格式(如
#ffffff、rgb(255,255,255)等) - tabbar区域高度单位:高度值为数字类型,单位为 rpx
- 动态修改:通过
uni.$emit('setTheme', new CustomTheme(bgType, bg, navBarConfig, tabbarStyle))动态修改的主题会立即生效
7 page页跳转自定义tab页
使用 pageToTab 方法 跳转到tab页
以 detail.uvue 页面为例,实现主题切换功能:
<template>
<view class="detail-root">
<view class="detail-content">
<view class="detail-item">
<text class="label">订单编号:</text>
<text class="value">{{ orderId }}</text>
</view>
<view class="detail-item">
<text class="label">订单状态:</text>
<text class="value">{{ orderStatus }}</text>
</view>
<view class="detail-desc">
<text>这是订单{{ orderId }}的详细信息页面</text>
</view>
<view>
<input v-model="content" placeholder="请输入。。" />
<text>{{sbase64}}</text>
<text>{{base64yostr}}</text>
</view>
<view>
<button type="primary" @click="req">访问testvoid</button>
<button type="primary" @click="reqSm2">访问testSm2</button>
<button type="primary" @click="reqSm4">访问testSm4</button>
<button type="primary" @click="reqSm2Sm4">访问testreqSm2Sm4</button>
<input style="height: 50px; border:1px solid grey;margin: 5px 0;" v-model="addr" placeholder="输入跳转tabbar key"/>
<button type="default" @click="tz">跳转指定tab页</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import { stringToBase64, base64ToString, ReqApi } from "@/uni_modules/sunrains-request"
import { Response, FrontRequestVo } from "@/uni_modules/sunrains-common-type"
import { CustFailError, getComErrorInfo } from "@/uni_modules/sunrains-common-err"
import { pageToTab} from "@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts"
const addr = ref("")
function tz(){
if(addr.value.length<1){
uni.showToast({
title: '请输入跳转的tabbar key'
});
}else{
pageToTab(addr.value)
}
}
let priKey = "4a4a1d48fc284fe2fc43970641880554aed2f69fdfbea63257ea417af4e65802"
let pubKey = "0460e86263ec52b38a3da24c7a605055d14f8aa5c7855e4f4e19819f4f8a5b72181afd7aa4ab19255eb871eaedaba30fa18cd5c71693d3d76b030a04969bd1edfe"
const BASEURL = "http://192.168.1.9:8081"
type IRootType = {
name : string;
age : number;
}
type Builds = {
buildingNo : string,
sortNum : number,
buildAreaId : string,
id : string
}
async function req() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'GET',
url: BASEURL + "/test/get/cs1",
data: data,
header: null,
reqFlag: "void",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqvoid==", res.data)
// console.log("####==",res)
} catch (err) {
uni.showToast({
title: err.message ?? "请求异常",
icon: "error"
})
console.log("-----", err)
return
};
}
//"sign"|"sm4Encrypt"|"signwithenSM2-Sm4Encrypt"|"void";
async function reqSm2() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'GET',
url: BASEURL + "/test/get/cs1",
data: data,
header: null,
reqFlag: "sign",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm2==", res.data)
} catch (err) {
uni.showToast({
title: err.message ?? "请求异常",
icon: "error"
})
//#ifndef APP-ANDROID
const errMsg = (err as any).errMsg ?? "未知错误";
const errCode = (err as any).errCode ?? -1;
console.log('标准化错误码:', errMsg);
console.log('标准化错误信息:', errCode);
//#else
if (err instanceof UniError) {
let sa = err as UniError
console.log('sa==:', sa);
}
//#endif
console.log("-----", err)
return
};
}
async function reqSm4() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'POST',
url: BASEURL + "/test/post/cs1",
data: data,
header: null,
reqFlag: "sm4Encrypt",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm4==", res.data)
} catch (err) {
console.log("####reqSm4 err==", err)
uni.showToast({
title: err.message??"",
icon: "error"
})
return
};
}
async function reqSm2Sm4() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'POST',
url: BASEURL + "/test/post/cs1",
data: data,
header: null,
reqFlag: "signwithenSM2-Sm4Encrypt",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm2Sm4==", res.data)
} catch (err) {
console.log("-----", err)
return
};
}
const orderId = ref<string>('')
const orderStatus = ref<string>('')
const content = ref<string>('')
const sbase64 = computed(() => {
return stringToBase64(content.value)
})
const base64yostr = computed(() => {
return base64ToString(sbase64.value)
})
onLoad((options) => {
const opts = options as UTSJSONObject
const id = options?.["orderId"] as string | null
const status = options?.["status"] as string | null
if (id != null) {
orderId.value = id
}
if (status != null) {
orderStatus.value = status
}
console.log(`接收订单参数:ID=${orderId.value},状态=${orderStatus.value}`)
})
const goBack = () => {
uni.navigateBack()
}
function init(){
// uni.stopPullDownRefresh()
// console.log('stop onPullDownRefresh=====');
}
// onPullDownRefresh(() => {
// console.log('onPullDownRefresh=====');
// init()
// })
function refresh(){
uni.removeStorageSync("LOGINTABBARITEMS")
// uni.startPullDownRefresh({
// success:()=>{
// console.log("1111")
// }
// });
// init()
}
</script>
<style scoped>
.detail-root {
/* flex: 1;
display: flex;
flex-direction: column; */
background-color: #fff;
}
.status-bar {
height: 44px;
background-color: #ffffff;
}
.detail-header {
height: 44px;
background-color: #fff;
flex-direction: row;
align-items: center;
padding-left: 15px;
padding-right: 15px;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #eee;
}
.back-icon {
font-size: 18px;
color: #007aff;
margin-right: 10px;
}
.detail-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.detail-content {
flex: 1;
padding: 20px;
}
.detail-item {
flex-direction: row;
margin-bottom: 20px;
align-items: center;
}
.label {
font-size: 15px;
color: #666;
width: 80px;
}
.value {
font-size: 15px;
color: #333;
font-weight: bold;
}
.detail-desc {
margin-top: 30px;
font-size: 14px;
color: #999;
}
</style>
⚠️ 常见问题
Q1: 为什么小程序端需要单独配置?
A: 微信小程序不支持动态组件渲染,必须静态声明所有可能使用的组件。
Q2: 动态 Tab 刷新失败怎么办?
A: 检查以下几点:
- 组件是否在
tabbar-config.uts中正确注册 - 是否正确使用了
GLOBAL_COMPONENT_REGISTRY.get()获取组件 - TabItem 的参数是否完整且类型正确
Q3: 导航栏配置不生效?
A: 确保 setNavbarConfig() 在 setDefaultTabItems() 之前调用,或在应用启动时尽早调用。
Q4: 如何设置数字角标?
A: 使用 uni.$emit('setTabBadge', {"key": "tab_key", "badge": number}) 即可,详见第 4 章。
Q5: 角标设置后不显示怎么办?
A: 检查以下几点:
- badge 值是否大于 0(0 或负数不显示)
- tab key 是否正确(必须与 TabItem 的 key 一致)
- 是否重新赋值了整个数组(直接修改对象属性不会触发响应式)
📝 总结
- 基础配置三要素:注册组件 → 配置导航栏 → 设置默认 Tab
- pages.json:自定义 TabBar 入口必须放在第一位
- 动态刷新:通过
uni.$emit('refreshNewTabbar', [...])实现 - 数字角标:通过
uni.$emit('setTabBadge', {"key": "...", "badge": number})设置 - 小程序兼容:需要在
tab-content-renderer.uvue中静态声明组件 - 主题配置 通过
uni.$emit('setTheme', new CustomTheme(bgType, bg, navBarConfig, tabbarStyle))动态修改
sunrains-tabbar 自定义 TabBar 插件使用指南
点击体验h5端
📋 目录
- [1. 基础配置]
- [2. pages.json 配置]
- [3. 动态配置 TabBar]
- [4. 数字角标功能]
- [5. 小程序端特殊配置]
- [6. 主题配置]
- [7. page页跳转自定义tab页]
1. 基础配置
App.uvue onLaunch 初始化
import {initDefaultTabItems} from '@/uni_modules/sunrains-tabbar/tabbar-config.uts'
onLaunch(() => {
console.log('App Launch')
initDefaultTabItems()
}
1.1 创建 tabbar-config.uts 文件
在项目根目录创建 tabbar-config.uts 文件,用于注册全局组件和配置导航栏。
完整示例:
// 1. 导入所有自定义 tabbar 页面组件并注册
import Recommend from '@/pages/home/recommend.uvue'
import Order from '@/pages/order/order.uvue'
import Mine from '@/pages/mine/mine.uvue'
import {
setNavbarConfig,
NavBarConfig,
registerGlobalComponent,
setDefaultTabItems,
TabItem
} from '@/uni_modules/sunrains-tabbar/types.uts'
// 2. 注册全局组件
registerGlobalComponent('home', Recommend)
registerGlobalComponent('order', Order)
registerGlobalComponent('mine', Mine)
// 3. 配置导航栏(可选,不设置会使用默认配置)
setNavbarConfig(new NavBarConfig(
true, // 是否显示导航栏
17, // 字体大小
'bold', // 字体粗细
'#333333', // 文字颜色
'#ffffff', // 背景颜色
'', // 背景图片(支持网络图片)
56, // 导航栏高度
))
// 4. 设置默认 Tab 列表(至少设置一个固定项)
// 其他 Tab 可通过服务端接口动态配置
setDefaultTabItems([
new TabItem(
'home', // 唯一标识
'首页', // 显示名称
'/static/tab/main1.png', // 未选中图标
'/static/tab/main1_active.png', // 选中图标
'@/pages/home/recommend.uvue', // 页面路径
Recommend // 组件引用
),
new TabItem(
'mine',
'我的',
'/static/tab/main3.png',
'/static/tab/main3_active.png'
)
])
关键步骤说明:
- 导入组件:将所有需要作为 Tab 的页面组件导入
- 注册组件:使用
registerGlobalComponent注册每个组件(第一个参数为唯一标识) - 配置导航栏:可选配置,根据项目需求调整
- 设置默认 Tab:至少配置一个固定 Tab,其余可动态添加
2. pages.json 配置
2.1 必需配置
必须在 pages.json 中配置自定义 TabBar 入口页面(必须放在第一个位置):
{
"path": "uni_modules/sunrains-tabbar/index",
"style": {
"navigationBarTitleText": "自定义tabbar",
"navigationStyle": "custom"
}
}
2.2 完整示例
{
"pages": [
{
"path": "uni_modules/sunrains-tabbar/index",
"style": {
"navigationBarTitleText": "自定义tabbar",
"navigationStyle": "custom"
}
},
{
"path": "pages/home/recommend",
"style": {
"navigationBarTitleText": "首页"
}
},
{
"path": "pages/mine/mine",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/order/order",
"style": {
"navigationBarTitleText": "订单"
}
},
{
"path": "pages/detail/detail",
"style": {
"navigationBarTitleText": "详情"
}
}
],
"globalStyle": {
"backgroundColor": "#ffffff"
}
}
注意事项:
- 自定义 TabBar 入口页面必须配置在
pages数组的第一位 - 所有 Tab 页面都需要在
pages中声明 navigationStyle设置为"custom"以隐藏原生导航栏
3. 动态配置 TabBar
3.1 使用场景
当需要根据用户权限、服务端配置等动态调整 TabBar 时,可以在任意页面触发刷新。
3.2 实现示例
以下面文档 mine.uvue 页面为例:
关键点:
- 使用 refreshTabbar方法触发 TabBar 刷新
- 确保所有动态 Tab 对应的组件已在
tabbar-config.uts中注册
4. 数字角标功能
4.1 功能说明
支持为任意 Tab 设置数字角标,用于显示未读消息数、待处理事项等。
特性:
- ✅ 动态设置/更新角标
- ✅ 超过 99 自动显示 "99+"
- ✅ 角标为 0 或 null 时不显示
- ✅ 响应式更新,立即生效
4.2 使用方法
方法一:通过事件设置(推荐)
在任意页面通过 调用 setTabBadge 方法:
import { setTabBadge} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 设置订单 tab 的角标为 5
setTabBadge("order",5)
// 清除角标(设置为 0)
setTabBadge("order",0)
// 设置超过 99 显示 99+
setTabBadge("order",150)
方法二:初始化时设置
在创建 TabItem 时传入 badge 参数:
import { TabItem } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
// 带角标的 tab
new TabItem(
'order',
'订单',
'/static/tab/main2.png',
'/static/tab/main2_active.png'
10 // badge 参数
)
// 不带角标的 tab
new TabItem(
'home',
'首页',
'/static/tab/main1.png',
'/static/tab/main1_active.png'
)
4.3 完整示例
以 文档后面 mine.uvue 页面为例,实现动态设置角标功能:
4.4 实际应用场景
场景 1:订单未读数
// 从服务端获取未读订单数
async function loadUnreadOrderCount() {
const res = await getOrderCount()
setTabBadge("order",res.data.count)
}
// 页面加载时调用
onShow(() => {
loadUnreadOrderCount()
})
场景 2:消息通知
// WebSocket 收到新消息
websocket.onMessage((msg) => {
if (msg.type === 'new_message') {
const currentBadge = getCurrentBadge('message')
setTabBadge("newMessage",currentBadge + 1)
}
})
场景 3:购物车数量
// 添加商品到购物车后更新角标
function addToCart(product: Product) {
cartItems.push(product)
setTabBadge("cart",cartItems.length)
}
5. 小程序端特殊配置
4.1 配置 tab-content-renderer.uvue
注意: 仅在需要支持微信小程序时需要配置,App 端可忽略此步骤。
在 uni_modules/sunrains-tabbar/tab-content-renderer.uvue 中配置:
<template>
<view class="renderer-wrap">
<!-- App 端:使用动态组件 -->
<!-- #ifndef MP-WEIXIN -->
<template v-for="item in items" :key="item.key">
<component
v-if="currentTab === item.key"
:is="item.component"
:key="`${item.key}-${renderKey}`"
/>
</template>
<!-- #endif -->
<!-- 微信小程序端:使用静态声明 -->
<!-- #ifdef MP-WEIXIN -->
<Recommend v-if="currentTab === 'home'" style="width: 100%; height: 100%;" />
<Order v-if="currentTab === 'order'" style="width: 100%; height: 100%;" />
<Mine v-if="currentTab === 'mine'" style="width: 100%; height: 100%;" />
<!-- #endif -->
</view>
</template>
<script setup lang="uts">
import { TabItem } from './utssdk/types.uts'
// #ifdef MP-WEIXIN
// 只有在小程序端才需要这些导入,App 端会自动忽略
import Recommend from '@/pages/home/recommend.uvue'
import Order from '@/pages/order/order.uvue'
import Mine from '@/pages/mine/mine.uvue'
// #endif
interface Props {
currentTab: string
items: TabItem[]
renderKey: number
}
defineProps<Props>()
</script>
<style scoped>
.renderer-wrap {
width: 100%;
height: 100%;
}
</style>
配置说明:
- 条件编译:使用
#ifndef MP-WEIXIN和#ifdef MP-WEIXIN区分平台 - App 端:使用
<component :is="...">动态渲染组件 - 小程序端:必须静态导入并声明所有可能的 Tab 组件
- 导入位置:在
#ifdef MP-WEIXIN块内导入组件
6. 主题配置
6.1 主题功能说明
插件支持自定义 TabBar 的主题样式,支持网络背景图、本地图片背景图、纯颜色背景(支持rgba透明)、导航栏、tabbar区域背景、图标大小、图标(支持网络图标)、文字颜色、文字大小、等属性 满足不同项目的视觉需求。
主题特性:
- ✅ 支持全局主题配置
- ✅ 支持动态切换主题
- ✅ 支持自定义主题参数
6.2 初始化时配置主题
在 tabbar-config.uts 文件中配置主题:
import {
setNavbarConfig,
NavBarConfig,
registerGlobalComponent,
setDefaultTabItems,
TabItem,
setTabBarTheme,
TabBarTheme
} from '@/uni_modules/sunrains-tabbar/types.uts'
import { setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
let bgType : BgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/2.png"
//配置导航栏(可选,不设置会使用默认配置)
let navBarConfig : NavBarConfig = new NavBarConfig(
true, // 是否显示导航栏
34, // 字体大小
'bold', // 字体粗细
'#FFA500', // 文字颜色
'',// 背景颜色
"", //背景图片(支持网络图片)
90) // 导航栏高度
let tabbarIcon = new TabbarIcon(
"mine",//自定义组件唯一标识
//非选中图标
"https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4",
//选中图标
"/static/tab/main3_active.png",
//文字颜色
12,
//非选中文字大小
"green",
//选择文字颜色
"white",
//选择文字粗细
"bold"
)
let tabbarIcons : Map<string,TabbarIcon> = new Map();
tabbarIcons.set("mine",tabbarIcon)
let tabbarStyle : TabbarStyle = new TabbarStyle(
//tabbar区域高度
120,
//tabbar区域背景
"rgba(21, 20, 31, 0.8)",
//tabbar图标文字样式
tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
6.3 动态修改主题
在任意页面通过调用 setTabPageTheme 方法动态修改主题:
import { setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 动态修改背景色和选中颜色
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
6.4 在页面中动态切换主题
以 mine.uvue 页面为例,实现主题切换功能:
<template>
<scroll-view direction="vertical" class="mine-page"
show-scrollbar = "false"
enable-back-to-top="true"
refresher-enabled="true"
refresher-default-style="none"
refresher-background="rgba(232, 201, 183, 0.5)"
:refresher-triggered="data.refreshing2"
refresher-max-drag-distance="200px"
@refresherpulling="onRefresherpulling2"
@refresherrefresh="onRefresherrefresh2"
@refresherrestore="onRefresherrestore2"
>
<uni-refresh-box
slot="refresher"
:pulling-distance="data.pullingDistance2"
:refreshing="data.refreshing2"
loading-class="loading-dark"
text-class="uni-text-class-buildin"
style="flex-direction: column;height: 46px;padding-top: 6px;"
pulling-text="继续下拉可刷新"
loosing-text="释放后会刷新"
loading-text="奋力加载中..."
/>
<view class="page-title">
<text style="color:#ff4d40">动态设置中心</text>
</view>
<view class="tabbar">
<text class="title-text">tabbar设置</text>
<button class="tabbar-btn" @click="refresh1">动态tabbar(我的)</button>
<button class="tabbar-btn" @click="refresh2">动态tabbar(首页、我的)</button>
<button class="tabbar-btn" @click="refresh3">动态tabbar(订单、我的)</button>
<button class="tabbar-btn" @click="refresh4">动态tabbar(我的、订单、首页)</button>
<text class="title-text">设置角标</text>
<input class="input-item" v-model="tabbar" placeholder="请输入tabbar" />
<input class="input-item" v-model="badge" placeholder="请输入角标" />
<button class="tabbar-btn" @click="setBadge">确定</button>
<button class="tabbar-btn" @click="theme">主题1</button>
<button class="tabbar-btn" @click="theme2">主题2</button>
<button class="tabbar-btn" @click="theme3">主题3</button>
<button class="tabbar-btn" @click="clear">clear</button>
<button class="tabbar-btn" type="default" @click="refresh">刷新</button>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { TabItem, GLOBAL_COMPONENT_REGISTRY, CustomTheme, TabbarIcon, TabbarStyle, NavBarConfig } from '@/uni_modules/sunrains-tabbar/utssdk/types.uts'
import { setTabBadge,setTabbarColorHeight,refreshTabbar ,setTabPageTheme} from '@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts'
// 用于自动化测试的数据暴露 - 使用 reactive 将所有 ref 状态集中暴露
const data = reactive({
refreshing2: false,
pullingDistance2: 0
})
function onRefresherpulling2(e: RefresherEvent) {
data.pullingDistance2 = e.detail.dy
}
const refreshing = ref(false)
function onRefresherrefresh2() {
data.refreshing2 = true
console.log('触发刷新')
setTimeout(() => {
data.refreshing2 = false
console.log('刷新完成')
}, 1500)
}
function onRefresherrestore2() {
data.pullingDistance2 = 0
}
function onRefresherabort2() {
data.pullingDistance2 = 0
}
// 上拉触底加载
const loadMore = () => {
console.log("滚动到底部", "info")
}
function clear() {
uni.removeStorageSync("CUSTOMTHEME")
}
const bgColor = ref<string>('pink')
const tabHeight = ref<string>('120')
const badge = ref<string>('0')
const ss = ref<any>("");
// tab 选项配置
const tabbar = ref<string | null>(null)
const selectedTab = ref<string>('mine')
const selectedTabIndex = ref<number>(0)
function setBadge() {
//动态设置指定tabbar 角标
const badgeNum = parseInt(badge.value)
if (isNaN(badgeNum)) {
uni.showToast({
title: '请输入有效数字',
icon: 'none'
})
return
}
setTabBadge(tabbar.value,badgeNum)
uni.showToast({
title: `已设置 ${tabbar.value} 角标为 ${badgeNum}`,
icon: 'success'
})
}
function setStyle() {
setTabbarColorHeight(bgColor.value,parseInt(tabHeight.value) )
}
function refresh1() {
console.log("动态tabbar(我的)")
refreshTabbar([
new TabItem('mine', 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png')
]);
}
function refresh2() {
console.log("动态tabbar(首页、我的)")
refreshTabbar([
new TabItem('home', 12, "red", "yellow", "bold", '首页', '/static/tab/main1.png', '/static/tab/main1_active.png'),
new TabItem('mine', 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png')
])
}
function refresh3() {
console.log("动态tabbar(订单、我的)")
refreshTabbar( [
new TabItem('order', 12, "red", "yellow", "bold", '订单', '/static/tab/main2.png', '/static/tab/main2_active.png'),
new TabItem('mine', 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png')
])
}
function refresh4() {
console.log("动态tabbar(我的、订单、首页)")
refreshTabbar([
new TabItem('mine', 12, "red", "yellow", "bold", '我的', '/static/tab/main3.png', '/static/tab/main3_active.png'),
new TabItem('order', 12, "red", "yellow", "bold", '订单', '/static/tab/main2.png', '/static/tab/main2_active.png'),
new TabItem('home', 12, "red", "yellow", "bold", '首页', '/static/tab/main1.png',
'https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4')
])
}
function theme() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/2.png"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(true, 34, 'bold', '#FFA500', "rgba(191, 191, 191, 0.5)", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"https://gimg3.baidu.com/search/src=https%3A%2F%2Fpic.rmb.bdstatic.com%2Fbjh%2Fuser%2F779083e57f84a4ce5345b17aaf7f8459.jpeg&refer=http%3A%2F%2Fwww.baidu.com&app=2021&size=r1,1&n=0&g=4&er=404&q=100&maxorilen2heic=2000000?sec=1781283600&t=4feca8b5a1149e3ed0f28ee030f767b4",
"/static/tab/main3_active.png",
12,
"green",
"white",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
let tabbarStyle : TabbarStyle = new TabbarStyle(120, "rgba(21, 20, 31, 0.8)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function theme2() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/sm.jpg"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(false, 34, 'bold', '#0c0c0c', "", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"/static/aj.png",
"/static/aj1.png",
20,
"green",
"red",
"bold"
)
let tabbarIcon1 = new TabbarIcon("home",
"/static/sy1.png",
"/static/sy.png",
16,
"black",
"yellow",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
tabbarIcons.set("home", tabbarIcon1)
let tabbarStyle : TabbarStyle = new TabbarStyle(120, "rgba(21, 20, 31, 0.4)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function theme3() {
//主题背景类型
let bgType = "IMG"
//背景 如果是图片传图片路径 颜色则传 具体颜色
let bg : string = "/static/777.png"
// let bg:string = "black"
let navBarConfig : NavBarConfig = new NavBarConfig(true, 34, 'bold', '#0c0c0c', "", "", 90)
let tabbarIcon = new TabbarIcon("mine",
"/static/aj.png",
"/static/aj1.png",
20,
"green",
"red",
"bold"
)
let tabbarIcon1 = new TabbarIcon("home",
"/static/sy1.png",
"/static/sy.png",
16,
"#1afa29",
"yellow",
"bold"
)
let tabbarIcons : Map<string, TabbarIcon> = new Map();
tabbarIcons.set("mine", tabbarIcon)
tabbarIcons.set("home", tabbarIcon1)
let tabbarStyle : TabbarStyle = new TabbarStyle(200, "rgba(10, 13, 31, 0.8)", tabbarIcons)
let theme = new CustomTheme(bgType, bg, navBarConfig, tabbarStyle);
setTabPageTheme(theme)
}
function init() {
uni.stopPullDownRefresh()
console.log('stop onPullDownRefresh=====');
}
onPullDownRefresh(() => {
console.log('onPullDownRefresh=====');
init()
})
function refresh() {
uni.startPullDownRefresh({
success: () => {
console.log("1111")
}
});
init()
}
</script>
<style scoped lang="scss">
.mine-page {
height: 100%;
// border: 12px solid red;
margin: 5px;
}
.page-title {
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.title-text {
font-size: 18px;
font-weight: bold;
color: #333;
}
.tabbar {
// border: 2px solid cyan;
padding: 15px;
display: flex;
flex-direction: column;
}
.input-item {
height: 45px;
border: 1px solid #ccc;
border-radius: 6px;
padding-left: 10px;
background: #fff;
margin: 10px;
padding-left: 20px;
}
.picker-item {
height: 45px;
border: 1px solid #ccc;
border-radius: 6px;
background: #fff;
margin: 10px;
display: flex;
align-items: center;
}
.picker-display {
padding-left: 20px;
font-size: 16px;
color: #333;
}
.tabbar-btn {
margin-bottom: 10px;
border-radius: 8px;
background: #f5f5f5;
border: 1px solid #e5e5e5;
padding: 16px 0;
font-size: 16px;
}
.tabbar-btn:last-child {
margin-bottom: 0;
}
</style>
6.5 主题配置注意事项
- 颜色格式:颜色值必须为有效的 CSS 颜色格式(如
#ffffff、rgb(255,255,255)等) - tabbar区域高度单位:高度值为数字类型,单位为 rpx
- 动态修改:通过
uni.$emit('setTheme', new CustomTheme(bgType, bg, navBarConfig, tabbarStyle))动态修改的主题会立即生效
7 page页跳转自定义tab页
使用 pageToTab 方法 跳转到tab页
以 detail.uvue 页面为例,实现主题切换功能:
<template>
<view class="detail-root">
<view class="detail-content">
<view class="detail-item">
<text class="label">订单编号:</text>
<text class="value">{{ orderId }}</text>
</view>
<view class="detail-item">
<text class="label">订单状态:</text>
<text class="value">{{ orderStatus }}</text>
</view>
<view class="detail-desc">
<text>这是订单{{ orderId }}的详细信息页面</text>
</view>
<view>
<input v-model="content" placeholder="请输入。。" />
<text>{{sbase64}}</text>
<text>{{base64yostr}}</text>
</view>
<view>
<button type="primary" @click="req">访问testvoid</button>
<button type="primary" @click="reqSm2">访问testSm2</button>
<button type="primary" @click="reqSm4">访问testSm4</button>
<button type="primary" @click="reqSm2Sm4">访问testreqSm2Sm4</button>
<input style="height: 50px; border:1px solid grey;margin: 5px 0;" v-model="addr" placeholder="输入跳转tabbar key"/>
<button type="default" @click="tz">跳转指定tab页</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import { stringToBase64, base64ToString, ReqApi } from "@/uni_modules/sunrains-request"
import { Response, FrontRequestVo } from "@/uni_modules/sunrains-common-type"
import { CustFailError, getComErrorInfo } from "@/uni_modules/sunrains-common-err"
import { pageToTab} from "@/uni_modules/sunrains-tabbar/utssdk/noticetab.uts"
const addr = ref("")
function tz(){
if(addr.value.length<1){
uni.showToast({
title: '请输入跳转的tabbar key'
});
}else{
pageToTab(addr.value)
}
}
let priKey = "4a4a1d48fc284fe2fc43970641880554aed2f69fdfbea63257ea417af4e65802"
let pubKey = "0460e86263ec52b38a3da24c7a605055d14f8aa5c7855e4f4e19819f4f8a5b72181afd7aa4ab19255eb871eaedaba30fa18cd5c71693d3d76b030a04969bd1edfe"
const BASEURL = "http://192.168.1.9:8081"
type IRootType = {
name : string;
age : number;
}
type Builds = {
buildingNo : string,
sortNum : number,
buildAreaId : string,
id : string
}
async function req() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'GET',
url: BASEURL + "/test/get/cs1",
data: data,
header: null,
reqFlag: "void",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqvoid==", res.data)
// console.log("####==",res)
} catch (err) {
uni.showToast({
title: err.message ?? "请求异常",
icon: "error"
})
console.log("-----", err)
return
};
}
//"sign"|"sm4Encrypt"|"signwithenSM2-Sm4Encrypt"|"void";
async function reqSm2() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'GET',
url: BASEURL + "/test/get/cs1",
data: data,
header: null,
reqFlag: "sign",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm2==", res.data)
} catch (err) {
uni.showToast({
title: err.message ?? "请求异常",
icon: "error"
})
//#ifndef APP-ANDROID
const errMsg = (err as any).errMsg ?? "未知错误";
const errCode = (err as any).errCode ?? -1;
console.log('标准化错误码:', errMsg);
console.log('标准化错误信息:', errCode);
//#else
if (err instanceof UniError) {
let sa = err as UniError
console.log('sa==:', sa);
}
//#endif
console.log("-----", err)
return
};
}
async function reqSm4() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'POST',
url: BASEURL + "/test/post/cs1",
data: data,
header: null,
reqFlag: "sm4Encrypt",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm4==", res.data)
} catch (err) {
console.log("####reqSm4 err==", err)
uni.showToast({
title: err.message??"",
icon: "error"
})
return
};
}
async function reqSm2Sm4() {
let data : UTSJSONObject = {
name: "张三",
age: 18
}
try {
let vo : FrontRequestVo = {
method: 'POST',
url: BASEURL + "/test/post/cs1",
data: data,
header: null,
reqFlag: "signwithenSM2-Sm4Encrypt",
priKey: priKey,
pubKey: pubKey,
//method 为POST时 不能为null
contentType: "urlencoded",
timeout: null,
//当为上传文件时 文件路径参数不能为空
filePathParamName: null
}
const res = await ReqApi(vo)
console.log("####reqSm2Sm4==", res.data)
} catch (err) {
console.log("-----", err)
return
};
}
const orderId = ref<string>('')
const orderStatus = ref<string>('')
const content = ref<string>('')
const sbase64 = computed(() => {
return stringToBase64(content.value)
})
const base64yostr = computed(() => {
return base64ToString(sbase64.value)
})
onLoad((options) => {
const opts = options as UTSJSONObject
const id = options?.["orderId"] as string | null
const status = options?.["status"] as string | null
if (id != null) {
orderId.value = id
}
if (status != null) {
orderStatus.value = status
}
console.log(`接收订单参数:ID=${orderId.value},状态=${orderStatus.value}`)
})
const goBack = () => {
uni.navigateBack()
}
function init(){
// uni.stopPullDownRefresh()
// console.log('stop onPullDownRefresh=====');
}
// onPullDownRefresh(() => {
// console.log('onPullDownRefresh=====');
// init()
// })
function refresh(){
uni.removeStorageSync("LOGINTABBARITEMS")
// uni.startPullDownRefresh({
// success:()=>{
// console.log("1111")
// }
// });
// init()
}
</script>
<style scoped>
.detail-root {
/* flex: 1;
display: flex;
flex-direction: column; */
background-color: #fff;
}
.status-bar {
height: 44px;
background-color: #ffffff;
}
.detail-header {
height: 44px;
background-color: #fff;
flex-direction: row;
align-items: center;
padding-left: 15px;
padding-right: 15px;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #eee;
}
.back-icon {
font-size: 18px;
color: #007aff;
margin-right: 10px;
}
.detail-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.detail-content {
flex: 1;
padding: 20px;
}
.detail-item {
flex-direction: row;
margin-bottom: 20px;
align-items: center;
}
.label {
font-size: 15px;
color: #666;
width: 80px;
}
.value {
font-size: 15px;
color: #333;
font-weight: bold;
}
.detail-desc {
margin-top: 30px;
font-size: 14px;
color: #999;
}
</style>
⚠️ 常见问题
Q1: 为什么小程序端需要单独配置?
A: 微信小程序不支持动态组件渲染,必须静态声明所有可能使用的组件。
Q2: 动态 Tab 刷新失败怎么办?
A: 检查以下几点:
- 组件是否在
tabbar-config.uts中正确注册 - 是否正确使用了
GLOBAL_COMPONENT_REGISTRY.get()获取组件 - TabItem 的参数是否完整且类型正确
Q3: 导航栏配置不生效?
A: 确保 setNavbarConfig() 在 setDefaultTabItems() 之前调用,或在应用启动时尽早调用。
Q4: 如何设置数字角标?
A: 使用 uni.$emit('setTabBadge', {"key": "tab_key", "badge": number}) 即可,详见第 4 章。
Q5: 角标设置后不显示怎么办?
A: 检查以下几点:
- badge 值是否大于 0(0 或负数不显示)
- tab key 是否正确(必须与 TabItem 的 key 一致)
- 是否重新赋值了整个数组(直接修改对象属性不会触发响应式)
📝 总结
- 基础配置三要素:注册组件 → 配置导航栏 → 设置默认 Tab
- pages.json:自定义 TabBar 入口必须放在第一位
- 动态刷新:通过
uni.$emit('refreshNewTabbar', [...])实现 - 数字角标:通过
uni.$emit('setTabBadge', {"key": "...", "badge": number})设置 - 小程序兼容:需要在
tab-content-renderer.uvue中静态声明组件 - 主题配置 通过
uni.$emit('setTheme', new CustomTheme(bgType, bg, navBarConfig, tabbarStyle))动态修改
uniapp x插件 SM2 SM3 SM4 AES RSA 加解密 签名 验签 客户端 服务端 全平台解决方案
sunrains-smutil 插件使用指南
本插件提供SM2、SM3 、 SM4、AES、RSA 客户端(ios,安卓,鸿蒙,微信小程序,h5),服务端(java)SM2、SM3 、 SM4、AES、RSA 算法的跨平台实现,支持以下功能:
SM2 非对称加密算法
✅ SM2 密钥对生成
✅ SM2 数字签名
✅ SM2 签名验证
✅ SM2 数据加密
✅ SM2 数据解密
SM3 密码杂凑算法
✅ SM3 哈希计算(字符串)
✅ HMAC-SM3 密钥哈希
✅ 文件 SM3 哈希
✅ 哈希值验证
SM4 对称加密算法
✅ SM4 密钥生成(含 IV)
✅ SM4 数据加密(CBC 模式)
✅ SM4 数据解密(CBC 模式)
AES 对称加密算法
✅ AES 密钥和 IV 生成
✅ AES 数据加密(CBC 模式, PKCS7/PKCS5 填充)
✅ AES 数据解密(CBC 模式, PKCS7/PKCS5 填充)
✅ 跨平台兼容(Android/iOS/HarmonyOS/H5/微信小程序)
RSA 非对称加密算法
✅ RSA 密钥对生成(2048位)
✅ RSA-OAEP-SHA256 数据加密
✅ RSA-OAEP-SHA256 数据解密
✅ SHA256withRSA 数字签名(PKCS#1 v1.5)
✅ SHA256withRSA 签名验证(PKCS#1 v1.5)
✅ 跨平台兼容(Android/iOS/HarmonyOS/H5)
⚠️ 微信小程序端暂不支持RSA-OAEP-SHA256加密解密,仅支持签名验签
调用方式
本插件同时支持回调风格和异步风格两种调用方式:
方式一:回调风格(传统方式)
import { sm2Sign } from "@/uni_modules/sunrains-util";
sm2Sign({
data: "需要签名的数据",
privateKey: "你的私钥",
success: (res) => {
console.log("签名结果:", res.signature);
},
fail: (err) => {
console.error("签名失败:", err);
}
});
方式二:异步风格(推荐,更简洁)
import { sm2SignAsync } from "@/uni_modules/sunrains-util";
async function doSign() {
try {
const result = await sm2SignAsync("需要签名的数据", "你的私钥")
console.log("签名结果:", result.signature)
} catch (error) {
console.error("签名失败:", error)
}
}
平台支持
| 平台 | SM2 实现方式 | SM4 实现方式 | AES 实现方式 | RSA 实现方式 | 状态 |
|---|---|---|---|---|---|
| Android | BouncyCastle | BouncyCastle | Java Crypto API | Java Crypto API (RSA-OAEP-SHA256) | ✅ 完全支持 |
| iOS | GMObjC | GMObjC | CommonCrypto | Security Framework (RSA-OAEP-SHA256) | ✅ 完全支持 |
| HarmonyOS | cryptoFramework | cryptoFramework | cryptoFramework | cryptoFramework (RSA-OAEP-SHA256) | ✅ 完全支持 |
| H5 | sm-crypto | sm-crypto | Web Crypto API | Web Crypto API (RSA-OAEP-SHA256) | ✅ 完全支持 |
| 微信小程序 | miniprogram-sm-crypto | miniprogram-sm-crypto | crypto-js | jsrsasign (仅签名验签) | ⚠️ 部分支持 |
使用示例
SM2 相关操作
1. 生成 SM2 密钥对
同步方式:
import { sm2GenerateKeyPair } from "@/uni_modules/sunrains-util";
const result = sm2GenerateKeyPair();
console.log("公钥:", result.publicKey); // 04开头的130位十六进制字符串
console.log("私钥:", result.privateKey); // 64位十六进制字符串
异步方式:
import { sm2GenerateKeyPairAsync } from "@/uni_modules/sunrains-util";
async function generateKeys() {
const result = await sm2GenerateKeyPairAsync()
console.log("公钥:", result.publicKey)
console.log("私钥:", result.privateKey)
}
2. SM2 签名
回调风格:
import { sm2Sign } from "@/uni_modules/sunrains-util";
sm2Sign({
data: "需要签名的数据",
privateKey: "你的私钥",
success: (res) => {
console.log("签名结果:", res.signature);
},
fail: (err) => {
console.error("签名失败:", err);
}
});
异步风格(推荐):
import { sm2SignAsync } from "@/uni_modules/sunrains-util";
async function doSign() {
try {
const result = await sm2SignAsync("需要签名的数据", "你的私钥")
console.log("签名结果:", result.signature)
} catch (error) {
console.error("签名失败:", error)
}
}
3. SM2 验签
回调风格:
import { sm2Verify } from "@/uni_modules/sunrains-util";
sm2Verify({
data: "原始数据",
signature: "签名值",
publicKey: "你的公钥",
success: (res) => {
console.log("验签结果:", res.valid); // true 或 false
},
fail: (err) => {
console.error("验签失败:", err);
}
});
异步风格(推荐):
import { sm2VerifyAsync } from "@/uni_modules/sunrains-util";
async function doVerify() {
try {
const result = await sm2VerifyAsync("原始数据", "签名值", "你的公钥")
console.log("验签结果:", result.valid) // true 或 false
} catch (error) {
console.error("验签失败:", error)
}
}
完整实战示例
以下是一个完整的国密算法测试页面示例,及服务端(java)工具类,展示了所有功能的实际使用:
客户端 Vue 3 Composition API 示例
<template>
<view class="order-content">
<view class="order-header">
<text class="order-title">国密算法测试</text>
</view>
<scroll-view class="order-list" scroll-y="true">
<view class="section">
<view class="section-title">SM4 对称加密</view>
<view class="input-group">
<text class="label">待加密内容:</text>
<input v-model="sm4Content" placeholder="请输入要加密的内容" class="input" />
</view>
<button @click="testSm4Encrypt" class="btn btn-primary">SM4 加密</button>
<view class="result-box" v-if="sm4Key.key">
<text class="result-label">密钥 (key):</text>
<text selectable="true" class="result-value">{{ sm4Key.key }}</text>
</view>
<view class="result-box" v-if="sm4Key.iv">
<text class="result-label">初始向量 (iv):</text>
<text selectable="true" class="result-value">{{ sm4Key.iv }}</text>
</view>
<view class="result-box" v-if="sm4Encrypted">
<text class="result-label">加密结果:</text>
<text selectable="true" class="result-value">{{ sm4Encrypted }}</text>
</view>
<button @click="testSm4Decrypt" :disabled="sm4Encrypted.length==0" class="btn btn-secondary">SM4 解密</button>
<button @click="testSm4Decrypt" :disabled="sm4Encrypted === ''" :class="sm4Encrypted === '' ? 'btn btn-secondary disabled' : 'btn btn-secondary'" class="btn btn-secondary">SM4 解密</button>
<view class="result-box" v-if="sm4Decrypted">
<text class="result-label">解密结果:</text>
<text selectable="true" class="result-value success">{{ sm4Decrypted }}</text>
</view>
</view>
<view class="section">
<view class="section-title">SM2 数字签名</view>
<view class="input-group">
<text class="label">待签名内容:</text>
<input v-model="sm2SignContent" placeholder="请输入要签名的内容" class="input" />
</view>
<button @click="testSm2Sign" class="btn btn-primary">生成密钥对并签名</button>
<view class="result-box" v-if="sm2KeyPair.publicKey">
<text class="result-label">公钥:</text>
<text selectable="true" class="result-value small">{{ sm2KeyPair.publicKey }}</text>
</view>
<view class="result-box" v-if="sm2KeyPair.privateKey">
<text class="result-label">私钥:</text>
<text selectable="true" class="result-value small">{{ sm2KeyPair.privateKey }}</text>
</view>
<view class="result-box" v-if="sm2Signature">
<text class="result-label">签名结果:</text>
<text selectable="true" class="result-value small">{{ sm2Signature }}</text>
</view>
<button @click="testSm2Verify" :disabled="sm2Signature.length==0" class="btn btn-secondary">验签</button>
<view class="result-box" v-if="sm2VerifyResult !== null">
<text class="result-label">验签结果:</text>
<text class="result-value" :class="sm2VerifyResult ==true? 'success' : 'error'">
{{ sm2VerifyResult ==true ? '✅ 验签成功' : '❌ 验签失败' }}
</text>
</view>
</view>
<view class="section">
<view class="section-title">SM2 非对称加密</view>
<view class="input-group">
<text class="label">待加密内容:</text>
<input v-model="sm2EncryptContent" placeholder="请输入要加密的内容" class="input" />
</view>
<button @click="testSm2Encrypt" class="btn btn-primary">SM2 加密</button>
<view class="result-box" v-if="sm2EncryptKeyPair.publicKey">
<text class="result-label">公钥:</text>
<text selectable="true" class="result-value small">{{ sm2EncryptKeyPair.publicKey }}</text>
</view>
<view class="result-box" v-if="sm2EncryptKeyPair.privateKey">
<text class="result-label">私钥:</text>
<text selectable="true" class="result-value small">{{ sm2EncryptKeyPair.privateKey }}</text>
</view>
<view class="result-box" v-if="sm2Encrypted">
<text class="result-label">加密结果:</text>
<text selectable="true" class="result-value small">{{ sm2Encrypted }}</text>
</view>
<button @click="testSm2Decrypt" :disabled="sm2Encrypted.length==0" class="btn btn-secondary">SM2 解密</button>
<view class="result-box" v-if="sm2Decrypted">
<text class="result-label">解密结果:</text>
<text selectable="true" class="result-value success">{{ sm2Decrypted }}</text>
</view>
</view>
<view class="section">
<view class="section-title">SM3 哈希算法</view>
<!-- 1. SM3 哈希 -->
<view class="input-group">
<text class="label">待哈希内容:</text>
<input v-model="sm3Content" placeholder="请输入要哈希的内容" class="input" />
</view>
<button @click="testSm3Hash" class="btn btn-primary">SM3 哈希</button>
<view class="result-box" v-if="sm3HashResult">
<text class="result-label">SM3 哈希值:</text>
<text selectable="true" class="result-value small">{{ sm3HashResult }}</text>
</view>
<!-- 2. SM3 哈希验证 -->
<view class="divider"></view>
<view class="sub-title">SM3 哈希验证</view>
<view class="input-group">
<text class="label">验证 - 原始内容:</text>
<input v-model="sm3VerifyContent" placeholder="请输入原始内容" class="input" />
</view>
<button @click="testSm3Verify" :disabled="sm3VerifyContent.length == 0" class="btn btn-secondary">验证哈希值</button>
<view class="result-box" v-if="sm3HashMatchResult !== null">
<text class="result-label">哈希匹配结果:</text>
<text class="result-value" :class="sm3HashMatchResult == true ? 'success' : 'error'">
{{ sm3HashMatchResult == true ? '✅ 哈希值匹配' : '❌ 哈希值不匹配' }}
</text>
</view>
<!-- 3. HMAC-SM3 哈希 -->
<view class="divider"></view>
<view class="sub-title">HMAC-SM3 哈希</view>
<view class="input-group">
<text class="label">HMAC-SM3 待哈希内容:</text>
<input v-model="sm3HmacContent" placeholder="请输入要哈希的内容" class="input" />
</view>
<view class="input-group">
<text class="label">HMAC-SM3 密钥(HEX):</text>
<input v-model="sm3HmacKey" placeholder="请输入HMAC密钥(HEX格式)" class="input" />
</view>
<button @click="testSm3HmacHash" :disabled="sm3HmacContent.length == 0 || sm3HmacKey.length == 0" class="btn btn-primary">HMAC-SM3 哈希</button>
<view class="result-box" v-if="sm3HmacHashResult">
<text class="result-label">HMAC-SM3 哈希值:</text>
<text selectable="true" class="result-value small">{{ sm3HmacHashResult }}</text>
</view>
<!-- 4. HMAC-SM3 哈希验证 -->
<view class="divider"></view>
<view class="sub-title">HMAC-SM3 哈希验证</view>
<view class="input-group">
<text class="label">验证 - 原始内容:</text>
<input v-model="sm3HmacVerifyContent" placeholder="请输入原始内容" class="input" />
</view>
<view class="input-group">
<text class="label">验证 - HMAC 密钥:</text>
<input v-model="sm3HmacVerifyKey" placeholder="请输入HMAC密钥" class="input" />
</view>
<view class="input-group">
<text class="label">验证 - 期望哈希值:</text>
<input v-model="sm3HmacExpectedHash" placeholder="请输入期望的哈希值" class="input" />
</view>
<button @click="testSm3HmacHashWithExpected" :disabled="sm3HmacVerifyContent.length == 0 || sm3HmacVerifyKey.length == 0 || sm3HmacExpectedHash.length == 0" class="btn btn-secondary">验证 HMAC-SM3 哈希值</button>
<view class="result-box" v-if="sm3HmacHashMatchResult !== null">
<text class="result-label">HMAC-SM3 哈希匹配结果:</text>
<text class="result-value" :class="sm3HmacHashMatchResult == true ? 'success' : 'error'">
{{ sm3HmacHashMatchResult == true ? '✅ 哈希值匹配' : '❌ 哈希值不匹配' }}
</text>
</view>
<!-- 5. 文件 SM3 哈希 -->
<view class="divider"></view>
<view class="sub-title">文件 SM3 哈希</view>
<button @click="chooseImageAndHash" class="btn btn-primary">选择图片并计算 SM3</button>
<button @click="chooseFileAndHash" class="btn btn-primary">选择文件并计算 SM3</button>
<view class="result-box" v-if="sm3FileName">
<text class="result-label">文件名:</text>
<text class="result-value">{{ sm3FileName }}</text>
</view>
<view class="result-box" v-if="sm3FileHashResult">
<text class="result-label">文件 SM3 哈希值:</text>
<text selectable="true" class="result-value small">{{ sm3FileHashResult }}</text>
</view>
</view>
<view class="section">
<view class="section-title">AES 对称加密</view>
<view class="input-group">
<text class="label">待加密内容:</text>
<input v-model="aesContent" placeholder="请输入要加密的内容" class="input" />
</view>
<button @click="testAesEncrypt" class="btn btn-primary">AES 加密</button>
<view class="result-box" v-if="aesKey.key">
<text class="result-label">密钥 (key):</text>
<text selectable="true" class="result-value">{{ aesKey.key }}</text>
</view>
<view class="result-box" v-if="aesKey.iv">
<text class="result-label">初始向量 (iv):</text>
<text selectable="true" class="result-value">{{ aesKey.iv }}</text>
</view>
<view class="result-box" v-if="aesEncrypted">
<text class="result-label">加密结果:</text>
<text selectable="true" class="result-value">{{ aesEncrypted }}</text>
</view>
<button @click="testAesDecrypt" :disabled="aesEncrypted.length==0" class="btn btn-secondary">AES 解密</button>
<view class="result-box" v-if="aesDecrypted">
<text class="result-label">解密结果:</text>
<text selectable="true" class="result-value success">{{ aesDecrypted }}</text>
</view>
</view>
<view class="section">
<view class="section-title">RSA 非对称加密</view>
<view class="input-group">
<text class="label">待加密内容:</text>
<input v-model="rsaContent" placeholder="请输入要加密的内容" class="input" />
</view>
<button @click="testRsaEncrypt" class="btn btn-primary">RSA 加密</button>
<view class="result-box" v-if="rsaKeyPair.publicKey">
<text class="result-label">公钥:</text>
<text selectable="true" class="result-value small">{{ rsaKeyPair.publicKey }}</text>
</view>
<view class="result-box" v-if="rsaKeyPair.privateKey">
<text class="result-label">私钥:</text>
<text selectable="true" class="result-value small">{{ rsaKeyPair.privateKey }}</text>
</view>
<view class="result-box" v-if="rsaEncrypted">
<text class="result-label">加密结果:</text>
<text selectable="true" class="result-value small">{{ rsaEncrypted }}</text>
</view>
<button @click="testRsaDecrypt" :disabled="rsaEncrypted.length==0" class="btn btn-secondary">RSA 解密</button>
<view class="result-box" v-if="rsaDecrypted">
<text class="result-label">解密结果:</text>
<text selectable="true" class="result-value success">{{ rsaDecrypted }}</text>
</view>
</view>
<view class="section">
<view class="section-title">RSA 数字签名</view>
<view class="input-group">
<text class="label">待签名内容:</text>
<input v-model="rsaSignContent" placeholder="请输入要签名的内容" class="input" />
</view>
<button @click="testRsaSign" class="btn btn-primary">生成密钥对并签名</button>
<view class="result-box" v-if="rsaSignKeyPair.publicKey">
<text class="result-label">公钥:</text>
<text selectable="true" class="result-value small">{{ rsaSignKeyPair.publicKey }}</text>
</view>
<view class="result-box" v-if="rsaSignKeyPair.privateKey">
<text class="result-label">私钥:</text>
<text selectable="true" class="result-value small">{{ rsaSignKeyPair.privateKey }}</text>
</view>
<view class="result-box" v-if="rsaSignature">
<text class="result-label">签名结果:</text>
<text selectable="true" class="result-value small">{{ rsaSignature }}</text>
</view>
<button @click="testRsaVerify" :disabled="rsaSignature.length==0" class="btn btn-secondary">验签</button>
<view class="result-box" v-if="rsaVerifyResult !== null">
<text class="result-label">验签结果:</text>
<text class="result-value" :class="rsaVerifyResult ==true? 'success' : 'error'">
{{ rsaVerifyResult ==true ? '✅ 验签成功' : '❌ 验签失败' }}
</text>
</view>
</view>
<view class="order-item" @click="goToDetail(1002, '已完成')">
<view class="order-info">
<text class="order-id">订单编号:1002</text>
<text class="order-status">状态:已完成</text>
</view>
<view class="order-btn">
<text class="go-detail">查看详情</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import {IdcardOcrModel}from "@/uni_modules/sunrains-idcard-ocr/utssdk/interface.uts"
import {Response} from "@/uni_modules/sunrains-utils/index.uts"
import * as SM from "@/uni_modules/sunrains-smutil"
// ==================== SM4 相关数据 ====================
const sm4Content = ref("Hello World")
const sm4Key = ref<SM.Sm4Key>({ key: '', iv: '' })
const sm4Encrypted = ref("")
const sm4Decrypted = ref("")
// ==================== SM2 签名验签相关数据 ====================
const sm2SignContent = ref("123")
const sm2KeyPair = ref<SM.Sm2KeyPair>({ publicKey: '', privateKey: '' })
const sm2Signature = ref("")
const sm2VerifyResult = ref<boolean | null>(null)
// ==================== SM2 加密解密相关数据 ====================
const sm2EncryptContent = ref("Hello SM2")
const sm2EncryptKeyPair = ref<SM.Sm2KeyPair>({ publicKey: '', privateKey: '' })
const sm2Encrypted = ref("")
const sm2Decrypted = ref("")
// ==================== SM3 相关数据 ====================
const sm3Content = ref("Hello SM3")
const sm3HashResult = ref("")
const sm3VerifyContent = ref("")
const sm3HashMatchResult = ref<boolean | null>(null)
const sm3HmacContent = ref("Hello HMAC-SM3")
const sm3HmacKey = ref("0123456789abcdef0123456789abcdef") // 默认 HMAC 密钥(32字节HEX)
const sm3HmacHashResult = ref("")
const sm3HmacVerifyContent = ref("")
const sm3HmacVerifyKey = ref("")
const sm3HmacExpectedHash = ref("")
const sm3HmacHashMatchResult = ref<boolean | null>(null)
const sm3VerifyResult = ref<boolean|null>(null)
const sm3FileName = ref("")
const sm3FileHashResult = ref("")
// ==================== AES 相关数据 ====================
const aesContent = ref("Hello AES")
const aesKey = ref<SM.AesKey>({ key: '', iv: '' })
const aesEncrypted = ref("")
const aesDecrypted = ref("")
// ==================== RSA 加密解密相关数据 ====================
const rsaContent = ref("Hello RSA")
const rsaKeyPair = ref<SM.RsaKeyPair>({ publicKey: '', privateKey: '' })
const rsaEncrypted = ref("")
const rsaDecrypted = ref("")
// ==================== RSA 签名验签相关数据 ====================
const rsaSignContent = ref("需要签名的数据")
const rsaSignKeyPair = ref<SM.RsaKeyPair>({ publicKey: '', privateKey: '' })
const rsaSignature = ref("")
const rsaVerifyResult = ref<boolean | null>(null)
// ==================== SM4 加密 ====================
async function testSm4Encrypt() {
if (sm4Content.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥
const key = await SM.sm4GenerateKeyAsync()
sm4Key.value = { key: key.key, iv: key.iv }
console.log("SM4 密钥:", key)
// 加密
const encryptResult = await SM.sm4EncryptAsync(sm4Content.value, key.key, key.iv)
sm4Encrypted.value = encryptResult.result
console.log("SM4 加密成功:", encryptResult.result)
uni.showToast({ title: '加密成功', icon: 'success' })
} catch (e) {
console.error("SM4 加密失败:", e)
uni.showToast({ title: '加密失败', icon: 'error' })
}
}
// ==================== SM4 解密 ====================
async function testSm4Decrypt() {
if (sm4Encrypted.value.length==0 || sm4Key.value.key.length==0) {
uni.showToast({ title: '请先加密', icon: 'none' })
return
}
try {
const decryptResult = await SM.sm4DecryptAsync(sm4Encrypted.value, sm4Key.value.key, sm4Key.value.iv)
sm4Decrypted.value = decryptResult.result
console.log("SM4 解密成功:", decryptResult.result)
if (decryptResult.result == sm4Content.value) {
uni.showToast({ title: '解密成功且内容一致', icon: 'success' })
} else {
uni.showToast({ title: '解密成功但内容不一致', icon: 'error' })
}
} catch (e) {
console.error("SM4 解密失败:", e)
uni.showToast({ title: '解密失败', icon: 'error' })
}
}
// ==================== AES 加密 ====================
async function testAesEncrypt() {
if (aesContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥和 IV
const key = await SM.aesGenerateKeyAsync()
aesKey.value = { key: key.key, iv: key.iv }
console.log("AES 密钥:", key)
// 加密
const encryptResult = await SM.aesEncryptAsync(aesContent.value, key.key, key.iv)
aesEncrypted.value = encryptResult.result
console.log("AES 加密成功:", encryptResult.result)
uni.showToast({ title: '加密成功', icon: 'success' })
} catch (e) {
console.error("AES 加密失败:", e)
uni.showToast({ title: '加密失败', icon: 'error' })
}
}
// ==================== AES 解密 ====================
async function testAesDecrypt() {
if (aesEncrypted.value.length==0 || aesKey.value.key.length==0) {
uni.showToast({ title: '请先加密', icon: 'none' })
return
}
try {
const decryptResult = await SM.aesDecryptAsync(aesEncrypted.value, aesKey.value.key, aesKey.value.iv)
aesDecrypted.value = decryptResult.result
console.log("AES 解密成功:", decryptResult.result)
if (decryptResult.result == aesContent.value) {
uni.showToast({ title: '解密成功且内容一致', icon: 'success' })
} else {
uni.showToast({ title: '解密成功但内容不一致', icon: 'error' })
}
} catch (e) {
console.error("AES 解密失败:", e)
uni.showToast({ title: '解密失败', icon: 'error' })
}
}
// ==================== RSA 加密 ====================
async function testRsaEncrypt() {
if (rsaContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥对
const keyPair = await SM.rsaGenerateKeyPairAsync()
rsaKeyPair.value = {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey
}
console.log("RSA 公钥:", keyPair.publicKey)
console.log("RSA 私钥:", keyPair.privateKey)
// 加密
const encryptResult = await SM.rsaEncryptAsync(rsaContent.value, keyPair.publicKey)
rsaEncrypted.value = encryptResult.result
console.log("RSA 加密结果:", encryptResult.result)
uni.showToast({ title: '加密成功', icon: 'success' })
} catch (e) {
console.error("RSA 加密失败:", e)
uni.showToast({ title: '加密失败', icon: 'error' })
}
}
// ==================== RSA 解密 ====================
async function testRsaDecrypt() {
if (rsaEncrypted.value.length==0 || rsaKeyPair.value.privateKey.length==0) {
uni.showToast({ title: '请先加密', icon: 'none' })
return
}
try {
const decryptResult = await SM.rsaDecryptAsync(rsaEncrypted.value, rsaKeyPair.value.privateKey)
rsaDecrypted.value = decryptResult.result
console.log("RSA 解密结果:", decryptResult.result)
if (decryptResult.result == rsaContent.value) {
uni.showToast({ title: '解密成功且内容一致', icon: 'success' })
} else {
uni.showToast({ title: '解密成功但内容不一致', icon: 'error' })
}
} catch (e) {
console.error("RSA 解密异常:", e)
uni.showToast({ title: '解密异常', icon: 'error' })
}
}
// ==================== RSA 签名 ====================
async function testRsaSign() {
if (rsaSignContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥对
const keyPair = await SM.rsaGenerateKeyPairAsync()
rsaSignKeyPair.value = {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey
}
console.log("RSA 公钥:", keyPair.publicKey)
console.log("RSA 私钥:", keyPair.privateKey)
// 签名
const signResult = await SM.rsaSignAsync(rsaSignContent.value, keyPair.privateKey)
rsaSignature.value = signResult.signature
console.log("RSA 签名成功:", signResult.signature)
uni.showToast({ title: '签名成功', icon: 'success' })
} catch (e) {
console.error("RSA 签名失败:", e)
uni.showToast({ title: '签名失败', icon: 'error' })
}
}
// ==================== RSA 验签 ====================
async function testRsaVerify() {
if (rsaSignature.value.length==0 || rsaSignKeyPair.value.publicKey.length==0) {
uni.showToast({ title: '请先签名', icon: 'none' })
return
}
try {
// 验签
const verifyResult = await SM.rsaVerifyAsync(rsaSignContent.value, rsaSignature.value, rsaSignKeyPair.value.publicKey)
rsaVerifyResult.value = verifyResult.valid
console.log("RSA 验签结果:", verifyResult.valid)
if (verifyResult.valid) {
uni.showToast({ title: '验签成功', icon: 'success' })
} else {
uni.showToast({ title: '验签失败', icon: 'error' })
}
} catch (e) {
console.error("RSA 验签异常:", e)
uni.showToast({ title: '验签异常', icon: 'error' })
}
}
// ==================== SM2 签名 ====================
async function testSm2Sign() {
if (sm2SignContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥对
const keyPair = await SM.sm2GenerateKeyPairAsync()
sm2KeyPair.value = {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey
}
console.log("SM2 公钥:", keyPair.publicKey)
console.log("SM2 私钥:", keyPair.privateKey)
// 签名
const signResult = await SM.sm2SignAsync(sm2SignContent.value, keyPair.privateKey)
sm2Signature.value = signResult.signature
console.log("SM2 签名成功:", signResult.signature)
uni.showToast({ title: '签名成功', icon: 'success' })
} catch (e) {
console.error("SM2 生成密钥对失败:", e)
uni.showToast({ title: '生成密钥对失败', icon: 'error' })
}
}
// ==================== SM2 验签 ====================
async function testSm2Verify() {
if (sm2Signature.value.length==0 || sm2KeyPair.value.publicKey.length==0) {
uni.showToast({ title: '请先签名', icon: 'none' })
return
}
try {
// 验签
const verifyResult = await SM.sm2VerifyAsync(sm2SignContent.value, sm2Signature.value, sm2KeyPair.value.publicKey)
sm2VerifyResult.value = verifyResult.valid
console.log("SM2 验签结果:", verifyResult.valid)
if (verifyResult.valid) {
uni.showToast({ title: '验签成功', icon: 'success' })
} else {
uni.showToast({ title: '验签失败', icon: 'error' })
}
} catch (e) {
console.error("SM2 验签异常:", e)
uni.showToast({ title: '验签异常', icon: 'error' })
}
}
// ==================== SM2 加密 ====================
async function testSm2Encrypt() {
if (sm2EncryptContent.value.length==0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥对
const keyPair = await SM.sm2GenerateKeyPairAsync()
sm2EncryptKeyPair.value = {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey
}
console.log("SM2 加密公钥:", keyPair.publicKey)
console.log("SM2 加密私钥:", keyPair.privateKey)
// 加密
const encryptResult = await SM.sm2EncryptAsync(sm2EncryptContent.value, keyPair.publicKey)
sm2Encrypted.value = encryptResult.encrypted
console.log("SM2 加密结果:", encryptResult.encrypted)
uni.showToast({ title: '加密成功', icon: 'success' })
} catch (e) {
console.error("SM2 生成密钥对失败:", e)
uni.showToast({ title: '生成密钥对失败', icon: 'error' })
}
}
// ==================== SM2 解密 ====================
async function testSm2Decrypt() {
if (sm2Encrypted.value.length==0 || sm2EncryptKeyPair.value.privateKey.length==0) {
uni.showToast({ title: '请先加密', icon: 'none' })
return
}
try {
const decryptResult = await SM.sm2DecryptAsync(sm2Encrypted.value, sm2EncryptKeyPair.value.privateKey)
sm2Decrypted.value = decryptResult.decrypted
console.log("SM2 解密结果:", decryptResult.decrypted)
if (decryptResult.decrypted == sm2EncryptContent.value) {
uni.showToast({ title: '解密成功且内容一致', icon: 'success' })
} else {
uni.showToast({ title: '解密成功但内容不一致', icon: 'error' })
}
} catch (e) {
console.error("SM2 解密异常:", e)
uni.showToast({ title: '解密异常', icon: 'error' })
}
}
// ==================== SM3 哈希 ====================
async function testSm3Hash() {
if (sm3Content.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
const hashResult = await SM.sm3HashAsync(sm3Content.value)
sm3HashResult.value = hashResult.resultHash
console.log("SM3 哈希成功:", hashResult.resultHash)
uni.showToast({ title: '哈希成功', icon: 'success' })
} catch (e) {
console.error("SM3 哈希失败:", e)
uni.showToast({ title: '哈希失败', icon: 'error' })
}
}
// ==================== HMAC-SM3 哈希 ====================
async function testSm3HmacHash() {
if (sm3HmacContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
if (sm3HmacKey.value.length == 0) {
uni.showToast({ title: '请输入HMAC密钥', icon: 'none' })
return
}
try {
const hmacHashResult = await SM.sm3HmacHashAsync(sm3HmacContent.value, sm3HmacKey.value)
sm3HmacHashResult.value = hmacHashResult.resultHash
sm3HmacExpectedHash.value = hmacHashResult.resultHash
sm3HmacVerifyKey.value = sm3HmacKey.value
sm3HmacVerifyContent.value = sm3HmacContent.value
console.log("HMAC-SM3 哈希成功:", hmacHashResult.resultHash)
uni.showToast({ title: 'HMAC-SM3 哈希成功', icon: 'success' })
} catch (e) {
console.error("HMAC-SM3 哈希失败:", e)
uni.showToast({ title: 'HMAC-SM3 哈希失败', icon: 'error' })
}
}
// ==================== HMAC-SM3 验证(输入期望哈希值) ====================
async function testSm3HmacHashWithExpected() {
if (sm3HmacVerifyContent.value.length == 0) {
uni.showToast({ title: '请输入原始内容', icon: 'none' })
return
}
try {
// 计算实际 HMAC-SM3 哈希值
const res = await SM.sm3HmacVerifyAsync(sm3HmacVerifyContent.value, sm3HmacVerifyKey.value,sm3HmacHashResult.value)
if (res) {
uni.showToast({ title: 'HMAC-SM3 哈希值匹配', icon: 'success' })
} else {
uni.showToast({ title: 'HMAC-SM3 哈希值不匹配', icon: 'error' })
}
} catch (e) {
console.error("HMAC-SM3 哈希验证异常:", e)
uni.showToast({ title: '验证异常', icon: 'error' })
}
}
// ==================== SM3 验证 ====================
async function testSm3Verify() {
if (sm3HashResult.value.length == 0 || sm3Content.value.length == 0) {
uni.showToast({ title: '请先计算哈希', icon: 'none' })
return
}
try {
// 重新计算哈希并验证
const res = await SM.sm3VerifyAsync(sm3VerifyContent.value,sm3HashResult.value)
sm3VerifyResult.value = res
console.log("SM3 验证结果:", sm3VerifyResult.value)
if (sm3VerifyResult.value!) {
uni.showToast({ title: '验证成功', icon: 'success' })
} else {
uni.showToast({ title: '验证失败', icon: 'error' })
}
} catch (e) {
console.error("SM3 验证异常:", e)
uni.showToast({ title: '验证异常', icon: 'error' })
}
}
// ==================== 选择文件并计算 SM3 哈希 ====================
async function processFileHash(filePath: string, fileSize?: number) {
// 计算文件 SM3 哈希
uni.showLoading({ title: '计算中...' })
try {
console.log("开始计算文件 SM3 哈希")
console.log("文件路径:", filePath)
const result = await SM.sm3FileHashAsync(filePath)
sm3FileHashResult.value = result.resultHash
console.log("文件 SM3 哈希成功:", result.resultHash)
uni.hideLoading()
uni.showToast({ title: '文件哈希成功', icon: 'success' })
} catch (e) {
console.error("文件 SM3 哈希失败:", e)
uni.hideLoading()
uni.showToast({ title: '文件哈希失败', icon: 'error' })
}
}
function chooseFileAndHash() {
uni.chooseFile({
count: 1,
success: (res) => {
const tempFiles = res.tempFiles
if (tempFiles != null && tempFiles.length > 0) {
const file = tempFiles[0]
const fileName = file.name
const filePath = file.path
const fileSize = file.size
sm3FileName.value = fileName != null ? fileName : "未知文件"
if (filePath != null) {
processFileHash(filePath, fileSize)
}
}
},
fail: (err) => {
console.error("选择文件失败:", err)
if (err.errMsg != null && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选择文件失败', icon: 'error' })
}
}
})
}
// ==================== 选择相册图片并计算 SM3 哈希 ====================
function chooseImageAndHash() {
uni.chooseImage({
count: 1,
sizeType: ['original'], // 只选择原图,不压缩
sourceType: ['album', 'camera'],
success: (res) => {
const tempFilePaths = res.tempFilePaths
if (tempFilePaths != null && tempFilePaths.length > 0) {
const filePath = tempFilePaths[0]
const fileName = "图片文件"
sm3FileName.value = fileName
console.log("选择的图片(原图):", filePath)
if (filePath != null) {
// 调用异步函数处理文件哈希
processFileHash(filePath)
}
}
},
fail: (err) => {
console.error("选择图片失败:", err)
if (err.errMsg != null && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选择图片失败', icon: 'error' })
}
}
})
}
const goToDetail = (orderId: number, status: string) => {
uni.setStorageSync('fromTabBar', true)
uni.navigateTo({
url: `/pages/detail/detail?orderId=${orderId}&status=${status}`,
success: () => {
console.log(`跳转到order${orderId}的详情页`)
}
})
}
function btn (){
}
</script>
<style scoped>
.order-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: #f5f5f5;
}
.order-header {
height: 48px;
background-color: #fff;
align-items: center;
justify-content: center;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #eee;
}
.order-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.order-list {
flex: 1;
padding: 10px;
}
/* 区块样式 */
.section {
background-color: #fff;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #eee;
}
.input-group {
margin-bottom: 15px;
}
.label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.input {
height: 40px;
border-width: 1px;
border-style: solid;
border-color: #ddd;
border-radius: 4px;
padding: 0 10px;
font-size: 14px;
background-color: #fafafa;
}
/* 按钮样式 */
.btn {
height: 44px;
border-radius: 4px;
font-size: 15px;
margin-bottom: 10px;
}
.btn-primary {
background-color: #007aff;
color: #fff;
}
.btn-secondary {
background-color: #34c759;
color: #fff;
}
.btn:disabled {
opacity: 0.5;
}
/* 结果展示框 */
.result-box {
margin-top: 10px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
border-left-width: 3px;
border-left-style: solid;
border-left-color: #007aff;
}
.result-label {
font-size: 12px;
color: #999;
margin-bottom: 5px;
}
.result-value {
font-size: 13px;
color: #333;
line-height: 1.5;
}
.result-value.small {
font-size: 11px;
}
.result-value.success {
color: #34c759;
font-weight: bold;
}
.result-value.error {
color: #ff3b30;
font-weight: bold;
}
.divider {
height: 1px;
background-color: #eee;
margin: 15px 0;
}
.sub-title {
font-size: 14px;
font-weight: bold;
color: #666;
margin-bottom: 10px;
}
</style>
服务端(java工具类)
package top.sunrains;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.crypto.engines.SM2Engine;
import org.bouncycastle.crypto.params.ECDomainParameters;
import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
import org.bouncycastle.crypto.params.ECPublicKeyParameters;
import org.bouncycastle.crypto.params.ParametersWithRandom;
import org.bouncycastle.crypto.signers.SM2Signer;
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.spec.ECParameterSpec;
import org.bouncycastle.util.encoders.Hex;
import java.math.BigInteger;
import java.security.SecureRandom;
/**
* <dependency>
* <groupId>org.bouncycastle</groupId>
* <artifactId>bcprov-jdk15to18</artifactId>
* <version>1.69</version>
* </dependency>
*/
public class SM2Util {
private static final ECParameterSpec CURVE = ECNamedCurveTable.getParameterSpec("sm2p256v1");
private static final ECDomainParameters DOMAIN = new ECDomainParameters(
CURVE.getCurve(),
CURVE.getG(),
CURVE.getN(),
CURVE.getH()
);
/**
* 生成SM2密钥对
*
* @return Map包含publicKey和privateKey,均为小写十六进制字符串
*/
public static java.util.Map<String, String> generateKeyPair() {
try {
BigInteger d = new BigInteger(256, new SecureRandom()).mod(DOMAIN.getN());
org.bouncycastle.math.ec.ECPoint q = DOMAIN.getG().multiply(d).normalize();
String x = String.format("4x", q.getAffineXCoord().toBigInteger());
String y = String.format("4x", q.getAffineYCoord().toBigInteger());
String publicKey = "04" + x + y;
String privateKey = String.format("4x", d);
java.util.Map<String, String> keyMap = new java.util.HashMap<>();
keyMap.put("publicKey", publicKey.toLowerCase());
keyMap.put("privateKey", privateKey.toLowerCase());
return keyMap;
} catch (Exception e) {
System.err.println("密钥生成失败: " + e.getMessage());
e.printStackTrace();
java.util.Map<String, String> keyMap = new java.util.HashMap<>();
keyMap.put("publicKey", "");
keyMap.put("privateKey", "");
return keyMap;
}
}
/**
* SM2签名
*
* @param data 待签名的原始数据
* @param privateKeyHex 私钥(64字符十六进制)
* @return 签名结果(DER格式,小写十六进制)
*/
public static String sign(String data, String privateKeyHex) {
try {
byte[] msgBytes = data.getBytes(java.nio.charset.StandardCharsets.UTF_8);
BigInteger d = new BigInteger(privateKeyHex, 16);
ECPrivateKeyParameters priKey = new ECPrivateKeyParameters(d, DOMAIN);
SM2Signer signer = new SM2Signer(new SM3Digest());
signer.init(true, priKey);
signer.update(msgBytes, 0, msgBytes.length);
byte[] sig = signer.generateSignature();
return Hex.toHexString(sig).toLowerCase();
} catch (Exception e) {
System.err.println("签名失败: " + e.getMessage());
e.printStackTrace();
return "";
}
}
/**
* SM2验签
*
* @param data 原始数据
* @param signatureHex 签名(DER格式,十六进制)
* @param publicKeyHex 公钥(04开头或不含04前缀的十六进制)
* @return 验签是否成功
*/
public static boolean verify(String data, String signatureHex, String publicKeyHex) {
try {
byte[] msgBytes = data.getBytes(java.nio.charset.StandardCharsets.UTF_8);
String xy = publicKeyHex.startsWith("04") ? publicKeyHex.substring(2) : publicKeyHex;
BigInteger x = new BigInteger(xy.substring(0, 64), 16);
BigInteger y = new BigInteger(xy.substring(64, 128), 16);
org.bouncycastle.math.ec.ECPoint pubPoint = DOMAIN.getCurve().createPoint(x, y).normalize();
ECPublicKeyParameters pubKey = new ECPublicKeyParameters(pubPoint, DOMAIN);
SM2Signer signer = new SM2Signer(new SM3Digest());
signer.init(false, pubKey);
signer.update(msgBytes, 0, msgBytes.length);
byte[] sigBytes = Hex.decode(signatureHex);
return signer.verifySignature(sigBytes);
} catch (Exception e) {
System.err.println("验签失败: " + e.getMessage());
e.printStackTrace();
return false;
}
}
/**
* SM2加密
*
* @param data 待加密的原始数据(UTF-8字符串)
* @param publicKeyHex 公钥(04开头或不含04前缀的十六进制)
* @return 密文(HEX字符串,小写)
*/
public static String encrypt(String data, String publicKeyHex) {
try {
byte[] dataBytes = data.getBytes(java.nio.charset.StandardCharsets.UTF_8);
String xy = publicKeyHex.startsWith("04") ? publicKeyHex.substring(2) : publicKeyHex;
BigInteger x = new BigInteger(xy.substring(0, 64), 16);
BigInteger y = new BigInteger(xy.substring(64, 128), 16);
org.bouncycastle.math.ec.ECPoint pubPoint = DOMAIN.getCurve().createPoint(x, y).normalize();
ECPublicKeyParameters pubKey = new ECPublicKeyParameters(pubPoint, DOMAIN);
SM2Engine engine = new SM2Engine(new SM3Digest(), SM2Engine.Mode.C1C3C2);
engine.init(true, new ParametersWithRandom(pubKey, new SecureRandom()));
byte[] encrypted = engine.processBlock(dataBytes, 0, dataBytes.length);
return Hex.toHexString(encrypted).toLowerCase();
} catch (Exception e) {
System.err.println("加密失败: " + e.getMessage());
e.printStackTrace();
return "";
}
}
/**
* SM2解密
*
* @param encryptedHex 密文(HEX字符串)
* @param privateKeyHex 私钥(64字符十六进制)
* @return 解密后的原始数据(UTF-8字符串)
*/
public static String decrypt(String encryptedHex, String privateKeyHex) {
try {
byte[] encryptedBytes = Hex.decode(encryptedHex);
BigInteger d = new BigInteger(privateKeyHex, 16);
ECPrivateKeyParameters priKey = new ECPrivateKeyParameters(d, DOMAIN);
SM2Engine engine = new SM2Engine(new SM3Digest(), SM2Engine.Mode.C1C3C2);
engine.init(false, priKey);
byte[] decrypted = engine.processBlock(encryptedBytes, 0, encryptedBytes.length);
return new String(decrypted, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
System.err.println("解密失败: " + e.getMessage());
e.printStackTrace();
return "";
}
}
/**
* 测试方法
*/
public static void main(String[] args) {
System.out.println("=== SM2 工具类测试 ===\n");
java.util.Map<String, String> keyPair = generateKeyPair();
String publicKey = keyPair.get("publicKey");
String privateKey = keyPair.get("privateKey");
System.out.println("公钥: " + publicKey);
System.out.println("私钥: " + privateKey);
System.out.println();
String testData = "123";
System.out.println("原始数据: " + testData);
String signature = sign(testData, privateKey);
System.out.println("签名: " + signature);
System.out.println();
boolean isValid = verify(testData, signature, publicKey);
System.out.println("验签结果: " + isValid);
System.out.println();
if (!isValid) {
System.err.println("验签失败!");
} else {
System.out.println("验签成功!");
}
System.out.println("\n=== SM2 加密解密测试 ===\n");
// 加密解密测试
String originalText = "Hello SM2";
System.out.println("原始数据: " + originalText);
String encrypted = encrypt(originalText, publicKey);
System.out.println("原始数据: " + originalText);
String decrypted = decrypt(encrypted, privateKey);
System.out.println("解密结果: " + decrypted);
System.out.println();
if (originalText.equals(decrypted)) {
System.out.println("加密解密成功!");
} else {
System.err.println("加密解密失败!");
}
}
}
package top.sunrains;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.security.Security;
/**
* <dependency>
* <groupId>org.bouncycastle</groupId>
* <artifactId>bcprov-jdk15to18</artifactId>
* <version>1.69</version>
* </dependency>
*
* SM4国密算法工具类
* 使用Bouncy Castle原生API
* 配置信息:
* - 算法:SM4
* - 模式:CBC(密码分组链接)
* - 填充:PKCS5Padding
* - 密钥长度:128位(16字节)
* - IV长度:128位(16字节)
*/
public class Sm4Util {
static {
try {
Security.addProvider(new BouncyCastleProvider());
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws Exception {
// String key = generateKeyHex();
// String iv = generateIvHex();
//String s = encryptHex("123", key, iv);
String key = "7f9a942a1aa85c331d3697a5369a37fe";
String iv = "cbc07750d33d6a3ef6d39fc7e7c4a876";
String s = "09187a80e9f52e7e962a3465ed70669e";
System.out.println(s);
String decryptedHex = decryptHex(s, key, iv);
System.out.println(decryptedHex);
}
/**
* 生成SM4密钥(16字节,128位)
* @return 十六进制编码的密钥字符串(32个字符)
*/
public static String generateKeyHex() {
byte[] keyBytes = new byte[16];
new SecureRandom().nextBytes(keyBytes);
return byteArrayToHex(keyBytes);
}
/**
* 生成IV向量(16字节,128位)
* @return 十六进制编码的IV字符串(32个字符)
*/
public static String generateIvHex() {
byte[] ivBytes = new byte[16];
new SecureRandom().nextBytes(ivBytes);
return byteArrayToHex(ivBytes);
}
/**
* SM4-CBC加密(字符串到HEX)
* 标准加密方式
*
* @param plaintext 明文字符串(UTF-8)
* @param keyHex 十六进制密钥字符串
* @param ivHex 十六进制IV字符串
* @return 加密后的HEX字符串
* @throws Exception 加密异常
*/
public static String encryptHex(String plaintext, String keyHex, String ivHex) throws Exception {
byte[] key = decodeKeyHex(keyHex);
byte[] iv = decodeIvHex(ivHex);
byte[] data = plaintext.getBytes("UTF-8");
byte[] encrypted = encrypt(data, key, iv);
return byteArrayToHex(encrypted);
}
/**
* SM4-CBC加密
*
* @param data 原始数据字节数组
* @param key 密钥字节数组(16字节)
* @param iv IV字节数组(16字节)
* @return 加密后的字节数组
* @throws Exception 加密异常
*/
public static byte[] encrypt(byte[] data, byte[] key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS5Padding", "BC");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(key, "SM4"),
new IvParameterSpec(iv));
return cipher.doFinal(data);
}
/**
* byte[] 转 Hex
*
* @param bytes 字节数组
* @return HEX字符串(小写)
*/
public static String byteArrayToHex(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(b & 0xFF);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
return sb.toString().toLowerCase();
}
public static String decryptHex(String encryptedHex, String keyHex, String ivHex) throws Exception {
byte[] key = decodeKeyHex(keyHex);
byte[] iv = decodeIvHex(ivHex);
byte[] encryptedData = hexToByteArray(encryptedHex);
byte[] decrypted = decrypt(encryptedData, key, iv);
return new String(decrypted, "UTF-8");
}
/**
* SM4-CBC解密
*
* @param encryptedData 加密数据字节数组
* @param key 密钥字节数组(16字节)
* @param iv IV字节数组(16字节)
* @return 解密后的原始字节数组
* @throws Exception 解密异常
*/
public static byte[] decrypt(byte[] encryptedData, byte[] key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS5Padding", "BC");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(key, "SM4"),
new IvParameterSpec(iv));
return cipher.doFinal(encryptedData);
}
/**
* 从十六进制字符串还原密钥字节数组
* @param keyHex 十六进制编码的密钥字符串
* @return 密钥字节数组(16字节)
*/
public static byte[] decodeKeyHex(String keyHex) {
return hexToByteArray(keyHex);
}
/**
* 从十六进制字符串还原IV字节数组
* @param ivHex 十六进制编码的IV字符串
* @return IV字节数组(16字节)
*/
public static byte[] decodeIvHex(String ivHex) {
return hexToByteArray(ivHex);
}
/**
* HEX 转 byte[]
*
* @param hex HEX字符串
* @return 字节数组
*/
public static byte[] hexToByteArray(String hex) {
if (hex == null || hex.length() == 0) {
return new byte[0];
}
int len = hex.length() / 2;
byte[] bytes = new byte[len];
for (int i = 0; i < len; i++) {
int high = Character.digit(hex.charAt(i * 2), 16);
int low = Character.digit(hex.charAt(i * 2 + 1), 16);
bytes[i] = (byte) ((high << 4) | low);
}
return bytes;
}
public static String stringToHex(String str) {
if (str == null || str.isEmpty()) {
return "";
}
byte[] bytes = str.getBytes(java.nio.charset.StandardCharsets.UTF_8);
return byteArrayToHex(bytes);
}
}
package top.sunrains;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
/**
* AES对称加密算法工具类
* 使用Java原生Crypto API
* 配置信息:
* - 算法:AES-256
* - 模式:CBC(密码分组链接)
* - 填充:PKCS5Padding
* - 密钥长度:256位(32字节)
* - IV长度:128位(16字节)
*/
public class AesUtil {
/**
* 生成AES密钥和IV
* @return Map包含key (32字符) 和 iv (16字符)
*/
public static Map<String, String> generateKey() {
try {
// 生成32字节的随机字符串作为密钥
String key = generateRandomString(32);
// 生成16字节的随机字符串作为IV
String iv = generateRandomString(16);
System.out.println("AES Key: " + key + " (长度: " + key.length() + ")");
System.out.println("AES IV: " + iv + " (长度: " + iv.length() + ")");
Map<String, String> result = new HashMap<>();
result.put("key", key);
result.put("iv", iv);
return result;
} catch (Exception e) {
System.err.println("AES 密钥生成失败: " + e.getMessage());
e.printStackTrace();
return new HashMap<>();
}
}
/**
* 生成指定长度的随机字符串
*/
private static String generateRandomString(int length) {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
SecureRandom random = new SecureRandom();
StringBuilder result = new StringBuilder(length);
for (int i = 0; i < length; i++) {
result.append(chars.charAt(random.nextInt(chars.length())));
}
return result.toString();
}
/**
* AES-CBC加密
*
* @param plaintext 明文字符串(UTF-8)
* @param keyStr 密钥字符串(32字符)
* @param ivStr IV字符串(16字符)
* @return Base64编码的密文
* @throws Exception 加密异常
*/
public static String encrypt(String plaintext, String keyStr, String ivStr) throws Exception {
// 将字符串Key和IV转换为字节数组(UTF-8编码)
byte[] keyBytes = keyStr.getBytes("UTF-8");
byte[] ivBytes = ivStr.getBytes("UTF-8");
// 创建密钥和IV
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
// 创建Cipher对象(AES/CBC/PKCS5Padding)
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
// 执行加密
byte[] encrypted = cipher.doFinal(plaintext.getBytes("UTF-8"));
// 返回Base64编码的密文
return Base64.getEncoder().encodeToString(encrypted);
}
/**
* AES-CBC解密
*
* @param encryptedBase64 Base64编码的密文
* @param keyStr 密钥字符串(32字符)
* @param ivStr IV字符串(16字符)
* @return 解密后的明文字符串
* @throws Exception 解密异常
*/
public static String decrypt(String encryptedBase64, String keyStr, String ivStr) throws Exception {
// 将字符串Key和IV转换为字节数组(UTF-8编码)
byte[] keyBytes = keyStr.getBytes("UTF-8");
byte[] ivBytes = ivStr.getBytes("UTF-8");
// 创建密钥和IV
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
// 创建Cipher对象(AES/CBC/PKCS5Padding)
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
// 将Base64密文解码为字节
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedBase64);
// 执行解密
byte[] decrypted = cipher.doFinal(encryptedBytes);
// 返回明文字符串
return new String(decrypted, "UTF-8");
}
/**
* 测试方法
*/
public static void main(String[] args) throws Exception {
System.out.println("=== AES 工具类测试 ===\n");
// 生成密钥
Map<String, String> keyMap = generateKey();
String key = keyMap.get("key");
String iv = keyMap.get("iv");
System.out.println("密钥: " + key);
System.out.println("IV: " + iv);
System.out.println();
// 加密测试
String originalText = "Hello AES";
System.out.println("原始数据: " + originalText);
String encrypted = encrypt(originalText, key, iv);
System.out.println("加密结果: " + encrypted);
System.out.println();
// 解密测试
String decrypted = decrypt(encrypted, key, iv);
System.out.println("解密结果: " + decrypted);
System.out.println();
if (originalText.equals(decrypted)) {
System.out.println("✅ 加密解密成功!");
} else {
System.err.println("❌ 加密解密失败!");
}
}
}
package top.sunrains;
import cn.hutool.core.text.CharSequenceUtil;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.crypto.macs.HMac;
import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.Security;
/**
* SM3国密哈希算法工具类
* 使用Bouncy Castle原生API
* 功能:
* - 字符串哈希计算
* - 字节数组哈希计算
* - 文件哈希计算
* - HMAC-SM3计算
*/
public class SM3Util {
static {
try {
Security.addProvider(new BouncyCastleProvider());
} catch (Exception e) {
e.printStackTrace();
}
}
private static final int BUFFER_SIZE = 8192;
/**
* 计算字符串的SM3哈希值
*
* @param data 待哈希的字符串
* @return 哈希值(小写十六进制字符串,64个字符)
*/
public static String hash(String data) {
if (data == null) {
throw new IllegalArgumentException("Data cannot be null");
}
return hash(data.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/**
* 验证字符串的SM3哈希值
*
* @param data 原始数据
* @param expectedHash 期望的哈希值
* @return 是否匹配
*/
public static boolean verify(String data, String expectedHash) {
String actualHash = hash(data);
return actualHash.equalsIgnoreCase(expectedHash);
}
/**
* 计算字节数组的SM3哈希值
*
* @param data 待哈希的字节数组
* @return 哈希值(小写十六进制字符串,64个字符)
*/
public static String hash(byte[] data) {
SM3Digest digest = new SM3Digest();
digest.update(data, 0, data.length);
byte[] result = new byte[digest.getDigestSize()];
digest.doFinal(result, 0);
return byteArrayToHex(result);
}
/**
* 计算文件的SM3哈希值(支持大文件)
*
* @param filePath 文件路径
* @return 哈希值(小写十六进制字符串,64个字符)
* @throws IOException 文件读取异常
*/
public static String hashFile(String filePath) throws IOException {
return hashFile(new File(filePath));
}
/**
* 计算文件的SM3哈希值(支持大文件)
*
* @param file 文件对象
* @return 哈希值(小写十六进制字符串,64个字符)
* @throws IOException 文件读取异常
*/
public static String hashFile(File file) throws IOException {
if (!file.exists()) {
throw new IOException("File not found: " + file.getAbsolutePath());
}
SM3Digest digest = new SM3Digest();
byte[] buffer = new byte[BUFFER_SIZE];
try (InputStream is = new FileInputStream(file)) {
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
}
byte[] result = new byte[digest.getDigestSize()];
digest.doFinal(result, 0);
return byteArrayToHex(result);
}
/**
* 计算HMAC-SM3(基于密钥的哈希消息认证码)
*
* @param data 待计算的数据
* @param hexKey 密钥
* @return HMAC-SM3结果(小写十六进制字符串,64个字符)
*/
public static String hmac(String data, String hexKey) {
if (CharSequenceUtil.isBlank(hexKey)) {
throw new IllegalArgumentException("密钥不能为空");
}
return hmac(data.getBytes(java.nio.charset.StandardCharsets.UTF_8),
hexToByteArray(hexKey));
}
/**
* 计算HMAC-SM3(基于密钥的哈希消息认证码)
*
* @param data 待计算的字节数组
* @param hexKey hex密钥字节数组
* @return HMAC-SM3结果(小写十六进制字符串,64个字符)
*/
public static String hmac(byte[] data, byte[] hexKey) {
HMac hmac = new HMac(new SM3Digest());
hmac.init(new KeyParameter(hexKey));
hmac.update(data, 0, data.length);
byte[] result = new byte[hmac.getMacSize()];
hmac.doFinal(result, 0);
return byteArrayToHex(result);
}
/**
* byte[] 转 Hex
*
* @param bytes 字节数组
* @return HEX字符串(小写)
*/
private static String byteArrayToHex(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(b & 0xFF);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
return sb.toString().toLowerCase();
}
/**
* 测试方法
*/
public static void main(String[] args) throws IOException {
String testData = "12";
System.out.println("SM3: " + hash(testData));
String key = "1";
key = stringToHex(key);
System.out.println("HMAC-SM3 key: " + key);
String hmacResult = hmac(testData, key);
System.out.println("HMAC-SM3: " + hmacResult);
System.out.println("HMAC长度: " + hmacResult.length() + " 字符");
System.out.println("文件哈希值: " + hashFile("/Users/sun/1/t.jpg"));
}
public static String stringToHex(String str) {
if (str == null || str.isEmpty()) {
return "";
}
byte[] bytes = str.getBytes(java.nio.charset.StandardCharsets.UTF_8);
return byteArrayToHex(bytes);
}
/**
* HEX 转 byte[]
*
* @param hex HEX字符串
* @return 字节数组
*/
public static byte[] hexToByteArray(String hex) {
if (hex == null || hex.length() == 0) {
return new byte[0];
}
int len = hex.length() / 2;
byte[] bytes = new byte[len];
for (int i = 0; i < len; i++) {
int high = Character.digit(hex.charAt(i * 2), 16);
int low = Character.digit(hex.charAt(i * 2 + 1), 16);
bytes[i] = (byte) ((high << 4) | low);
}
return bytes;
}
}
各平台实现说明
Android 平台
SM2 实现:
-
依赖库:BouncyCastle (
org.bouncycastle:bcprov-jdk15on:1.70) -
特点:
- 使用
ECKeyPairGenerator生成密钥对 - 使用
SM2Signer进行签名和验签(DER 格式) - 使用
SM2Engine进行加密和解密(C1C3C2 格式) - 不依赖 JCA Provider,避免兼容性问题
- 使用
SM4 实现:
-
依赖库:BouncyCastle
-
特点:
- 使用
SM4Engine进行加密和解密 - CBC 模式 + PKCS7 填充
- 支持自定义密钥和 IV
- 使用
iOS 平台
实现方式:GMObjC 原生库
SM2 特点:
- 使用
GMSm2Utils进行所有操作 - 签名自动转换为 DER 格式以兼容其他平台
- 加密输出 C1C3C2 格式(04 开头)
- 支持跨平台互通(Android/HarmonyOS)
SM4 特点:
- 使用
GMSm4Utils进行加密解密 - CBC 模式 + PKCS7 填充
- 密钥和 IV 为 32 位十六进制字符串
HarmonyOS 平台
实现方式: @ohos.security.cryptoFramework
SM2 特点:
- 使用 HarmonyOS 原生 cryptoFramework API
- 签名使用 DER 格式
- 加密使用 C1C3C2 格式
- 完全符合国密标准
SM3 特点:
- 使用
createMd(MessageDigest) 进行普通哈希 - 使用手动实现 HMAC-SM3 (RFC 2104)
- 支持流式文件哈希处理 (4096 字节缓冲区)
- 不依赖
createMac,避免密钥参数问题
SM4 特点:
- 使用
createSymKeyGenerator生成密钥 - 使用
createCipher进行加密解密 - CBC 模式 + PKCS7 填充
- 支持 SM4_128 算法
AES 特点:
- 使用
crypto.createSymKeyGenerator('AES256')创建密钥生成器 - 使用
crypto.createCipher('AES|CBC|PKCS7')进行加解密 - CBC 模式 + PKCS7 填充
- 密钥和 IV 为随机字符串(UTF-8 编码)
H5 平台
实现方式:sm-crypto + Web Crypto API
特点:
- SM2/SM3/SM4:通过 npm 包
sm-crypto实现 - AES:使用浏览器原生 Web Crypto API 实现
- 所有功能均在浏览器环境中运行
- SM2 签名使用 DER 格式(
der: true) - SM2 默认 userId 为
'1234567812345678'
依赖安装:
npm install --save sm-crypto
微信小程序平台
实现方式:miniprogram-sm-crypto + crypto-js + jsrsasign
特点:
- 专为小程序优化的国密算法库
- 完全支持 SM2、SM4 和 AES
- SM2:密钥生成、签名、验签、加密、解密(使用 miniprogram-sm-crypto)
- SM4:密钥生成、加密、解密(CBC 模式,使用 miniprogram-sm-crypto)
- AES:密钥生成、加密、解密(CBC 模式,使用 crypto-js)
- 需要通过微信开发者工具构建 npm
依赖安装:
npm install --save miniprogram-sm-crypto crypto-js jsrsasign
重要:微信小程序 npm 构建步骤
- 确保项目根目录已安装上述依赖
- 打开微信开发者工具,勾选「详情」→「本地设置」→「使用 npm 模块」
- 点击菜单栏「工具」→「构建 npm」,生成
miniprogram_npm目录 - 重新编译运行小程序
注意事项
1. 密钥格式
SM2 密钥:
- 公钥:以
04开头的 130 位十六进制字符串(非压缩格式) - 私钥:64 位十六进制字符串
SM4 密钥:
- 密钥 (key) :32 位十六进制字符串(128 位)
- 初始向量 (IV) :32 位十六进制字符串(128 位)
AES 密钥:
- 密钥 (key) :32 字符随机字符串(字母+数字,UTF-8 编码后为 256 位)
- 初始向量 (IV) :16 字符随机字符串(字母+数字,UTF-8 编码后为 128 位)
2. 签名格式
- 所有平台的签名输出均为 DER 编码的十六进制字符串
- DER 格式确保跨平台兼容性
3. 哈希格式
SM3 哈希:
- 输出长度:64 位十六进制字符串 (256 位)
- 格式:小写十六进制
- 示例:
a1b2c3d4e5f6...(64 个字符)
HMAC-SM3 哈希:
- 输出长度:64 位十六进制字符串 (256 位)
- 密钥格式:十六进制字符串
- 格式:小写十六进制
4. 加密格式
SM2 加密:
- 输出格式:C1C3C2(以
04开头) - 兼容 Android BouncyCastle、iOS GMObjC、HarmonyOS cryptoFramework
SM4 加密:
- 模式:CBC
- 填充:PKCS7
- 输出:十六进制字符串
AES 加密:
- 模式:CBC
- 填充:PKCS5/PKCS7
- 密钥:32 字符随机字符串(UTF-8 编码后为 256 位)
- IV:16 字符随机字符串(UTF-8 编码后为 128 位)
- 输出:Base64 编码字符串
RSA 加密:
- 算法:RSA-OAEP with SHA-256 (对应 Java 的
RSA/ECB/OAEPWithSHA-256AndMGF1Padding) - 密钥长度:2048 位
- 私钥格式:PKCS#8 (Base64 编码)
- 公钥格式:X.509/SPKI (Base64 编码)
- 输出:Base64 编码字符串
- 跨平台兼容:Android/iOS/HarmonyOS/H5 完全互通
RSA 签名:
- 算法:SHA256withRSA (PKCS#1 v1.5)
- 私钥格式:PKCS#8 (Base64 编码)
- 公钥格式:X.509/SPKI (Base64 编码)
- 签名输出:Base64 编码字符串
- 跨平台兼容:Android/iOS/HarmonyOS/H5/微信小程序 完全互通
5. 跨平台兼容性
✅ 完全兼容:
- 各平台生成的 SM2 密钥对可以互相使用
- Android 平台签名的数据可以在 iOS/Harmony/H5 平台验证
- SM2 加密的数据可以在不同平台间解密
- SM4 加密的数据可以在 Android/iOS/Harmony 平台间加解密
- AES 加密的数据可以在所有平台间加解密(Android/iOS/HarmonyOS/H5/微信小程序)
- SM3 哈希值在所有平台保持一致(相同输入产生相同输出)
- HMAC-SM3 支持跨平台验证
- RSA-OAEP-SHA256 加密的数据可以在 Android/iOS/HarmonyOS/H5 平台间加解密
- SHA256withRSA 签名可以在所有平台间验签(包括微信小程序)
⚠️ 注意事项:
- 确保使用相同的哈希选项(默认启用)
- SM4/AES 加密和解密必须使用相同的密钥和 IV
- 微信小程序需要先构建 npm 才能使用
- SM3 文件哈希需要确保文件内容完全一致
- AES 密钥为 32 字符随机字符串,IV 为 16 字符随机字符串
- RSA 密钥长度为 2048 位,私钥为 PKCS#8 格式,公钥为 X.509/SPKI 格式
- RSA 加密使用 OAEP with SHA-256 填充,与其他平台互通
- RSA 签名使用 SHA256withRSA (PKCS#1 v1.5),所有平台兼容
- 微信小程序端暂不支持 RSA-OAEP-SHA256 加密解密,仅支持签名验签
sunrains-smutil 插件使用指南
本插件提供SM2、SM3 、 SM4、AES、RSA 客户端(ios,安卓,鸿蒙,微信小程序,h5),服务端(java)SM2、SM3 、 SM4、AES、RSA 算法的跨平台实现,支持以下功能:
SM2 非对称加密算法
✅ SM2 密钥对生成
✅ SM2 数字签名
✅ SM2 签名验证
✅ SM2 数据加密
✅ SM2 数据解密
SM3 密码杂凑算法
✅ SM3 哈希计算(字符串)
✅ HMAC-SM3 密钥哈希
✅ 文件 SM3 哈希
✅ 哈希值验证
SM4 对称加密算法
✅ SM4 密钥生成(含 IV)
✅ SM4 数据加密(CBC 模式)
✅ SM4 数据解密(CBC 模式)
AES 对称加密算法
✅ AES 密钥和 IV 生成
✅ AES 数据加密(CBC 模式, PKCS7/PKCS5 填充)
✅ AES 数据解密(CBC 模式, PKCS7/PKCS5 填充)
✅ 跨平台兼容(Android/iOS/HarmonyOS/H5/微信小程序)
RSA 非对称加密算法
✅ RSA 密钥对生成(2048位)
✅ RSA-OAEP-SHA256 数据加密
✅ RSA-OAEP-SHA256 数据解密
✅ SHA256withRSA 数字签名(PKCS#1 v1.5)
✅ SHA256withRSA 签名验证(PKCS#1 v1.5)
✅ 跨平台兼容(Android/iOS/HarmonyOS/H5)
⚠️ 微信小程序端暂不支持RSA-OAEP-SHA256加密解密,仅支持签名验签
调用方式
本插件同时支持回调风格和异步风格两种调用方式:
方式一:回调风格(传统方式)
import { sm2Sign } from "@/uni_modules/sunrains-util";
sm2Sign({
data: "需要签名的数据",
privateKey: "你的私钥",
success: (res) => {
console.log("签名结果:", res.signature);
},
fail: (err) => {
console.error("签名失败:", err);
}
});
方式二:异步风格(推荐,更简洁)
import { sm2SignAsync } from "@/uni_modules/sunrains-util";
async function doSign() {
try {
const result = await sm2SignAsync("需要签名的数据", "你的私钥")
console.log("签名结果:", result.signature)
} catch (error) {
console.error("签名失败:", error)
}
}
平台支持
| 平台 | SM2 实现方式 | SM4 实现方式 | AES 实现方式 | RSA 实现方式 | 状态 |
|---|---|---|---|---|---|
| Android | BouncyCastle | BouncyCastle | Java Crypto API | Java Crypto API (RSA-OAEP-SHA256) | ✅ 完全支持 |
| iOS | GMObjC | GMObjC | CommonCrypto | Security Framework (RSA-OAEP-SHA256) | ✅ 完全支持 |
| HarmonyOS | cryptoFramework | cryptoFramework | cryptoFramework | cryptoFramework (RSA-OAEP-SHA256) | ✅ 完全支持 |
| H5 | sm-crypto | sm-crypto | Web Crypto API | Web Crypto API (RSA-OAEP-SHA256) | ✅ 完全支持 |
| 微信小程序 | miniprogram-sm-crypto | miniprogram-sm-crypto | crypto-js | jsrsasign (仅签名验签) | ⚠️ 部分支持 |
使用示例
SM2 相关操作
1. 生成 SM2 密钥对
同步方式:
import { sm2GenerateKeyPair } from "@/uni_modules/sunrains-util";
const result = sm2GenerateKeyPair();
console.log("公钥:", result.publicKey); // 04开头的130位十六进制字符串
console.log("私钥:", result.privateKey); // 64位十六进制字符串
异步方式:
import { sm2GenerateKeyPairAsync } from "@/uni_modules/sunrains-util";
async function generateKeys() {
const result = await sm2GenerateKeyPairAsync()
console.log("公钥:", result.publicKey)
console.log("私钥:", result.privateKey)
}
2. SM2 签名
回调风格:
import { sm2Sign } from "@/uni_modules/sunrains-util";
sm2Sign({
data: "需要签名的数据",
privateKey: "你的私钥",
success: (res) => {
console.log("签名结果:", res.signature);
},
fail: (err) => {
console.error("签名失败:", err);
}
});
异步风格(推荐):
import { sm2SignAsync } from "@/uni_modules/sunrains-util";
async function doSign() {
try {
const result = await sm2SignAsync("需要签名的数据", "你的私钥")
console.log("签名结果:", result.signature)
} catch (error) {
console.error("签名失败:", error)
}
}
3. SM2 验签
回调风格:
import { sm2Verify } from "@/uni_modules/sunrains-util";
sm2Verify({
data: "原始数据",
signature: "签名值",
publicKey: "你的公钥",
success: (res) => {
console.log("验签结果:", res.valid); // true 或 false
},
fail: (err) => {
console.error("验签失败:", err);
}
});
异步风格(推荐):
import { sm2VerifyAsync } from "@/uni_modules/sunrains-util";
async function doVerify() {
try {
const result = await sm2VerifyAsync("原始数据", "签名值", "你的公钥")
console.log("验签结果:", result.valid) // true 或 false
} catch (error) {
console.error("验签失败:", error)
}
}
完整实战示例
以下是一个完整的国密算法测试页面示例,及服务端(java)工具类,展示了所有功能的实际使用:
客户端 Vue 3 Composition API 示例
<template>
<view class="order-content">
<view class="order-header">
<text class="order-title">国密算法测试</text>
</view>
<scroll-view class="order-list" scroll-y="true">
<view class="section">
<view class="section-title">SM4 对称加密</view>
<view class="input-group">
<text class="label">待加密内容:</text>
<input v-model="sm4Content" placeholder="请输入要加密的内容" class="input" />
</view>
<button @click="testSm4Encrypt" class="btn btn-primary">SM4 加密</button>
<view class="result-box" v-if="sm4Key.key">
<text class="result-label">密钥 (key):</text>
<text selectable="true" class="result-value">{{ sm4Key.key }}</text>
</view>
<view class="result-box" v-if="sm4Key.iv">
<text class="result-label">初始向量 (iv):</text>
<text selectable="true" class="result-value">{{ sm4Key.iv }}</text>
</view>
<view class="result-box" v-if="sm4Encrypted">
<text class="result-label">加密结果:</text>
<text selectable="true" class="result-value">{{ sm4Encrypted }}</text>
</view>
<button @click="testSm4Decrypt" :disabled="sm4Encrypted.length==0" class="btn btn-secondary">SM4 解密</button>
<button @click="testSm4Decrypt" :disabled="sm4Encrypted === ''" :class="sm4Encrypted === '' ? 'btn btn-secondary disabled' : 'btn btn-secondary'" class="btn btn-secondary">SM4 解密</button>
<view class="result-box" v-if="sm4Decrypted">
<text class="result-label">解密结果:</text>
<text selectable="true" class="result-value success">{{ sm4Decrypted }}</text>
</view>
</view>
<view class="section">
<view class="section-title">SM2 数字签名</view>
<view class="input-group">
<text class="label">待签名内容:</text>
<input v-model="sm2SignContent" placeholder="请输入要签名的内容" class="input" />
</view>
<button @click="testSm2Sign" class="btn btn-primary">生成密钥对并签名</button>
<view class="result-box" v-if="sm2KeyPair.publicKey">
<text class="result-label">公钥:</text>
<text selectable="true" class="result-value small">{{ sm2KeyPair.publicKey }}</text>
</view>
<view class="result-box" v-if="sm2KeyPair.privateKey">
<text class="result-label">私钥:</text>
<text selectable="true" class="result-value small">{{ sm2KeyPair.privateKey }}</text>
</view>
<view class="result-box" v-if="sm2Signature">
<text class="result-label">签名结果:</text>
<text selectable="true" class="result-value small">{{ sm2Signature }}</text>
</view>
<button @click="testSm2Verify" :disabled="sm2Signature.length==0" class="btn btn-secondary">验签</button>
<view class="result-box" v-if="sm2VerifyResult !== null">
<text class="result-label">验签结果:</text>
<text class="result-value" :class="sm2VerifyResult ==true? 'success' : 'error'">
{{ sm2VerifyResult ==true ? '✅ 验签成功' : '❌ 验签失败' }}
</text>
</view>
</view>
<view class="section">
<view class="section-title">SM2 非对称加密</view>
<view class="input-group">
<text class="label">待加密内容:</text>
<input v-model="sm2EncryptContent" placeholder="请输入要加密的内容" class="input" />
</view>
<button @click="testSm2Encrypt" class="btn btn-primary">SM2 加密</button>
<view class="result-box" v-if="sm2EncryptKeyPair.publicKey">
<text class="result-label">公钥:</text>
<text selectable="true" class="result-value small">{{ sm2EncryptKeyPair.publicKey }}</text>
</view>
<view class="result-box" v-if="sm2EncryptKeyPair.privateKey">
<text class="result-label">私钥:</text>
<text selectable="true" class="result-value small">{{ sm2EncryptKeyPair.privateKey }}</text>
</view>
<view class="result-box" v-if="sm2Encrypted">
<text class="result-label">加密结果:</text>
<text selectable="true" class="result-value small">{{ sm2Encrypted }}</text>
</view>
<button @click="testSm2Decrypt" :disabled="sm2Encrypted.length==0" class="btn btn-secondary">SM2 解密</button>
<view class="result-box" v-if="sm2Decrypted">
<text class="result-label">解密结果:</text>
<text selectable="true" class="result-value success">{{ sm2Decrypted }}</text>
</view>
</view>
<view class="section">
<view class="section-title">SM3 哈希算法</view>
<!-- 1. SM3 哈希 -->
<view class="input-group">
<text class="label">待哈希内容:</text>
<input v-model="sm3Content" placeholder="请输入要哈希的内容" class="input" />
</view>
<button @click="testSm3Hash" class="btn btn-primary">SM3 哈希</button>
<view class="result-box" v-if="sm3HashResult">
<text class="result-label">SM3 哈希值:</text>
<text selectable="true" class="result-value small">{{ sm3HashResult }}</text>
</view>
<!-- 2. SM3 哈希验证 -->
<view class="divider"></view>
<view class="sub-title">SM3 哈希验证</view>
<view class="input-group">
<text class="label">验证 - 原始内容:</text>
<input v-model="sm3VerifyContent" placeholder="请输入原始内容" class="input" />
</view>
<button @click="testSm3Verify" :disabled="sm3VerifyContent.length == 0" class="btn btn-secondary">验证哈希值</button>
<view class="result-box" v-if="sm3HashMatchResult !== null">
<text class="result-label">哈希匹配结果:</text>
<text class="result-value" :class="sm3HashMatchResult == true ? 'success' : 'error'">
{{ sm3HashMatchResult == true ? '✅ 哈希值匹配' : '❌ 哈希值不匹配' }}
</text>
</view>
<!-- 3. HMAC-SM3 哈希 -->
<view class="divider"></view>
<view class="sub-title">HMAC-SM3 哈希</view>
<view class="input-group">
<text class="label">HMAC-SM3 待哈希内容:</text>
<input v-model="sm3HmacContent" placeholder="请输入要哈希的内容" class="input" />
</view>
<view class="input-group">
<text class="label">HMAC-SM3 密钥(HEX):</text>
<input v-model="sm3HmacKey" placeholder="请输入HMAC密钥(HEX格式)" class="input" />
</view>
<button @click="testSm3HmacHash" :disabled="sm3HmacContent.length == 0 || sm3HmacKey.length == 0" class="btn btn-primary">HMAC-SM3 哈希</button>
<view class="result-box" v-if="sm3HmacHashResult">
<text class="result-label">HMAC-SM3 哈希值:</text>
<text selectable="true" class="result-value small">{{ sm3HmacHashResult }}</text>
</view>
<!-- 4. HMAC-SM3 哈希验证 -->
<view class="divider"></view>
<view class="sub-title">HMAC-SM3 哈希验证</view>
<view class="input-group">
<text class="label">验证 - 原始内容:</text>
<input v-model="sm3HmacVerifyContent" placeholder="请输入原始内容" class="input" />
</view>
<view class="input-group">
<text class="label">验证 - HMAC 密钥:</text>
<input v-model="sm3HmacVerifyKey" placeholder="请输入HMAC密钥" class="input" />
</view>
<view class="input-group">
<text class="label">验证 - 期望哈希值:</text>
<input v-model="sm3HmacExpectedHash" placeholder="请输入期望的哈希值" class="input" />
</view>
<button @click="testSm3HmacHashWithExpected" :disabled="sm3HmacVerifyContent.length == 0 || sm3HmacVerifyKey.length == 0 || sm3HmacExpectedHash.length == 0" class="btn btn-secondary">验证 HMAC-SM3 哈希值</button>
<view class="result-box" v-if="sm3HmacHashMatchResult !== null">
<text class="result-label">HMAC-SM3 哈希匹配结果:</text>
<text class="result-value" :class="sm3HmacHashMatchResult == true ? 'success' : 'error'">
{{ sm3HmacHashMatchResult == true ? '✅ 哈希值匹配' : '❌ 哈希值不匹配' }}
</text>
</view>
<!-- 5. 文件 SM3 哈希 -->
<view class="divider"></view>
<view class="sub-title">文件 SM3 哈希</view>
<button @click="chooseImageAndHash" class="btn btn-primary">选择图片并计算 SM3</button>
<button @click="chooseFileAndHash" class="btn btn-primary">选择文件并计算 SM3</button>
<view class="result-box" v-if="sm3FileName">
<text class="result-label">文件名:</text>
<text class="result-value">{{ sm3FileName }}</text>
</view>
<view class="result-box" v-if="sm3FileHashResult">
<text class="result-label">文件 SM3 哈希值:</text>
<text selectable="true" class="result-value small">{{ sm3FileHashResult }}</text>
</view>
</view>
<view class="section">
<view class="section-title">AES 对称加密</view>
<view class="input-group">
<text class="label">待加密内容:</text>
<input v-model="aesContent" placeholder="请输入要加密的内容" class="input" />
</view>
<button @click="testAesEncrypt" class="btn btn-primary">AES 加密</button>
<view class="result-box" v-if="aesKey.key">
<text class="result-label">密钥 (key):</text>
<text selectable="true" class="result-value">{{ aesKey.key }}</text>
</view>
<view class="result-box" v-if="aesKey.iv">
<text class="result-label">初始向量 (iv):</text>
<text selectable="true" class="result-value">{{ aesKey.iv }}</text>
</view>
<view class="result-box" v-if="aesEncrypted">
<text class="result-label">加密结果:</text>
<text selectable="true" class="result-value">{{ aesEncrypted }}</text>
</view>
<button @click="testAesDecrypt" :disabled="aesEncrypted.length==0" class="btn btn-secondary">AES 解密</button>
<view class="result-box" v-if="aesDecrypted">
<text class="result-label">解密结果:</text>
<text selectable="true" class="result-value success">{{ aesDecrypted }}</text>
</view>
</view>
<view class="section">
<view class="section-title">RSA 非对称加密</view>
<view class="input-group">
<text class="label">待加密内容:</text>
<input v-model="rsaContent" placeholder="请输入要加密的内容" class="input" />
</view>
<button @click="testRsaEncrypt" class="btn btn-primary">RSA 加密</button>
<view class="result-box" v-if="rsaKeyPair.publicKey">
<text class="result-label">公钥:</text>
<text selectable="true" class="result-value small">{{ rsaKeyPair.publicKey }}</text>
</view>
<view class="result-box" v-if="rsaKeyPair.privateKey">
<text class="result-label">私钥:</text>
<text selectable="true" class="result-value small">{{ rsaKeyPair.privateKey }}</text>
</view>
<view class="result-box" v-if="rsaEncrypted">
<text class="result-label">加密结果:</text>
<text selectable="true" class="result-value small">{{ rsaEncrypted }}</text>
</view>
<button @click="testRsaDecrypt" :disabled="rsaEncrypted.length==0" class="btn btn-secondary">RSA 解密</button>
<view class="result-box" v-if="rsaDecrypted">
<text class="result-label">解密结果:</text>
<text selectable="true" class="result-value success">{{ rsaDecrypted }}</text>
</view>
</view>
<view class="section">
<view class="section-title">RSA 数字签名</view>
<view class="input-group">
<text class="label">待签名内容:</text>
<input v-model="rsaSignContent" placeholder="请输入要签名的内容" class="input" />
</view>
<button @click="testRsaSign" class="btn btn-primary">生成密钥对并签名</button>
<view class="result-box" v-if="rsaSignKeyPair.publicKey">
<text class="result-label">公钥:</text>
<text selectable="true" class="result-value small">{{ rsaSignKeyPair.publicKey }}</text>
</view>
<view class="result-box" v-if="rsaSignKeyPair.privateKey">
<text class="result-label">私钥:</text>
<text selectable="true" class="result-value small">{{ rsaSignKeyPair.privateKey }}</text>
</view>
<view class="result-box" v-if="rsaSignature">
<text class="result-label">签名结果:</text>
<text selectable="true" class="result-value small">{{ rsaSignature }}</text>
</view>
<button @click="testRsaVerify" :disabled="rsaSignature.length==0" class="btn btn-secondary">验签</button>
<view class="result-box" v-if="rsaVerifyResult !== null">
<text class="result-label">验签结果:</text>
<text class="result-value" :class="rsaVerifyResult ==true? 'success' : 'error'">
{{ rsaVerifyResult ==true ? '✅ 验签成功' : '❌ 验签失败' }}
</text>
</view>
</view>
<view class="order-item" @click="goToDetail(1002, '已完成')">
<view class="order-info">
<text class="order-id">订单编号:1002</text>
<text class="order-status">状态:已完成</text>
</view>
<view class="order-btn">
<text class="go-detail">查看详情</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import {IdcardOcrModel}from "@/uni_modules/sunrains-idcard-ocr/utssdk/interface.uts"
import {Response} from "@/uni_modules/sunrains-utils/index.uts"
import * as SM from "@/uni_modules/sunrains-smutil"
// ==================== SM4 相关数据 ====================
const sm4Content = ref("Hello World")
const sm4Key = ref<SM.Sm4Key>({ key: '', iv: '' })
const sm4Encrypted = ref("")
const sm4Decrypted = ref("")
// ==================== SM2 签名验签相关数据 ====================
const sm2SignContent = ref("123")
const sm2KeyPair = ref<SM.Sm2KeyPair>({ publicKey: '', privateKey: '' })
const sm2Signature = ref("")
const sm2VerifyResult = ref<boolean | null>(null)
// ==================== SM2 加密解密相关数据 ====================
const sm2EncryptContent = ref("Hello SM2")
const sm2EncryptKeyPair = ref<SM.Sm2KeyPair>({ publicKey: '', privateKey: '' })
const sm2Encrypted = ref("")
const sm2Decrypted = ref("")
// ==================== SM3 相关数据 ====================
const sm3Content = ref("Hello SM3")
const sm3HashResult = ref("")
const sm3VerifyContent = ref("")
const sm3HashMatchResult = ref<boolean | null>(null)
const sm3HmacContent = ref("Hello HMAC-SM3")
const sm3HmacKey = ref("0123456789abcdef0123456789abcdef") // 默认 HMAC 密钥(32字节HEX)
const sm3HmacHashResult = ref("")
const sm3HmacVerifyContent = ref("")
const sm3HmacVerifyKey = ref("")
const sm3HmacExpectedHash = ref("")
const sm3HmacHashMatchResult = ref<boolean | null>(null)
const sm3VerifyResult = ref<boolean|null>(null)
const sm3FileName = ref("")
const sm3FileHashResult = ref("")
// ==================== AES 相关数据 ====================
const aesContent = ref("Hello AES")
const aesKey = ref<SM.AesKey>({ key: '', iv: '' })
const aesEncrypted = ref("")
const aesDecrypted = ref("")
// ==================== RSA 加密解密相关数据 ====================
const rsaContent = ref("Hello RSA")
const rsaKeyPair = ref<SM.RsaKeyPair>({ publicKey: '', privateKey: '' })
const rsaEncrypted = ref("")
const rsaDecrypted = ref("")
// ==================== RSA 签名验签相关数据 ====================
const rsaSignContent = ref("需要签名的数据")
const rsaSignKeyPair = ref<SM.RsaKeyPair>({ publicKey: '', privateKey: '' })
const rsaSignature = ref("")
const rsaVerifyResult = ref<boolean | null>(null)
// ==================== SM4 加密 ====================
async function testSm4Encrypt() {
if (sm4Content.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥
const key = await SM.sm4GenerateKeyAsync()
sm4Key.value = { key: key.key, iv: key.iv }
console.log("SM4 密钥:", key)
// 加密
const encryptResult = await SM.sm4EncryptAsync(sm4Content.value, key.key, key.iv)
sm4Encrypted.value = encryptResult.result
console.log("SM4 加密成功:", encryptResult.result)
uni.showToast({ title: '加密成功', icon: 'success' })
} catch (e) {
console.error("SM4 加密失败:", e)
uni.showToast({ title: '加密失败', icon: 'error' })
}
}
// ==================== SM4 解密 ====================
async function testSm4Decrypt() {
if (sm4Encrypted.value.length==0 || sm4Key.value.key.length==0) {
uni.showToast({ title: '请先加密', icon: 'none' })
return
}
try {
const decryptResult = await SM.sm4DecryptAsync(sm4Encrypted.value, sm4Key.value.key, sm4Key.value.iv)
sm4Decrypted.value = decryptResult.result
console.log("SM4 解密成功:", decryptResult.result)
if (decryptResult.result == sm4Content.value) {
uni.showToast({ title: '解密成功且内容一致', icon: 'success' })
} else {
uni.showToast({ title: '解密成功但内容不一致', icon: 'error' })
}
} catch (e) {
console.error("SM4 解密失败:", e)
uni.showToast({ title: '解密失败', icon: 'error' })
}
}
// ==================== AES 加密 ====================
async function testAesEncrypt() {
if (aesContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥和 IV
const key = await SM.aesGenerateKeyAsync()
aesKey.value = { key: key.key, iv: key.iv }
console.log("AES 密钥:", key)
// 加密
const encryptResult = await SM.aesEncryptAsync(aesContent.value, key.key, key.iv)
aesEncrypted.value = encryptResult.result
console.log("AES 加密成功:", encryptResult.result)
uni.showToast({ title: '加密成功', icon: 'success' })
} catch (e) {
console.error("AES 加密失败:", e)
uni.showToast({ title: '加密失败', icon: 'error' })
}
}
// ==================== AES 解密 ====================
async function testAesDecrypt() {
if (aesEncrypted.value.length==0 || aesKey.value.key.length==0) {
uni.showToast({ title: '请先加密', icon: 'none' })
return
}
try {
const decryptResult = await SM.aesDecryptAsync(aesEncrypted.value, aesKey.value.key, aesKey.value.iv)
aesDecrypted.value = decryptResult.result
console.log("AES 解密成功:", decryptResult.result)
if (decryptResult.result == aesContent.value) {
uni.showToast({ title: '解密成功且内容一致', icon: 'success' })
} else {
uni.showToast({ title: '解密成功但内容不一致', icon: 'error' })
}
} catch (e) {
console.error("AES 解密失败:", e)
uni.showToast({ title: '解密失败', icon: 'error' })
}
}
// ==================== RSA 加密 ====================
async function testRsaEncrypt() {
if (rsaContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥对
const keyPair = await SM.rsaGenerateKeyPairAsync()
rsaKeyPair.value = {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey
}
console.log("RSA 公钥:", keyPair.publicKey)
console.log("RSA 私钥:", keyPair.privateKey)
// 加密
const encryptResult = await SM.rsaEncryptAsync(rsaContent.value, keyPair.publicKey)
rsaEncrypted.value = encryptResult.result
console.log("RSA 加密结果:", encryptResult.result)
uni.showToast({ title: '加密成功', icon: 'success' })
} catch (e) {
console.error("RSA 加密失败:", e)
uni.showToast({ title: '加密失败', icon: 'error' })
}
}
// ==================== RSA 解密 ====================
async function testRsaDecrypt() {
if (rsaEncrypted.value.length==0 || rsaKeyPair.value.privateKey.length==0) {
uni.showToast({ title: '请先加密', icon: 'none' })
return
}
try {
const decryptResult = await SM.rsaDecryptAsync(rsaEncrypted.value, rsaKeyPair.value.privateKey)
rsaDecrypted.value = decryptResult.result
console.log("RSA 解密结果:", decryptResult.result)
if (decryptResult.result == rsaContent.value) {
uni.showToast({ title: '解密成功且内容一致', icon: 'success' })
} else {
uni.showToast({ title: '解密成功但内容不一致', icon: 'error' })
}
} catch (e) {
console.error("RSA 解密异常:", e)
uni.showToast({ title: '解密异常', icon: 'error' })
}
}
// ==================== RSA 签名 ====================
async function testRsaSign() {
if (rsaSignContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥对
const keyPair = await SM.rsaGenerateKeyPairAsync()
rsaSignKeyPair.value = {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey
}
console.log("RSA 公钥:", keyPair.publicKey)
console.log("RSA 私钥:", keyPair.privateKey)
// 签名
const signResult = await SM.rsaSignAsync(rsaSignContent.value, keyPair.privateKey)
rsaSignature.value = signResult.signature
console.log("RSA 签名成功:", signResult.signature)
uni.showToast({ title: '签名成功', icon: 'success' })
} catch (e) {
console.error("RSA 签名失败:", e)
uni.showToast({ title: '签名失败', icon: 'error' })
}
}
// ==================== RSA 验签 ====================
async function testRsaVerify() {
if (rsaSignature.value.length==0 || rsaSignKeyPair.value.publicKey.length==0) {
uni.showToast({ title: '请先签名', icon: 'none' })
return
}
try {
// 验签
const verifyResult = await SM.rsaVerifyAsync(rsaSignContent.value, rsaSignature.value, rsaSignKeyPair.value.publicKey)
rsaVerifyResult.value = verifyResult.valid
console.log("RSA 验签结果:", verifyResult.valid)
if (verifyResult.valid) {
uni.showToast({ title: '验签成功', icon: 'success' })
} else {
uni.showToast({ title: '验签失败', icon: 'error' })
}
} catch (e) {
console.error("RSA 验签异常:", e)
uni.showToast({ title: '验签异常', icon: 'error' })
}
}
// ==================== SM2 签名 ====================
async function testSm2Sign() {
if (sm2SignContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥对
const keyPair = await SM.sm2GenerateKeyPairAsync()
sm2KeyPair.value = {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey
}
console.log("SM2 公钥:", keyPair.publicKey)
console.log("SM2 私钥:", keyPair.privateKey)
// 签名
const signResult = await SM.sm2SignAsync(sm2SignContent.value, keyPair.privateKey)
sm2Signature.value = signResult.signature
console.log("SM2 签名成功:", signResult.signature)
uni.showToast({ title: '签名成功', icon: 'success' })
} catch (e) {
console.error("SM2 生成密钥对失败:", e)
uni.showToast({ title: '生成密钥对失败', icon: 'error' })
}
}
// ==================== SM2 验签 ====================
async function testSm2Verify() {
if (sm2Signature.value.length==0 || sm2KeyPair.value.publicKey.length==0) {
uni.showToast({ title: '请先签名', icon: 'none' })
return
}
try {
// 验签
const verifyResult = await SM.sm2VerifyAsync(sm2SignContent.value, sm2Signature.value, sm2KeyPair.value.publicKey)
sm2VerifyResult.value = verifyResult.valid
console.log("SM2 验签结果:", verifyResult.valid)
if (verifyResult.valid) {
uni.showToast({ title: '验签成功', icon: 'success' })
} else {
uni.showToast({ title: '验签失败', icon: 'error' })
}
} catch (e) {
console.error("SM2 验签异常:", e)
uni.showToast({ title: '验签异常', icon: 'error' })
}
}
// ==================== SM2 加密 ====================
async function testSm2Encrypt() {
if (sm2EncryptContent.value.length==0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
// 生成密钥对
const keyPair = await SM.sm2GenerateKeyPairAsync()
sm2EncryptKeyPair.value = {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey
}
console.log("SM2 加密公钥:", keyPair.publicKey)
console.log("SM2 加密私钥:", keyPair.privateKey)
// 加密
const encryptResult = await SM.sm2EncryptAsync(sm2EncryptContent.value, keyPair.publicKey)
sm2Encrypted.value = encryptResult.encrypted
console.log("SM2 加密结果:", encryptResult.encrypted)
uni.showToast({ title: '加密成功', icon: 'success' })
} catch (e) {
console.error("SM2 生成密钥对失败:", e)
uni.showToast({ title: '生成密钥对失败', icon: 'error' })
}
}
// ==================== SM2 解密 ====================
async function testSm2Decrypt() {
if (sm2Encrypted.value.length==0 || sm2EncryptKeyPair.value.privateKey.length==0) {
uni.showToast({ title: '请先加密', icon: 'none' })
return
}
try {
const decryptResult = await SM.sm2DecryptAsync(sm2Encrypted.value, sm2EncryptKeyPair.value.privateKey)
sm2Decrypted.value = decryptResult.decrypted
console.log("SM2 解密结果:", decryptResult.decrypted)
if (decryptResult.decrypted == sm2EncryptContent.value) {
uni.showToast({ title: '解密成功且内容一致', icon: 'success' })
} else {
uni.showToast({ title: '解密成功但内容不一致', icon: 'error' })
}
} catch (e) {
console.error("SM2 解密异常:", e)
uni.showToast({ title: '解密异常', icon: 'error' })
}
}
// ==================== SM3 哈希 ====================
async function testSm3Hash() {
if (sm3Content.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
const hashResult = await SM.sm3HashAsync(sm3Content.value)
sm3HashResult.value = hashResult.resultHash
console.log("SM3 哈希成功:", hashResult.resultHash)
uni.showToast({ title: '哈希成功', icon: 'success' })
} catch (e) {
console.error("SM3 哈希失败:", e)
uni.showToast({ title: '哈希失败', icon: 'error' })
}
}
// ==================== HMAC-SM3 哈希 ====================
async function testSm3HmacHash() {
if (sm3HmacContent.value.length == 0) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
if (sm3HmacKey.value.length == 0) {
uni.showToast({ title: '请输入HMAC密钥', icon: 'none' })
return
}
try {
const hmacHashResult = await SM.sm3HmacHashAsync(sm3HmacContent.value, sm3HmacKey.value)
sm3HmacHashResult.value = hmacHashResult.resultHash
sm3HmacExpectedHash.value = hmacHashResult.resultHash
sm3HmacVerifyKey.value = sm3HmacKey.value
sm3HmacVerifyContent.value = sm3HmacContent.value
console.log("HMAC-SM3 哈希成功:", hmacHashResult.resultHash)
uni.showToast({ title: 'HMAC-SM3 哈希成功', icon: 'success' })
} catch (e) {
console.error("HMAC-SM3 哈希失败:", e)
uni.showToast({ title: 'HMAC-SM3 哈希失败', icon: 'error' })
}
}
// ==================== HMAC-SM3 验证(输入期望哈希值) ====================
async function testSm3HmacHashWithExpected() {
if (sm3HmacVerifyContent.value.length == 0) {
uni.showToast({ title: '请输入原始内容', icon: 'none' })
return
}
try {
// 计算实际 HMAC-SM3 哈希值
const res = await SM.sm3HmacVerifyAsync(sm3HmacVerifyContent.value, sm3HmacVerifyKey.value,sm3HmacHashResult.value)
if (res) {
uni.showToast({ title: 'HMAC-SM3 哈希值匹配', icon: 'success' })
} else {
uni.showToast({ title: 'HMAC-SM3 哈希值不匹配', icon: 'error' })
}
} catch (e) {
console.error("HMAC-SM3 哈希验证异常:", e)
uni.showToast({ title: '验证异常', icon: 'error' })
}
}
// ==================== SM3 验证 ====================
async function testSm3Verify() {
if (sm3HashResult.value.length == 0 || sm3Content.value.length == 0) {
uni.showToast({ title: '请先计算哈希', icon: 'none' })
return
}
try {
// 重新计算哈希并验证
const res = await SM.sm3VerifyAsync(sm3VerifyContent.value,sm3HashResult.value)
sm3VerifyResult.value = res
console.log("SM3 验证结果:", sm3VerifyResult.value)
if (sm3VerifyResult.value!) {
uni.showToast({ title: '验证成功', icon: 'success' })
} else {
uni.showToast({ title: '验证失败', icon: 'error' })
}
} catch (e) {
console.error("SM3 验证异常:", e)
uni.showToast({ title: '验证异常', icon: 'error' })
}
}
// ==================== 选择文件并计算 SM3 哈希 ====================
async function processFileHash(filePath: string, fileSize?: number) {
// 计算文件 SM3 哈希
uni.showLoading({ title: '计算中...' })
try {
console.log("开始计算文件 SM3 哈希")
console.log("文件路径:", filePath)
const result = await SM.sm3FileHashAsync(filePath)
sm3FileHashResult.value = result.resultHash
console.log("文件 SM3 哈希成功:", result.resultHash)
uni.hideLoading()
uni.showToast({ title: '文件哈希成功', icon: 'success' })
} catch (e) {
console.error("文件 SM3 哈希失败:", e)
uni.hideLoading()
uni.showToast({ title: '文件哈希失败', icon: 'error' })
}
}
function chooseFileAndHash() {
uni.chooseFile({
count: 1,
success: (res) => {
const tempFiles = res.tempFiles
if (tempFiles != null && tempFiles.length > 0) {
const file = tempFiles[0]
const fileName = file.name
const filePath = file.path
const fileSize = file.size
sm3FileName.value = fileName != null ? fileName : "未知文件"
if (filePath != null) {
processFileHash(filePath, fileSize)
}
}
},
fail: (err) => {
console.error("选择文件失败:", err)
if (err.errMsg != null && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选择文件失败', icon: 'error' })
}
}
})
}
// ==================== 选择相册图片并计算 SM3 哈希 ====================
function chooseImageAndHash() {
uni.chooseImage({
count: 1,
sizeType: ['original'], // 只选择原图,不压缩
sourceType: ['album', 'camera'],
success: (res) => {
const tempFilePaths = res.tempFilePaths
if (tempFilePaths != null && tempFilePaths.length > 0) {
const filePath = tempFilePaths[0]
const fileName = "图片文件"
sm3FileName.value = fileName
console.log("选择的图片(原图):", filePath)
if (filePath != null) {
// 调用异步函数处理文件哈希
processFileHash(filePath)
}
}
},
fail: (err) => {
console.error("选择图片失败:", err)
if (err.errMsg != null && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选择图片失败', icon: 'error' })
}
}
})
}
const goToDetail = (orderId: number, status: string) => {
uni.setStorageSync('fromTabBar', true)
uni.navigateTo({
url: `/pages/detail/detail?orderId=${orderId}&status=${status}`,
success: () => {
console.log(`跳转到order${orderId}的详情页`)
}
})
}
function btn (){
}
</script>
<style scoped>
.order-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: #f5f5f5;
}
.order-header {
height: 48px;
background-color: #fff;
align-items: center;
justify-content: center;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #eee;
}
.order-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.order-list {
flex: 1;
padding: 10px;
}
/* 区块样式 */
.section {
background-color: #fff;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #eee;
}
.input-group {
margin-bottom: 15px;
}
.label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.input {
height: 40px;
border-width: 1px;
border-style: solid;
border-color: #ddd;
border-radius: 4px;
padding: 0 10px;
font-size: 14px;
background-color: #fafafa;
}
/* 按钮样式 */
.btn {
height: 44px;
border-radius: 4px;
font-size: 15px;
margin-bottom: 10px;
}
.btn-primary {
background-color: #007aff;
color: #fff;
}
.btn-secondary {
background-color: #34c759;
color: #fff;
}
.btn:disabled {
opacity: 0.5;
}
/* 结果展示框 */
.result-box {
margin-top: 10px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
border-left-width: 3px;
border-left-style: solid;
border-left-color: #007aff;
}
.result-label {
font-size: 12px;
color: #999;
margin-bottom: 5px;
}
.result-value {
font-size: 13px;
color: #333;
line-height: 1.5;
}
.result-value.small {
font-size: 11px;
}
.result-value.success {
color: #34c759;
font-weight: bold;
}
.result-value.error {
color: #ff3b30;
font-weight: bold;
}
.divider {
height: 1px;
background-color: #eee;
margin: 15px 0;
}
.sub-title {
font-size: 14px;
font-weight: bold;
color: #666;
margin-bottom: 10px;
}
</style>
服务端(java工具类)
package top.sunrains;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.crypto.engines.SM2Engine;
import org.bouncycastle.crypto.params.ECDomainParameters;
import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
import org.bouncycastle.crypto.params.ECPublicKeyParameters;
import org.bouncycastle.crypto.params.ParametersWithRandom;
import org.bouncycastle.crypto.signers.SM2Signer;
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.spec.ECParameterSpec;
import org.bouncycastle.util.encoders.Hex;
import java.math.BigInteger;
import java.security.SecureRandom;
/**
* <dependency>
* <groupId>org.bouncycastle</groupId>
* <artifactId>bcprov-jdk15to18</artifactId>
* <version>1.69</version>
* </dependency>
*/
public class SM2Util {
private static final ECParameterSpec CURVE = ECNamedCurveTable.getParameterSpec("sm2p256v1");
private static final ECDomainParameters DOMAIN = new ECDomainParameters(
CURVE.getCurve(),
CURVE.getG(),
CURVE.getN(),
CURVE.getH()
);
/**
* 生成SM2密钥对
*
* @return Map包含publicKey和privateKey,均为小写十六进制字符串
*/
public static java.util.Map<String, String> generateKeyPair() {
try {
BigInteger d = new BigInteger(256, new SecureRandom()).mod(DOMAIN.getN());
org.bouncycastle.math.ec.ECPoint q = DOMAIN.getG().multiply(d).normalize();
String x = String.format("4x", q.getAffineXCoord().toBigInteger());
String y = String.format("4x", q.getAffineYCoord().toBigInteger());
String publicKey = "04" + x + y;
String privateKey = String.format("4x", d);
java.util.Map<String, String> keyMap = new java.util.HashMap<>();
keyMap.put("publicKey", publicKey.toLowerCase());
keyMap.put("privateKey", privateKey.toLowerCase());
return keyMap;
} catch (Exception e) {
System.err.println("密钥生成失败: " + e.getMessage());
e.printStackTrace();
java.util.Map<String, String> keyMap = new java.util.HashMap<>();
keyMap.put("publicKey", "");
keyMap.put("privateKey", "");
return keyMap;
}
}
/**
* SM2签名
*
* @param data 待签名的原始数据
* @param privateKeyHex 私钥(64字符十六进制)
* @return 签名结果(DER格式,小写十六进制)
*/
public static String sign(String data, String privateKeyHex) {
try {
byte[] msgBytes = data.getBytes(java.nio.charset.StandardCharsets.UTF_8);
BigInteger d = new BigInteger(privateKeyHex, 16);
ECPrivateKeyParameters priKey = new ECPrivateKeyParameters(d, DOMAIN);
SM2Signer signer = new SM2Signer(new SM3Digest());
signer.init(true, priKey);
signer.update(msgBytes, 0, msgBytes.length);
byte[] sig = signer.generateSignature();
return Hex.toHexString(sig).toLowerCase();
} catch (Exception e) {
System.err.println("签名失败: " + e.getMessage());
e.printStackTrace();
return "";
}
}
/**
* SM2验签
*
* @param data 原始数据
* @param signatureHex 签名(DER格式,十六进制)
* @param publicKeyHex 公钥(04开头或不含04前缀的十六进制)
* @return 验签是否成功
*/
public static boolean verify(String data, String signatureHex, String publicKeyHex) {
try {
byte[] msgBytes = data.getBytes(java.nio.charset.StandardCharsets.UTF_8);
String xy = publicKeyHex.startsWith("04") ? publicKeyHex.substring(2) : publicKeyHex;
BigInteger x = new BigInteger(xy.substring(0, 64), 16);
BigInteger y = new BigInteger(xy.substring(64, 128), 16);
org.bouncycastle.math.ec.ECPoint pubPoint = DOMAIN.getCurve().createPoint(x, y).normalize();
ECPublicKeyParameters pubKey = new ECPublicKeyParameters(pubPoint, DOMAIN);
SM2Signer signer = new SM2Signer(new SM3Digest());
signer.init(false, pubKey);
signer.update(msgBytes, 0, msgBytes.length);
byte[] sigBytes = Hex.decode(signatureHex);
return signer.verifySignature(sigBytes);
} catch (Exception e) {
System.err.println("验签失败: " + e.getMessage());
e.printStackTrace();
return false;
}
}
/**
* SM2加密
*
* @param data 待加密的原始数据(UTF-8字符串)
* @param publicKeyHex 公钥(04开头或不含04前缀的十六进制)
* @return 密文(HEX字符串,小写)
*/
public static String encrypt(String data, String publicKeyHex) {
try {
byte[] dataBytes = data.getBytes(java.nio.charset.StandardCharsets.UTF_8);
String xy = publicKeyHex.startsWith("04") ? publicKeyHex.substring(2) : publicKeyHex;
BigInteger x = new BigInteger(xy.substring(0, 64), 16);
BigInteger y = new BigInteger(xy.substring(64, 128), 16);
org.bouncycastle.math.ec.ECPoint pubPoint = DOMAIN.getCurve().createPoint(x, y).normalize();
ECPublicKeyParameters pubKey = new ECPublicKeyParameters(pubPoint, DOMAIN);
SM2Engine engine = new SM2Engine(new SM3Digest(), SM2Engine.Mode.C1C3C2);
engine.init(true, new ParametersWithRandom(pubKey, new SecureRandom()));
byte[] encrypted = engine.processBlock(dataBytes, 0, dataBytes.length);
return Hex.toHexString(encrypted).toLowerCase();
} catch (Exception e) {
System.err.println("加密失败: " + e.getMessage());
e.printStackTrace();
return "";
}
}
/**
* SM2解密
*
* @param encryptedHex 密文(HEX字符串)
* @param privateKeyHex 私钥(64字符十六进制)
* @return 解密后的原始数据(UTF-8字符串)
*/
public static String decrypt(String encryptedHex, String privateKeyHex) {
try {
byte[] encryptedBytes = Hex.decode(encryptedHex);
BigInteger d = new BigInteger(privateKeyHex, 16);
ECPrivateKeyParameters priKey = new ECPrivateKeyParameters(d, DOMAIN);
SM2Engine engine = new SM2Engine(new SM3Digest(), SM2Engine.Mode.C1C3C2);
engine.init(false, priKey);
byte[] decrypted = engine.processBlock(encryptedBytes, 0, encryptedBytes.length);
return new String(decrypted, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
System.err.println("解密失败: " + e.getMessage());
e.printStackTrace();
return "";
}
}
/**
* 测试方法
*/
public static void main(String[] args) {
System.out.println("=== SM2 工具类测试 ===\n");
java.util.Map<String, String> keyPair = generateKeyPair();
String publicKey = keyPair.get("publicKey");
String privateKey = keyPair.get("privateKey");
System.out.println("公钥: " + publicKey);
System.out.println("私钥: " + privateKey);
System.out.println();
String testData = "123";
System.out.println("原始数据: " + testData);
String signature = sign(testData, privateKey);
System.out.println("签名: " + signature);
System.out.println();
boolean isValid = verify(testData, signature, publicKey);
System.out.println("验签结果: " + isValid);
System.out.println();
if (!isValid) {
System.err.println("验签失败!");
} else {
System.out.println("验签成功!");
}
System.out.println("\n=== SM2 加密解密测试 ===\n");
// 加密解密测试
String originalText = "Hello SM2";
System.out.println("原始数据: " + originalText);
String encrypted = encrypt(originalText, publicKey);
System.out.println("原始数据: " + originalText);
String decrypted = decrypt(encrypted, privateKey);
System.out.println("解密结果: " + decrypted);
System.out.println();
if (originalText.equals(decrypted)) {
System.out.println("加密解密成功!");
} else {
System.err.println("加密解密失败!");
}
}
}
package top.sunrains;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.security.Security;
/**
* <dependency>
* <groupId>org.bouncycastle</groupId>
* <artifactId>bcprov-jdk15to18</artifactId>
* <version>1.69</version>
* </dependency>
*
* SM4国密算法工具类
* 使用Bouncy Castle原生API
* 配置信息:
* - 算法:SM4
* - 模式:CBC(密码分组链接)
* - 填充:PKCS5Padding
* - 密钥长度:128位(16字节)
* - IV长度:128位(16字节)
*/
public class Sm4Util {
static {
try {
Security.addProvider(new BouncyCastleProvider());
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws Exception {
// String key = generateKeyHex();
// String iv = generateIvHex();
//String s = encryptHex("123", key, iv);
String key = "7f9a942a1aa85c331d3697a5369a37fe";
String iv = "cbc07750d33d6a3ef6d39fc7e7c4a876";
String s = "09187a80e9f52e7e962a3465ed70669e";
System.out.println(s);
String decryptedHex = decryptHex(s, key, iv);
System.out.println(decryptedHex);
}
/**
* 生成SM4密钥(16字节,128位)
* @return 十六进制编码的密钥字符串(32个字符)
*/
public static String generateKeyHex() {
byte[] keyBytes = new byte[16];
new SecureRandom().nextBytes(keyBytes);
return byteArrayToHex(keyBytes);
}
/**
* 生成IV向量(16字节,128位)
* @return 十六进制编码的IV字符串(32个字符)
*/
public static String generateIvHex() {
byte[] ivBytes = new byte[16];
new SecureRandom().nextBytes(ivBytes);
return byteArrayToHex(ivBytes);
}
/**
* SM4-CBC加密(字符串到HEX)
* 标准加密方式
*
* @param plaintext 明文字符串(UTF-8)
* @param keyHex 十六进制密钥字符串
* @param ivHex 十六进制IV字符串
* @return 加密后的HEX字符串
* @throws Exception 加密异常
*/
public static String encryptHex(String plaintext, String keyHex, String ivHex) throws Exception {
byte[] key = decodeKeyHex(keyHex);
byte[] iv = decodeIvHex(ivHex);
byte[] data = plaintext.getBytes("UTF-8");
byte[] encrypted = encrypt(data, key, iv);
return byteArrayToHex(encrypted);
}
/**
* SM4-CBC加密
*
* @param data 原始数据字节数组
* @param key 密钥字节数组(16字节)
* @param iv IV字节数组(16字节)
* @return 加密后的字节数组
* @throws Exception 加密异常
*/
public static byte[] encrypt(byte[] data, byte[] key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS5Padding", "BC");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(key, "SM4"),
new IvParameterSpec(iv));
return cipher.doFinal(data);
}
/**
* byte[] 转 Hex
*
* @param bytes 字节数组
* @return HEX字符串(小写)
*/
public static String byteArrayToHex(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(b & 0xFF);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
return sb.toString().toLowerCase();
}
public static String decryptHex(String encryptedHex, String keyHex, String ivHex) throws Exception {
byte[] key = decodeKeyHex(keyHex);
byte[] iv = decodeIvHex(ivHex);
byte[] encryptedData = hexToByteArray(encryptedHex);
byte[] decrypted = decrypt(encryptedData, key, iv);
return new String(decrypted, "UTF-8");
}
/**
* SM4-CBC解密
*
* @param encryptedData 加密数据字节数组
* @param key 密钥字节数组(16字节)
* @param iv IV字节数组(16字节)
* @return 解密后的原始字节数组
* @throws Exception 解密异常
*/
public static byte[] decrypt(byte[] encryptedData, byte[] key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS5Padding", "BC");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(key, "SM4"),
new IvParameterSpec(iv));
return cipher.doFinal(encryptedData);
}
/**
* 从十六进制字符串还原密钥字节数组
* @param keyHex 十六进制编码的密钥字符串
* @return 密钥字节数组(16字节)
*/
public static byte[] decodeKeyHex(String keyHex) {
return hexToByteArray(keyHex);
}
/**
* 从十六进制字符串还原IV字节数组
* @param ivHex 十六进制编码的IV字符串
* @return IV字节数组(16字节)
*/
public static byte[] decodeIvHex(String ivHex) {
return hexToByteArray(ivHex);
}
/**
* HEX 转 byte[]
*
* @param hex HEX字符串
* @return 字节数组
*/
public static byte[] hexToByteArray(String hex) {
if (hex == null || hex.length() == 0) {
return new byte[0];
}
int len = hex.length() / 2;
byte[] bytes = new byte[len];
for (int i = 0; i < len; i++) {
int high = Character.digit(hex.charAt(i * 2), 16);
int low = Character.digit(hex.charAt(i * 2 + 1), 16);
bytes[i] = (byte) ((high << 4) | low);
}
return bytes;
}
public static String stringToHex(String str) {
if (str == null || str.isEmpty()) {
return "";
}
byte[] bytes = str.getBytes(java.nio.charset.StandardCharsets.UTF_8);
return byteArrayToHex(bytes);
}
}
package top.sunrains;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
/**
* AES对称加密算法工具类
* 使用Java原生Crypto API
* 配置信息:
* - 算法:AES-256
* - 模式:CBC(密码分组链接)
* - 填充:PKCS5Padding
* - 密钥长度:256位(32字节)
* - IV长度:128位(16字节)
*/
public class AesUtil {
/**
* 生成AES密钥和IV
* @return Map包含key (32字符) 和 iv (16字符)
*/
public static Map<String, String> generateKey() {
try {
// 生成32字节的随机字符串作为密钥
String key = generateRandomString(32);
// 生成16字节的随机字符串作为IV
String iv = generateRandomString(16);
System.out.println("AES Key: " + key + " (长度: " + key.length() + ")");
System.out.println("AES IV: " + iv + " (长度: " + iv.length() + ")");
Map<String, String> result = new HashMap<>();
result.put("key", key);
result.put("iv", iv);
return result;
} catch (Exception e) {
System.err.println("AES 密钥生成失败: " + e.getMessage());
e.printStackTrace();
return new HashMap<>();
}
}
/**
* 生成指定长度的随机字符串
*/
private static String generateRandomString(int length) {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
SecureRandom random = new SecureRandom();
StringBuilder result = new StringBuilder(length);
for (int i = 0; i < length; i++) {
result.append(chars.charAt(random.nextInt(chars.length())));
}
return result.toString();
}
/**
* AES-CBC加密
*
* @param plaintext 明文字符串(UTF-8)
* @param keyStr 密钥字符串(32字符)
* @param ivStr IV字符串(16字符)
* @return Base64编码的密文
* @throws Exception 加密异常
*/
public static String encrypt(String plaintext, String keyStr, String ivStr) throws Exception {
// 将字符串Key和IV转换为字节数组(UTF-8编码)
byte[] keyBytes = keyStr.getBytes("UTF-8");
byte[] ivBytes = ivStr.getBytes("UTF-8");
// 创建密钥和IV
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
// 创建Cipher对象(AES/CBC/PKCS5Padding)
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
// 执行加密
byte[] encrypted = cipher.doFinal(plaintext.getBytes("UTF-8"));
// 返回Base64编码的密文
return Base64.getEncoder().encodeToString(encrypted);
}
/**
* AES-CBC解密
*
* @param encryptedBase64 Base64编码的密文
* @param keyStr 密钥字符串(32字符)
* @param ivStr IV字符串(16字符)
* @return 解密后的明文字符串
* @throws Exception 解密异常
*/
public static String decrypt(String encryptedBase64, String keyStr, String ivStr) throws Exception {
// 将字符串Key和IV转换为字节数组(UTF-8编码)
byte[] keyBytes = keyStr.getBytes("UTF-8");
byte[] ivBytes = ivStr.getBytes("UTF-8");
// 创建密钥和IV
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
// 创建Cipher对象(AES/CBC/PKCS5Padding)
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
// 将Base64密文解码为字节
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedBase64);
// 执行解密
byte[] decrypted = cipher.doFinal(encryptedBytes);
// 返回明文字符串
return new String(decrypted, "UTF-8");
}
/**
* 测试方法
*/
public static void main(String[] args) throws Exception {
System.out.println("=== AES 工具类测试 ===\n");
// 生成密钥
Map<String, String> keyMap = generateKey();
String key = keyMap.get("key");
String iv = keyMap.get("iv");
System.out.println("密钥: " + key);
System.out.println("IV: " + iv);
System.out.println();
// 加密测试
String originalText = "Hello AES";
System.out.println("原始数据: " + originalText);
String encrypted = encrypt(originalText, key, iv);
System.out.println("加密结果: " + encrypted);
System.out.println();
// 解密测试
String decrypted = decrypt(encrypted, key, iv);
System.out.println("解密结果: " + decrypted);
System.out.println();
if (originalText.equals(decrypted)) {
System.out.println("✅ 加密解密成功!");
} else {
System.err.println("❌ 加密解密失败!");
}
}
}
package top.sunrains;
import cn.hutool.core.text.CharSequenceUtil;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.crypto.macs.HMac;
import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.Security;
/**
* SM3国密哈希算法工具类
* 使用Bouncy Castle原生API
* 功能:
* - 字符串哈希计算
* - 字节数组哈希计算
* - 文件哈希计算
* - HMAC-SM3计算
*/
public class SM3Util {
static {
try {
Security.addProvider(new BouncyCastleProvider());
} catch (Exception e) {
e.printStackTrace();
}
}
private static final int BUFFER_SIZE = 8192;
/**
* 计算字符串的SM3哈希值
*
* @param data 待哈希的字符串
* @return 哈希值(小写十六进制字符串,64个字符)
*/
public static String hash(String data) {
if (data == null) {
throw new IllegalArgumentException("Data cannot be null");
}
return hash(data.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/**
* 验证字符串的SM3哈希值
*
* @param data 原始数据
* @param expectedHash 期望的哈希值
* @return 是否匹配
*/
public static boolean verify(String data, String expectedHash) {
String actualHash = hash(data);
return actualHash.equalsIgnoreCase(expectedHash);
}
/**
* 计算字节数组的SM3哈希值
*
* @param data 待哈希的字节数组
* @return 哈希值(小写十六进制字符串,64个字符)
*/
public static String hash(byte[] data) {
SM3Digest digest = new SM3Digest();
digest.update(data, 0, data.length);
byte[] result = new byte[digest.getDigestSize()];
digest.doFinal(result, 0);
return byteArrayToHex(result);
}
/**
* 计算文件的SM3哈希值(支持大文件)
*
* @param filePath 文件路径
* @return 哈希值(小写十六进制字符串,64个字符)
* @throws IOException 文件读取异常
*/
public static String hashFile(String filePath) throws IOException {
return hashFile(new File(filePath));
}
/**
* 计算文件的SM3哈希值(支持大文件)
*
* @param file 文件对象
* @return 哈希值(小写十六进制字符串,64个字符)
* @throws IOException 文件读取异常
*/
public static String hashFile(File file) throws IOException {
if (!file.exists()) {
throw new IOException("File not found: " + file.getAbsolutePath());
}
SM3Digest digest = new SM3Digest();
byte[] buffer = new byte[BUFFER_SIZE];
try (InputStream is = new FileInputStream(file)) {
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
}
byte[] result = new byte[digest.getDigestSize()];
digest.doFinal(result, 0);
return byteArrayToHex(result);
}
/**
* 计算HMAC-SM3(基于密钥的哈希消息认证码)
*
* @param data 待计算的数据
* @param hexKey 密钥
* @return HMAC-SM3结果(小写十六进制字符串,64个字符)
*/
public static String hmac(String data, String hexKey) {
if (CharSequenceUtil.isBlank(hexKey)) {
throw new IllegalArgumentException("密钥不能为空");
}
return hmac(data.getBytes(java.nio.charset.StandardCharsets.UTF_8),
hexToByteArray(hexKey));
}
/**
* 计算HMAC-SM3(基于密钥的哈希消息认证码)
*
* @param data 待计算的字节数组
* @param hexKey hex密钥字节数组
* @return HMAC-SM3结果(小写十六进制字符串,64个字符)
*/
public static String hmac(byte[] data, byte[] hexKey) {
HMac hmac = new HMac(new SM3Digest());
hmac.init(new KeyParameter(hexKey));
hmac.update(data, 0, data.length);
byte[] result = new byte[hmac.getMacSize()];
hmac.doFinal(result, 0);
return byteArrayToHex(result);
}
/**
* byte[] 转 Hex
*
* @param bytes 字节数组
* @return HEX字符串(小写)
*/
private static String byteArrayToHex(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(b & 0xFF);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
return sb.toString().toLowerCase();
}
/**
* 测试方法
*/
public static void main(String[] args) throws IOException {
String testData = "12";
System.out.println("SM3: " + hash(testData));
String key = "1";
key = stringToHex(key);
System.out.println("HMAC-SM3 key: " + key);
String hmacResult = hmac(testData, key);
System.out.println("HMAC-SM3: " + hmacResult);
System.out.println("HMAC长度: " + hmacResult.length() + " 字符");
System.out.println("文件哈希值: " + hashFile("/Users/sun/1/t.jpg"));
}
public static String stringToHex(String str) {
if (str == null || str.isEmpty()) {
return "";
}
byte[] bytes = str.getBytes(java.nio.charset.StandardCharsets.UTF_8);
return byteArrayToHex(bytes);
}
/**
* HEX 转 byte[]
*
* @param hex HEX字符串
* @return 字节数组
*/
public static byte[] hexToByteArray(String hex) {
if (hex == null || hex.length() == 0) {
return new byte[0];
}
int len = hex.length() / 2;
byte[] bytes = new byte[len];
for (int i = 0; i < len; i++) {
int high = Character.digit(hex.charAt(i * 2), 16);
int low = Character.digit(hex.charAt(i * 2 + 1), 16);
bytes[i] = (byte) ((high << 4) | low);
}
return bytes;
}
}
各平台实现说明
Android 平台
SM2 实现:
-
依赖库:BouncyCastle (
org.bouncycastle:bcprov-jdk15on:1.70) -
特点:
- 使用
ECKeyPairGenerator生成密钥对 - 使用
SM2Signer进行签名和验签(DER 格式) - 使用
SM2Engine进行加密和解密(C1C3C2 格式) - 不依赖 JCA Provider,避免兼容性问题
- 使用
SM4 实现:
-
依赖库:BouncyCastle
-
特点:
- 使用
SM4Engine进行加密和解密 - CBC 模式 + PKCS7 填充
- 支持自定义密钥和 IV
- 使用
iOS 平台
实现方式:GMObjC 原生库
SM2 特点:
- 使用
GMSm2Utils进行所有操作 - 签名自动转换为 DER 格式以兼容其他平台
- 加密输出 C1C3C2 格式(04 开头)
- 支持跨平台互通(Android/HarmonyOS)
SM4 特点:
- 使用
GMSm4Utils进行加密解密 - CBC 模式 + PKCS7 填充
- 密钥和 IV 为 32 位十六进制字符串
HarmonyOS 平台
实现方式: @ohos.security.cryptoFramework
SM2 特点:
- 使用 HarmonyOS 原生 cryptoFramework API
- 签名使用 DER 格式
- 加密使用 C1C3C2 格式
- 完全符合国密标准
SM3 特点:
- 使用
createMd(MessageDigest) 进行普通哈希 - 使用手动实现 HMAC-SM3 (RFC 2104)
- 支持流式文件哈希处理 (4096 字节缓冲区)
- 不依赖
createMac,避免密钥参数问题
SM4 特点:
- 使用
createSymKeyGenerator生成密钥 - 使用
createCipher进行加密解密 - CBC 模式 + PKCS7 填充
- 支持 SM4_128 算法
AES 特点:
- 使用
crypto.createSymKeyGenerator('AES256')创建密钥生成器 - 使用
crypto.createCipher('AES|CBC|PKCS7')进行加解密 - CBC 模式 + PKCS7 填充
- 密钥和 IV 为随机字符串(UTF-8 编码)
H5 平台
实现方式:sm-crypto + Web Crypto API
特点:
- SM2/SM3/SM4:通过 npm 包
sm-crypto实现 - AES:使用浏览器原生 Web Crypto API 实现
- 所有功能均在浏览器环境中运行
- SM2 签名使用 DER 格式(
der: true) - SM2 默认 userId 为
'1234567812345678'
依赖安装:
npm install --save sm-crypto
微信小程序平台
实现方式:miniprogram-sm-crypto + crypto-js + jsrsasign
特点:
- 专为小程序优化的国密算法库
- 完全支持 SM2、SM4 和 AES
- SM2:密钥生成、签名、验签、加密、解密(使用 miniprogram-sm-crypto)
- SM4:密钥生成、加密、解密(CBC 模式,使用 miniprogram-sm-crypto)
- AES:密钥生成、加密、解密(CBC 模式,使用 crypto-js)
- 需要通过微信开发者工具构建 npm
依赖安装:
npm install --save miniprogram-sm-crypto crypto-js jsrsasign
重要:微信小程序 npm 构建步骤
- 确保项目根目录已安装上述依赖
- 打开微信开发者工具,勾选「详情」→「本地设置」→「使用 npm 模块」
- 点击菜单栏「工具」→「构建 npm」,生成
miniprogram_npm目录 - 重新编译运行小程序
注意事项
1. 密钥格式
SM2 密钥:
- 公钥:以
04开头的 130 位十六进制字符串(非压缩格式) - 私钥:64 位十六进制字符串
SM4 密钥:
- 密钥 (key) :32 位十六进制字符串(128 位)
- 初始向量 (IV) :32 位十六进制字符串(128 位)
AES 密钥:
- 密钥 (key) :32 字符随机字符串(字母+数字,UTF-8 编码后为 256 位)
- 初始向量 (IV) :16 字符随机字符串(字母+数字,UTF-8 编码后为 128 位)
2. 签名格式
- 所有平台的签名输出均为 DER 编码的十六进制字符串
- DER 格式确保跨平台兼容性
3. 哈希格式
SM3 哈希:
- 输出长度:64 位十六进制字符串 (256 位)
- 格式:小写十六进制
- 示例:
a1b2c3d4e5f6...(64 个字符)
HMAC-SM3 哈希:
- 输出长度:64 位十六进制字符串 (256 位)
- 密钥格式:十六进制字符串
- 格式:小写十六进制
4. 加密格式
SM2 加密:
- 输出格式:C1C3C2(以
04开头) - 兼容 Android BouncyCastle、iOS GMObjC、HarmonyOS cryptoFramework
SM4 加密:
- 模式:CBC
- 填充:PKCS7
- 输出:十六进制字符串
AES 加密:
- 模式:CBC
- 填充:PKCS5/PKCS7
- 密钥:32 字符随机字符串(UTF-8 编码后为 256 位)
- IV:16 字符随机字符串(UTF-8 编码后为 128 位)
- 输出:Base64 编码字符串
RSA 加密:
- 算法:RSA-OAEP with SHA-256 (对应 Java 的
RSA/ECB/OAEPWithSHA-256AndMGF1Padding) - 密钥长度:2048 位
- 私钥格式:PKCS#8 (Base64 编码)
- 公钥格式:X.509/SPKI (Base64 编码)
- 输出:Base64 编码字符串
- 跨平台兼容:Android/iOS/HarmonyOS/H5 完全互通
RSA 签名:
- 算法:SHA256withRSA (PKCS#1 v1.5)
- 私钥格式:PKCS#8 (Base64 编码)
- 公钥格式:X.509/SPKI (Base64 编码)
- 签名输出:Base64 编码字符串
- 跨平台兼容:Android/iOS/HarmonyOS/H5/微信小程序 完全互通
5. 跨平台兼容性
✅ 完全兼容:
- 各平台生成的 SM2 密钥对可以互相使用
- Android 平台签名的数据可以在 iOS/Harmony/H5 平台验证
- SM2 加密的数据可以在不同平台间解密
- SM4 加密的数据可以在 Android/iOS/Harmony 平台间加解密
- AES 加密的数据可以在所有平台间加解密(Android/iOS/HarmonyOS/H5/微信小程序)
- SM3 哈希值在所有平台保持一致(相同输入产生相同输出)
- HMAC-SM3 支持跨平台验证
- RSA-OAEP-SHA256 加密的数据可以在 Android/iOS/HarmonyOS/H5 平台间加解密
- SHA256withRSA 签名可以在所有平台间验签(包括微信小程序)
⚠️ 注意事项:
- 确保使用相同的哈希选项(默认启用)
- SM4/AES 加密和解密必须使用相同的密钥和 IV
- 微信小程序需要先构建 npm 才能使用
- SM3 文件哈希需要确保文件内容完全一致
- AES 密钥为 32 字符随机字符串,IV 为 16 字符随机字符串
- RSA 密钥长度为 2048 位,私钥为 PKCS#8 格式,公钥为 X.509/SPKI 格式
- RSA 加密使用 OAEP with SHA-256 填充,与其他平台互通
- RSA 签名使用 SHA256withRSA (PKCS#1 v1.5),所有平台兼容
- 微信小程序端暂不支持 RSA-OAEP-SHA256 加密解密,仅支持签名验签
终于解决了 uniapp X 的表情输入框的问题
准备写一个类似微信的输入框发现uniappx 使用 input 没法输入表情图片
使用富文本编辑框又出现了多一个 \n 换行,不适合聊天输入框
还好现在解决了,用AI写了个可以插入图片的输入框
准备写一个类似微信的输入框发现uniappx 使用 input 没法输入表情图片
使用富文本编辑框又出现了多一个 \n 换行,不适合聊天输入框
还好现在解决了,用AI写了个可以插入图片的输入框
















