@@ -1,7 +1,31 @@
<script>
+ const uat_URL = 'http://md.test.zyucgj.com/saas/clz/' // 预发布
+ // const uat_URL = 'http://192.168.16.103:9110/saas/clz/' // 本地
+ const pro_URL = 'https://car.zyucgj.com/saas/clz/' // 生产
+ const buildType = 0 // 打包环境对应类型,1 生产 0 预发布
+ const buildURL = buildType ? pro_URL : uat_URL // 打包后实际对应的url
+ const envText = ['预发布环境','生产环境']
+ const envTips = process.env.NODE_ENV == 'development' ? envText[0] : envText[buildType]
export default {
+ globalData: {
+ baseUrl: process.env.NODE_ENV == 'development' ? uat_URL : buildURL,
+ token: '',
+ version: '', // 当前版本号
+ buildType: buildType,
+ envTips: envTips
+ },
+ mounted() {
+ // 非V3模式
+ if(this.$scope){
+ this.$scope.globalData.token = this.$store.state.vuex_token
+ }else{
+ // V3模式
+ getApp({allowDefault: true}).globalData.token = this.$store.state.vuex_token
+ }
onLaunch: function() {
- console.log('App Launch')
+ // 重置tab到首页
+ uni.setStorageSync('setStatusIndexFunc', 0)
},
onShow: function() {
console.log('App Show')
@@ -12,6 +36,12 @@
}
</script>
-<style>
- /*每个页面公共css */
+<style lang="scss">
+ /*uview css */
+ @import "uview-ui/index.scss";
+ /* 登录页是否记住密码多选框样式 */
+ uni-checkbox.login-form-checkbox .uni-checkbox-input{
+ width: 24upx !important;
+ height: 24upx !important;
</style>
@@ -1,11 +1,46 @@
import Vue from 'vue'
import App from './App'
+import store from '@/store'
Vue.config.productionTip = false
-
App.mpType = 'app'
+import uView from "uview-ui"
+Vue.use(uView)
+
+let vuexStore = require("@/store/$u.mixin.js")
+Vue.mixin(vuexStore)
+/**
+ * 权限检查
+ */
+Vue.prototype.$hasPermissions = function (value) {
+ let isExist = false;
+ let permissionsStr = store.state.vuex_userData.permCodes;
+ // console.log(permissionsStr,'permissionsStr')
+ if (permissionsStr == undefined || permissionsStr == null) {
+ return false;
+ let has = permissionsStr.find(item => {
+ return item == value
+ })
+ if (has) {
+ isExist = true;
+ return isExist;
+}
+/*
+* 提示信息
+*/
+Vue.prototype.toashMsg = function (title) {
+ title = title == undefined ? "系统繁忙" : title;
+ uni.showToast({
+ title:title,
+ icon:'none'
const app = new Vue({
+ store,
...App
})
app.$mount()
@@ -0,0 +1,21 @@
+MIT License
+Copyright (c) 2020 www.uviewui.com
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
@@ -0,0 +1,102 @@
+<p align="center">
+ <img alt="logo" src="https://uviewui.com/common/logo.png" width="120" height="120" style="margin-bottom: 10px;">
+</p>
+<h3 align="center" style="margin: 30px 0 30px;font-weight: bold;font-size:40px;">uView</h3>
+<h3 align="center">多平台快速开发的UI框架</h3>
+## 说明
+uView UI,是[uni-app](https://uniapp.dcloud.io/)生态优秀的UI框架,全面的组件和便捷的工具会让您信手拈来,如鱼得水
+## 特性
+- 兼容安卓,iOS,微信小程序,H5,QQ小程序,百度小程序,支付宝小程序,头条小程序
+- 60+精选组件,功能丰富,多端兼容,让您快速集成,开箱即用
+- 众多贴心的JS利器,让您飞镖在手,召之即来,百步穿杨
+- 众多的常用页面和布局,让您专注逻辑,事半功倍
+- 详尽的文档支持,现代化的演示效果
+- 按需引入,精简打包体积
+## 安装
+```bash
+# npm方式安装
+npm i uview-ui
+```
+## 快速上手
+1. `main.js`引入uView库
+```js
+// main.js
+import uView from 'uview-ui';
+Vue.use(uView);
+2. `App.vue`引入基础样式(注意style标签需声明scss属性支持)
+```css
+/* App.vue */
+@import "uview-ui/index.scss";
+</style>
+3. `uni.scss`引入全局scss变量文件
+/* uni.scss */
+@import "uview-ui/theme.scss";
+4. `pages.json`配置easycom规则(按需引入)
+// pages.json
+{
+ "easycom": {
+ "^u-(.*)": "uview-ui/components/u-$1/u-$1.vue"
+ // 此为本身已有的内容
+ "pages": [
+ // ......
+ ]
+请通过[快速上手](https://uviewui.com/components/quickstart.html)了解更详细的内容
+## 使用方法
+配置easycom规则后,自动按需引入,无需`import`组件,直接引用即可。
+```html
+<template>
+ <u-button>按钮</u-button>
+</template>
+## 链接
+- [官方文档](https://uviewui.com/)
+- [更新日志](https://uviewui.com/components/changelog.html)
+- [升级指南](https://uviewui.com/components/changelog.html)
+- [关于我们](https://uviewui.com/cooperation/about.html)
+## 预览
+您可以通过**微信**扫码,查看最佳的演示效果。
+<br>
+<img src="https://uviewui.com/common/weixin_mini_qrcode.png" width="220" height="220" >
+<!-- ## 捐赠uView的研发
+uView文档和源码全部开源免费,如果您认为uView帮到了您的开发工作,您可以捐赠uView的研发工作,捐赠无门槛,哪怕是一杯可乐也好(相信这比打赏主播更有意义)。
+<img src="https://uviewui.com/common/wechat.png" width="220" >
+<img style="margin-left: 100px;" src="https://uviewui.com/common/alipay.png" width="220" >
+ -->
+## 版权信息
+uView遵循[MIT](https://en.wikipedia.org/wiki/MIT_License)开源协议,意味着您无需支付任何费用,也无需授权,即可将uView应用到您的产品中。
@@ -0,0 +1,182 @@
+ <u-popup mode="bottom" :border-radius="borderRadius" :popup="false" v-model="value" :maskCloseAble="maskCloseAble"
+ length="auto" :safeAreaInsetBottom="safeAreaInsetBottom" @close="popupClose" :z-index="uZIndex">
+ <view class="u-tips u-border-bottom" v-if="tips.text" :style="[tipsStyle]">
+ {{tips.text}}
+ </view>
+ <block v-for="(item, index) in list" :key="index">
+ <view
+ @touchmove.stop.prevent
+ @tap="itemClick(index)"
+ :style="[itemStyle(index)]"
+ class="u-action-sheet-item"
+ :class="[index < list.length - 1 ? 'u-border-bottom' : '']"
+ :hover-stay-time="150"
+ >
+ {{item.text}}
+ </block>
+ <view class="u-gab" v-if="cancelBtn">
+ <view @touchmove.stop.prevent class="u-actionsheet-cancel u-action-sheet-item" hover-class="u-hover-class"
+ :hover-stay-time="150" v-if="cancelBtn" @tap="close">{{cancelText}}</view>
+ </u-popup>
+<script>
+ /**
+ * actionSheet 操作菜单
+ * @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
+ * @tutorial https://www.uviewui.com/components/actionSheet.html
+ * @property {Array<Object>} list 按钮的文字数组,见官方文档示例
+ * @property {Object} tips 顶部的提示文字,见官方文档示例
+ * @property {String} cancel-text 取消按钮的提示文字
+ * @property {Boolean} cancel-btn 是否显示底部的取消按钮(默认true)
+ * @property {Number String} border-radius 弹出部分顶部左右的圆角值,单位rpx(默认0)
+ * @property {Boolean} mask-close-able 点击遮罩是否可以关闭(默认true)
+ * @property {Boolean} safe-area-inset-bottom 是否开启底部安全区适配(默认false)
+ * @property {Number String} z-index z-index值(默认1075)
+ * @event {Function} click 点击ActionSheet列表项时触发
+ * @event {Function} close 点击取消按钮时触发
+ * @example <u-action-sheet :list="list" @click="click" v-model="show"></u-action-sheet>
+ export default {
+ name: "u-action-sheet",
+ props: {
+ // 点击遮罩是否可以关闭actionsheet
+ maskCloseAble: {
+ type: Boolean,
+ default: true
+ // 按钮的文字数组,可以自定义颜色和字体大小,字体单位为rpx
+ list: {
+ type: Array,
+ default () {
+ // 如下
+ // return [{
+ // text: '确定',
+ // color: '',
+ // fontSize: ''
+ // }]
+ return [];
+ // 顶部的提示文字
+ tips: {
+ type: Object,
+ return {
+ text: '',
+ color: '',
+ fontSize: '26'
+ // 底部的取消按钮
+ cancelBtn: {
+ // 是否开启底部安全区适配,开启的话,会在iPhoneX机型底部添加一定的内边距
+ safeAreaInsetBottom: {
+ default: false
+ // 通过双向绑定控制组件的弹出与收起
+ value: {
+ // 弹出的顶部圆角值
+ borderRadius: {
+ type: [String, Number],
+ default: 0
+ // 弹出的z-index值
+ zIndex: {
+ // 取消按钮的文字提示
+ cancelText: {
+ type: String,
+ default: '取消'
+ computed: {
+ // 顶部提示的样式
+ tipsStyle() {
+ let style = {};
+ if (this.tips.color) style.color = this.tips.color;
+ if (this.tips.fontSize) style.fontSize = this.tips.fontSize + 'rpx';
+ return style;
+ // 操作项目的样式
+ itemStyle() {
+ return (index) => {
+ if (this.list[index].color) style.color = this.list[index].color;
+ if (this.list[index].fontSize) style.fontSize = this.list[index].fontSize + 'rpx';
+ // 选项被禁用的样式
+ if (this.list[index].disabled) style.color = '#c0c4cc';
+ uZIndex() {
+ // 如果用户有传递z-index值,优先使用
+ return this.zIndex ? this.zIndex : this.$u.zIndex.popup;
+ methods: {
+ // 点击取消按钮
+ close() {
+ // 发送input事件,并不会作用于父组件,而是要设置组件内部通过props传递的value参数
+ // 这是一个vue发送事件的特殊用法
+ this.popupClose();
+ this.$emit('close');
+ // 弹窗关闭
+ popupClose() {
+ this.$emit('input', false);
+ // 点击某一个item
+ itemClick(index) {
+ // disabled的项禁止点击
+ if(this.list[index].disabled) return;
+ this.$emit('click', index);
+</script>
+<style lang="scss" scoped>
+ @import "../../libs/css/style.components.scss";
+ .u-tips {
+ font-size: 26rpx;
+ text-align: center;
+ padding: 34rpx 0;
+ line-height: 1;
+ color: $u-tips-color;
+ .u-action-sheet-item {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 34rpx;
+ .u-gab {
+ height: 12rpx;
+ background-color: rgb(234, 234, 236);
+ .u-actionsheet-cancel {
+ color: $u-main-color;
@@ -0,0 +1,256 @@
+ <view class="u-alert-tips" v-if="show" :class="[
+ !show ? 'u-close-alert-tips': '',
+ type ? 'u-alert-tips--bg--' + type + '-light' : '',
+ type ? 'u-alert-tips--border--' + type + '-disabled' : '',
+ ]" :style="{
+ backgroundColor: bgColor,
+ borderColor: borderColor
+ }">
+ <view class="u-icon-wrap">
+ <u-icon v-if="showIcon" :name="uIcon" :size="description ? 40 : 32" class="u-icon" :color="uIconType" :custom-style="iconStyle"></u-icon>
+ <view class="u-alert-content" @tap.stop="click">
+ <view class="u-alert-title" :style="[uTitleStyle]">
+ {{title}}
+ <view v-if="description" class="u-alert-desc" :style="[descStyle]">
+ {{description}}
+ <u-icon @click="close" v-if="closeAble && !closeText" hoverClass="u-type-error-hover-color" name="close" color="#c0c4cc"
+ :size="22" class="u-close-icon" :style="{
+ top: description ? '18rpx' : '24rpx'
+ }"></u-icon>
+ <text v-if="closeAble && closeText" class="u-close-text" :style="{
+ }">{{closeText}}</text>
+ * alertTips 警告提示
+ * @description 警告提示,展现需要关注的信息
+ * @tutorial https://uviewui.com/components/alertTips.html
+ * @property {String} title 显示的标题文字
+ * @property {String} description 辅助性文字,颜色比title浅一点,字号也小一点,可选
+ * @property {String} type 关闭按钮(默认为叉号icon图标)
+ * @property {String} icon 图标名称
+ * @property {Object} icon-style 图标的样式,对象形式
+ * @property {Object} title-style 标题的样式,对象形式
+ * @property {Object} desc-style 描述的样式,对象形式
+ * @property {String} close-able 用文字替代关闭图标,close-able为true时有效
+ * @property {Boolean} show-icon 是否显示左边的辅助图标
+ * @property {Boolean} show 显示或隐藏组件
+ * @event {Function} click 点击组件时触发
+ * @event {Function} close 点击关闭按钮时触发
+ name: 'u-alert-tips',
+ // 显示文字
+ title: {
+ default: ''
+ // 主题,success/warning/info/error
+ type: {
+ default: 'warning'
+ // 辅助性文字
+ description: {
+ // 是否可关闭
+ closeAble: {
+ // 关闭按钮自定义文本
+ closeText: {
+ // 是否显示图标
+ showIcon: {
+ // 文字颜色,如果定义了color值,icon会失效
+ color: {
+ // 背景颜色
+ bgColor: {
+ // 边框颜色
+ borderColor: {
+ // 是否显示
+ show: {
+ // 左边显示的icon
+ icon: {
+ // icon的样式
+ iconStyle: {
+ default() {
+ return {}
+ // 标题的样式
+ titleStyle: {
+ // 描述文字的样式
+ descStyle: {
+ data() {
+ uTitleStyle() {
+ // 如果有描述文字的话,标题进行加粗
+ style.fontWeight = this.description ? 500 : 'normal';
+ // 将用户传入样式对象和style合并,传入的优先级比style高,同属性会被覆盖
+ return this.$u.deepMerge(style, this.titleStyle);
+ uIcon() {
+ // 如果有设置icon名称就使用,否则根据type主题,推定一个默认的图标
+ return this.icon ? this.icon : this.$u.type2icon(this.type);
+ uIconType() {
+ // 如果有设置图标的样式,优先使用,没有的话,则用type的样式
+ return Object.keys(this.iconStyle).length ? '' : this.type;
+ // 点击内容
+ click() {
+ this.$emit('click');
+ // 点击关闭按钮
+ .u-alert-tips {
+ padding: 16rpx 30rpx;
+ border-radius: 8rpx;
+ position: relative;
+ transition: all 0.3s linear;
+ border: 1px solid #fff;
+ &--bg--primary-light {
+ background-color: $u-type-primary-light;
+ &--bg--info-light {
+ background-color: $u-type-info-light;
+ &--bg--success-light {
+ background-color: $u-type-success-light;
+ &--bg--warning-light {
+ background-color: $u-type-warning-light;
+ &--bg--error-light {
+ background-color: $u-type-error-light;
+ &--border--primary-disabled {
+ border-color: $u-type-primary-disabled;
+ &--border--success-disabled {
+ border-color: $u-type-success-disabled;
+ &--border--error-disabled {
+ border-color: $u-type-error-disabled;
+ &--border--warning-disabled {
+ border-color: $u-type-warning-disabled;
+ &--border--info-disabled {
+ border-color: $u-type-info-disabled;
+ .u-close-alert-tips {
+ opacity: 0;
+ visibility: hidden;
+ .u-icon {
+ margin-right: 16rpx;
+ .u-alert-title {
+ font-size: 28rpx;
+ .u-alert-desc {
+ text-align: left;
+ color: $u-content-color;
+ .u-close-icon {
+ position: absolute;
+ top: 20rpx;
+ right: 20rpx;
+ .u-close-hover {
+ color: red;
+ .u-close-text {
+ font-size: 24rpx;
@@ -0,0 +1,290 @@
+ <view class="content">
+ <view class="cropper-wrapper" :style="{ height: cropperOpt.height + 'px' }">
+ <canvas
+ class="cropper"
+ :disable-scroll="true"
+ @touchstart="touchStart"
+ @touchmove="touchMove"
+ @touchend="touchEnd"
+ :style="{ width: cropperOpt.width, height: cropperOpt.height, backgroundColor: 'rgba(0, 0, 0, 0.8)' }"
+ canvas-id="cropper"
+ id="cropper"
+ ></canvas>
+ :style="{
+ position: 'fixed',
+ top: `-${cropperOpt.width * cropperOpt.pixelRatio}px`,
+ left: `-${cropperOpt.height * cropperOpt.pixelRatio}px`,
+ width: `${cropperOpt.width * cropperOpt.pixelRatio}px`,
+ height: `${cropperOpt.height * cropperOpt.pixelRatio}`
+ }"
+ canvas-id="targetId"
+ id="targetId"
+ <view class="cropper-buttons safe-area-padding" :style="{ height: bottomNavHeight + 'px' }">
+ <!-- #ifdef H5 -->
+ <view class="upload" @tap="uploadTap">选择图片</view>
+ <!-- #endif -->
+ <!-- #ifndef H5 -->
+ <view class="upload" @tap="uploadTap">重新选择</view>
+ <view class="getCropperImage" @tap="getCropperImage(false)">确定</view>
+import WeCropper from './weCropper.js';
+export default {
+ // 裁剪矩形框的样式,其中可包含的属性为lineWidth-边框宽度(单位rpx),color: 边框颜色,
+ // mask-遮罩颜色,一般设置为一个rgba的透明度,如"rgba(0, 0, 0, 0.35)"
+ boundStyle: {
+ lineWidth: 4,
+ borderColor: 'rgb(245, 245, 245)',
+ mask: 'rgba(0, 0, 0, 0.35)'
+ };
+ // // 裁剪框宽度,单位rpx
+ // rectWidth: {
+ // type: [String, Number],
+ // default: 400
+ // },
+ // // 裁剪框高度,单位rpx
+ // rectHeight: {
+ // // 输出图片宽度,单位rpx
+ // destWidth: {
+ // // 输出图片高度,单位rpx
+ // destHeight: {
+ // // 输出的图片类型,如果发现裁剪的图片很大,可能是因为设置为了"png",改成"jpg"即可
+ // fileType: {
+ // type: String,
+ // default: 'jpg',
+ // // 生成的图片质量
+ // // H5上无效,目前不考虑使用此参数
+ // quality: {
+ // type: [Number, String],
+ // default: 1
+ // }
+ // 底部导航的高度
+ bottomNavHeight: 50,
+ originWidth: 200,
+ width: 0,
+ height: 0,
+ cropperOpt: {
+ id: 'cropper',
+ targetId: 'targetCropper',
+ pixelRatio: 1,
+ scale: 2.5,
+ zoom: 8,
+ cut: {
+ x: (this.width - this.originWidth) / 2,
+ y: (this.height - this.originWidth) / 2,
+ width: this.originWidth,
+ height: this.originWidth
+ lineWidth: uni.upx2px(this.boundStyle.lineWidth),
+ mask: this.boundStyle.mask,
+ color: this.boundStyle.borderColor
+ // 裁剪框和输出图片的尺寸,高度默认等于宽度
+ // 输出图片宽度,单位px
+ destWidth: 200,
+ // 裁剪框宽度,单位px
+ rectWidth: 200,
+ // 输出的图片类型,如果'png'类型发现裁剪的图片太大,改成"jpg"即可
+ fileType: 'jpg',
+ src: '', // 选择的图片路径,用于在点击确定时,判断是否选择了图片
+ onLoad(option) {
+ let rectInfo = uni.getSystemInfoSync();
+ this.width = rectInfo.windowWidth;
+ this.height = rectInfo.windowHeight - this.bottomNavHeight;
+ this.cropperOpt.width = this.width;
+ this.cropperOpt.height = this.height;
+ this.cropperOpt.pixelRatio = rectInfo.pixelRatio;
+ if (option.destWidth) this.destWidth = option.destWidth;
+ if (option.rectWidth) {
+ let rectWidth = Number(option.rectWidth);
+ this.cropperOpt.cut = {
+ x: (this.width - rectWidth) / 2,
+ y: (this.height - rectWidth) / 2,
+ width: rectWidth,
+ height: rectWidth
+ this.rectWidth = option.rectWidth;
+ if (option.fileType) this.fileType = option.fileType;
+ // 初始化
+ this.cropper = new WeCropper(this.cropperOpt)
+ .on('ready', ctx => {
+ // wecropper is ready for work!
+ .on('beforeImageLoad', ctx => {
+ // before picture loaded, i can do something
+ .on('imageLoad', ctx => {
+ // picture loaded
+ .on('beforeDraw', (ctx, instance) => {
+ // before canvas draw,i can do something
+ });
+ // 设置导航栏样式,以免用户在page.json中没有设置为黑色背景
+ uni.setNavigationBarColor({
+ frontColor: '#ffffff',
+ backgroundColor: '#000000'
+ uni.chooseImage({
+ count: 1, // 默认9
+ sizeType: ['compressed'], // 可以指定是原图还是压缩图,默认二者都有
+ sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
+ success: res => {
+ this.src = res.tempFilePaths[0];
+ // 获取裁剪图片资源后,给data添加src属性及其值
+ this.cropper.pushOrign(this.src);
+ touchStart(e) {
+ this.cropper.touchStart(e);
+ touchMove(e) {
+ this.cropper.touchMove(e);
+ touchEnd(e) {
+ this.cropper.touchEnd(e);
+ getCropperImage(isPre = false) {
+ if(!this.src) return this.$u.toast('请先选择图片再裁剪');
+ let cropper_opt = {
+ destHeight: Number(this.destWidth), // uni.canvasToTempFilePath要求这些参数为数值
+ destWidth: Number(this.destWidth),
+ fileType: this.fileType
+ this.cropper.getCropperImage(cropper_opt, (path, err) => {
+ if (err) {
+ uni.showModal({
+ title: '温馨提示',
+ content: err.message
+ } else {
+ if (isPre) {
+ uni.previewImage({
+ current: '', // 当前显示图片的 http 链接
+ urls: [path] // 需要预览的图片 http 链接列表
+ uni.$emit('uAvatarCropper', path);
+ this.$u.route({
+ type: 'back'
+ uploadTap() {
+ const self = this;
+ sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图,默认二者都有
+ success: (res) => {
+ self.src = res.tempFilePaths[0];
+ self.cropper.pushOrign(this.src);
+};
+<style scoped>
+@import '../../libs/css/style.components.scss';
+.content {
+ background: rgba(255, 255, 255, 1);
+.cropper {
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 11;
+.cropper-buttons {
+ background-color: #000000;
+ color: #eee;
+.cropper-wrapper {
+ flex-direction: row;
+ justify-content: space-between;
+ background-color: #000;
+ width: 100vw;
+ position: fixed;
+ bottom: 0;
+.cropper-buttons .upload,
+.cropper-buttons .getCropperImage {
+ width: 50%;
+.cropper-buttons .upload {
+ padding-left: 50rpx;
+ text-align: right;
+ padding-right: 50rpx;
@@ -0,0 +1,1265 @@
+ * we-cropper v1.3.9
+ * (c) 2020 dlhandsome
+ * @license MIT
+(function(global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
+ typeof define === 'function' && define.amd ? define(factory) :
+ (global.WeCropper = factory());
+}(this, (function() {
+ 'use strict';
+ var device = void 0;
+ var TOUCH_STATE = ['touchstarted', 'touchmoved', 'touchended'];
+ function firstLetterUpper(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1)
+ function setTouchState(instance) {
+ var arg = [],
+ len = arguments.length - 1;
+ while (len-- > 0) arg[len] = arguments[len + 1];
+ TOUCH_STATE.forEach(function(key, i) {
+ if (arg[i] !== undefined) {
+ instance[key] = arg[i];
+ function validator(instance, o) {
+ Object.defineProperties(instance, o);
+ function getDevice() {
+ if (!device) {
+ device = uni.getSystemInfoSync();
+ return device
+ var tmp = {};
+ var ref = getDevice();
+ var pixelRatio = ref.pixelRatio;
+ var DEFAULT = {
+ id: {
+ default: 'cropper',
+ get: function get() {
+ return tmp.id
+ set: function set(value) {
+ if (typeof(value) !== 'string') {
+ console.error(("id:" + value + " is invalid"));
+ tmp.id = value;
+ width: {
+ default: 750,
+ return tmp.width
+ if (typeof(value) !== 'number') {
+ console.error(("width:" + value + " is invalid"));
+ tmp.width = value;
+ height: {
+ return tmp.height
+ console.error(("height:" + value + " is invalid"));
+ tmp.height = value;
+ pixelRatio: {
+ default: pixelRatio,
+ return tmp.pixelRatio
+ console.error(("pixelRatio:" + value + " is invalid"));
+ tmp.pixelRatio = value;
+ scale: {
+ default: 2.5,
+ return tmp.scale
+ console.error(("scale:" + value + " is invalid"));
+ tmp.scale = value;
+ zoom: {
+ default: 5,
+ return tmp.zoom
+ console.error(("zoom:" + value + " is invalid"));
+ } else if (value < 0 || value > 10) {
+ console.error("zoom should be ranged in 0 ~ 10");
+ tmp.zoom = value;
+ src: {
+ default: '',
+ return tmp.src
+ console.error(("src:" + value + " is invalid"));
+ tmp.src = value;
+ default: {},
+ return tmp.cut
+ if (typeof(value) !== 'object') {
+ console.error(("cut:" + value + " is invalid"));
+ tmp.cut = value;
+ return tmp.boundStyle
+ console.error(("boundStyle:" + value + " is invalid"));
+ tmp.boundStyle = value;
+ onReady: {
+ default: null,
+ return tmp.ready
+ tmp.ready = value;
+ onBeforeImageLoad: {
+ return tmp.beforeImageLoad
+ tmp.beforeImageLoad = value;
+ onImageLoad: {
+ return tmp.imageLoad
+ tmp.imageLoad = value;
+ onBeforeDraw: {
+ return tmp.beforeDraw
+ tmp.beforeDraw = value;
+ var ref$1 = getDevice();
+ var windowWidth = ref$1.windowWidth;
+ function prepare() {
+ var self = this;
+ // v1.4.0 版本中将不再自动绑定we-cropper实例
+ self.attachPage = function() {
+ var pages = getCurrentPages();
+ // 获取到当前page上下文
+ var pageContext = pages[pages.length - 1];
+ // 把this依附在Page上下文的wecropper属性上,便于在page钩子函数中访问
+ Object.defineProperty(pageContext, 'wecropper', {
+ console.warn(
+ 'Instance will not be automatically bound to the page after v1.4.0\n\n' +
+ 'Please use a custom instance name instead\n\n' +
+ 'Example: \n' +
+ 'this.mycropper = new WeCropper(options)\n\n' +
+ '// ...\n' +
+ 'this.mycropper.getCropperImage()'
+ );
+ return self
+ configurable: true
+ self.createCtx = function() {
+ var id = self.id;
+ var targetId = self.targetId;
+ if (id) {
+ self.ctx = self.ctx || uni.createCanvasContext(id);
+ self.targetCtx = self.targetCtx || uni.createCanvasContext(targetId);
+ console.error("constructor: create canvas context failed, 'id' must be valuable");
+ self.deviceRadio = windowWidth / 750;
+ var commonjsGlobal = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !==
+ 'undefined' ? self : {};
+ function createCommonjsModule(fn, module) {
+ return module = {
+ exports: {}
+ }, fn(module, module.exports), module.exports;
+ var tools = createCommonjsModule(function(module, exports) {
+ * String type check
+ exports.isStr = function(v) {
+ return typeof v === 'string';
+ * Number type check
+ exports.isNum = function(v) {
+ return typeof v === 'number';
+ * Array type check
+ exports.isArr = Array.isArray;
+ * undefined type check
+ exports.isUndef = function(v) {
+ return v === undefined;
+ exports.isTrue = function(v) {
+ return v === true;
+ exports.isFalse = function(v) {
+ return v === false;
+ * Function type check
+ exports.isFunc = function(v) {
+ return typeof v === 'function';
+ * Quick object check - this is primarily used to tell
+ * Objects from primitive values when we know the value
+ * is a JSON-compliant type.
+ exports.isObj = exports.isObject = function(obj) {
+ return obj !== null && typeof obj === 'object'
+ * Strict object type check. Only returns true
+ * for plain JavaScript objects.
+ var _toString = Object.prototype.toString;
+ exports.isPlainObject = function(obj) {
+ return _toString.call(obj) === '[object Object]'
+ * Check whether the object has the property.
+ var hasOwnProperty = Object.prototype.hasOwnProperty;
+ exports.hasOwn = function(obj, key) {
+ return hasOwnProperty.call(obj, key)
+ * Perform no operation.
+ * Stubbing args to make Flow happy without leaving useless transpiled code
+ * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/)
+ exports.noop = function(a, b, c) {};
+ * Check if val is a valid array index.
+ exports.isValidArrayIndex = function(val) {
+ var n = parseFloat(String(val));
+ return n >= 0 && Math.floor(n) === n && isFinite(val)
+ var tools_7 = tools.isFunc;
+ var tools_10 = tools.isPlainObject;
+ var EVENT_TYPE = ['ready', 'beforeImageLoad', 'beforeDraw', 'imageLoad'];
+ function observer() {
+ self.on = function(event, fn) {
+ if (EVENT_TYPE.indexOf(event) > -1) {
+ if (tools_7(fn)) {
+ event === 'ready' ?
+ fn(self) :
+ self[("on" + (firstLetterUpper(event)))] = fn;
+ console.error(("event: " + event + " is invalid"));
+ function wxPromise(fn) {
+ return function(obj) {
+ var args = [],
+ while (len-- > 0) args[len] = arguments[len + 1];
+ if (obj === void 0) obj = {};
+ return new Promise(function(resolve, reject) {
+ obj.success = function(res) {
+ resolve(res);
+ obj.fail = function(err) {
+ reject(err);
+ fn.apply(void 0, [obj].concat(args));
+ function draw(ctx, reserve) {
+ if (reserve === void 0) reserve = false;
+ return new Promise(function(resolve) {
+ ctx.draw(reserve, resolve);
+ var getImageInfo = wxPromise(uni.getImageInfo);
+ var canvasToTempFilePath = wxPromise(uni.canvasToTempFilePath);
+ var base64 = createCommonjsModule(function(module, exports) {
+ /*! http://mths.be/base64 v0.1.0 by @mathias | MIT license */
+ (function(root) {
+ // Detect free variables `exports`.
+ var freeExports = 'object' == 'object' && exports;
+ // Detect free variable `module`.
+ var freeModule = 'object' == 'object' && module &&
+ module.exports == freeExports && module;
+ // Detect free variable `global`, from Node.js or Browserified code, and use
+ // it as `root`.
+ var freeGlobal = typeof commonjsGlobal == 'object' && commonjsGlobal;
+ if (freeGlobal.global === freeGlobal || freeGlobal.window === freeGlobal) {
+ root = freeGlobal;
+ /*--------------------------------------------------------------------------*/
+ var InvalidCharacterError = function(message) {
+ this.message = message;
+ InvalidCharacterError.prototype = new Error;
+ InvalidCharacterError.prototype.name = 'InvalidCharacterError';
+ var error = function(message) {
+ // Note: the error messages used throughout this file match those used by
+ // the native `atob`/`btoa` implementation in Chromium.
+ throw new InvalidCharacterError(message);
+ var TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+ // http://whatwg.org/html/common-microsyntaxes.html#space-character
+ var REGEX_SPACE_CHARACTERS = /[\t\n\f\r ]/g;
+ // `decode` is designed to be fully compatible with `atob` as described in the
+ // HTML Standard. http://whatwg.org/html/webappapis.html#dom-windowbase64-atob
+ // The optimized base64-decoding algorithm used is based on @atk’s excellent
+ // implementation. https://gist.github.com/atk/1020396
+ var decode = function(input) {
+ input = String(input)
+ .replace(REGEX_SPACE_CHARACTERS, '');
+ var length = input.length;
+ if (length % 4 == 0) {
+ input = input.replace(/==?$/, '');
+ length = input.length;
+ if (
+ length % 4 == 1 ||
+ // http://whatwg.org/C#alphanumeric-ascii-characters
+ /[^+a-zA-Z0-9/]/.test(input)
+ ) {
+ error(
+ 'Invalid character: the string to be decoded is not correctly encoded.'
+ var bitCounter = 0;
+ var bitStorage;
+ var buffer;
+ var output = '';
+ var position = -1;
+ while (++position < length) {
+ buffer = TABLE.indexOf(input.charAt(position));
+ bitStorage = bitCounter % 4 ? bitStorage * 64 + buffer : buffer;
+ // Unless this is the first of a group of 4 characters…
+ if (bitCounter++ % 4) {
+ // …convert the first 8 bits to a single ASCII character.
+ output += String.fromCharCode(
+ 0xFF & bitStorage >> (-2 * bitCounter & 6)
+ return output;
+ // `encode` is designed to be fully compatible with `btoa` as described in the
+ // HTML Standard: http://whatwg.org/html/webappapis.html#dom-windowbase64-btoa
+ var encode = function(input) {
+ input = String(input);
+ if (/[^\0-\xFF]/.test(input)) {
+ // Note: no need to special-case astral symbols here, as surrogates are
+ // matched, and the input is supposed to only contain ASCII anyway.
+ 'The string to be encoded contains characters outside of the ' +
+ 'Latin1 range.'
+ var padding = input.length % 3;
+ var a;
+ var b;
+ var c;
+ // Make sure any padding is handled outside of the loop.
+ var length = input.length - padding;
+ // Read three bytes, i.e. 24 bits.
+ a = input.charCodeAt(position) << 16;
+ b = input.charCodeAt(++position) << 8;
+ c = input.charCodeAt(++position);
+ buffer = a + b + c;
+ // Turn the 24 bits into four chunks of 6 bits each, and append the
+ // matching character for each of them to the output.
+ output += (
+ TABLE.charAt(buffer >> 18 & 0x3F) +
+ TABLE.charAt(buffer >> 12 & 0x3F) +
+ TABLE.charAt(buffer >> 6 & 0x3F) +
+ TABLE.charAt(buffer & 0x3F)
+ if (padding == 2) {
+ a = input.charCodeAt(position) << 8;
+ b = input.charCodeAt(++position);
+ buffer = a + b;
+ TABLE.charAt(buffer >> 10) +
+ TABLE.charAt((buffer >> 4) & 0x3F) +
+ TABLE.charAt((buffer << 2) & 0x3F) +
+ '='
+ } else if (padding == 1) {
+ buffer = input.charCodeAt(position);
+ TABLE.charAt(buffer >> 2) +
+ TABLE.charAt((buffer << 4) & 0x3F) +
+ '=='
+ var base64 = {
+ 'encode': encode,
+ 'decode': decode,
+ 'version': '0.1.0'
+ // Some AMD build optimizers, like r.js, check for specific condition patterns
+ // like the following:
+ typeof undefined == 'function' &&
+ typeof undefined.amd == 'object' &&
+ undefined.amd
+ undefined(function() {
+ return base64;
+ } else if (freeExports && !freeExports.nodeType) {
+ if (freeModule) { // in Node.js or RingoJS v0.8.0+
+ freeModule.exports = base64;
+ } else { // in Narwhal or RingoJS v0.7.0-
+ for (var key in base64) {
+ base64.hasOwnProperty(key) && (freeExports[key] = base64[key]);
+ } else { // in Rhino or a web browser
+ root.base64 = base64;
+ }(commonjsGlobal));
+ function makeURI(strData, type) {
+ return 'data:' + type + ';base64,' + strData
+ function fixType(type) {
+ type = type.toLowerCase().replace(/jpg/i, 'jpeg');
+ var r = type.match(/png|jpeg|bmp|gif/)[0];
+ return 'image/' + r
+ function encodeData(data) {
+ var str = '';
+ if (typeof data === 'string') {
+ str = data;
+ for (var i = 0; i < data.length; i++) {
+ str += String.fromCharCode(data[i]);
+ return base64.encode(str)
+ * 获取图像区域隐含的像素数据
+ * @param canvasId canvas标识
+ * @param x 将要被提取的图像数据矩形区域的左上角 x 坐标
+ * @param y 将要被提取的图像数据矩形区域的左上角 y 坐标
+ * @param width 将要被提取的图像数据矩形区域的宽度
+ * @param height 将要被提取的图像数据矩形区域的高度
+ * @param done 完成回调
+ function getImageData(canvasId, x, y, width, height, done) {
+ uni.canvasGetImageData({
+ canvasId: canvasId,
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ success: function success(res) {
+ done(res, null);
+ fail: function fail(res) {
+ done(null, res);
+ * 生成bmp格式图片
+ * 按照规则生成图片响应头和响应体
+ * @param oData 用来描述 canvas 区域隐含的像素数据 { data, width, height } = oData
+ * @returns {*} base64字符串
+ function genBitmapImage(oData) {
+ //
+ // BITMAPFILEHEADER: http://msdn.microsoft.com/en-us/library/windows/desktop/dd183374(v=vs.85).aspx
+ // BITMAPINFOHEADER: http://msdn.microsoft.com/en-us/library/dd183376.aspx
+ var biWidth = oData.width;
+ var biHeight = oData.height;
+ var biSizeImage = biWidth * biHeight * 3;
+ var bfSize = biSizeImage + 54; // total header size = 54 bytes
+ // typedef struct tagBITMAPFILEHEADER {
+ // WORD bfType;
+ // DWORD bfSize;
+ // WORD bfReserved1;
+ // WORD bfReserved2;
+ // DWORD bfOffBits;
+ // } BITMAPFILEHEADER;
+ var BITMAPFILEHEADER = [
+ // WORD bfType -- The file type signature; must be "BM"
+ 0x42, 0x4D,
+ // DWORD bfSize -- The size, in bytes, of the bitmap file
+ bfSize & 0xff, bfSize >> 8 & 0xff, bfSize >> 16 & 0xff, bfSize >> 24 & 0xff,
+ // WORD bfReserved1 -- Reserved; must be zero
+ 0, 0,
+ // WORD bfReserved2 -- Reserved; must be zero
+ // DWORD bfOffBits -- The offset, in bytes, from the beginning of the BITMAPFILEHEADER structure to the bitmap bits.
+ 54, 0, 0, 0
+ ];
+ // typedef struct tagBITMAPINFOHEADER {
+ // DWORD biSize;
+ // LONG biWidth;
+ // LONG biHeight;
+ // WORD biPlanes;
+ // WORD biBitCount;
+ // DWORD biCompression;
+ // DWORD biSizeImage;
+ // LONG biXPelsPerMeter;
+ // LONG biYPelsPerMeter;
+ // DWORD biClrUsed;
+ // DWORD biClrImportant;
+ // } BITMAPINFOHEADER, *PBITMAPINFOHEADER;
+ var BITMAPINFOHEADER = [
+ // DWORD biSize -- The number of bytes required by the structure
+ 40, 0, 0, 0,
+ // LONG biWidth -- The width of the bitmap, in pixels
+ biWidth & 0xff, biWidth >> 8 & 0xff, biWidth >> 16 & 0xff, biWidth >> 24 & 0xff,
+ // LONG biHeight -- The height of the bitmap, in pixels
+ biHeight & 0xff, biHeight >> 8 & 0xff, biHeight >> 16 & 0xff, biHeight >> 24 & 0xff,
+ // WORD biPlanes -- The number of planes for the target device. This value must be set to 1
+ 1, 0,
+ // WORD biBitCount -- The number of bits-per-pixel, 24 bits-per-pixel -- the bitmap
+ // has a maximum of 2^24 colors (16777216, Truecolor)
+ 24, 0,
+ // DWORD biCompression -- The type of compression, BI_RGB (code 0) -- uncompressed
+ 0, 0, 0, 0,
+ // DWORD biSizeImage -- The size, in bytes, of the image. This may be set to zero for BI_RGB bitmaps
+ biSizeImage & 0xff, biSizeImage >> 8 & 0xff, biSizeImage >> 16 & 0xff, biSizeImage >> 24 & 0xff,
+ // LONG biXPelsPerMeter, unused
+ // LONG biYPelsPerMeter, unused
+ // DWORD biClrUsed, the number of color indexes of palette, unused
+ // DWORD biClrImportant, unused
+ 0, 0, 0, 0
+ var iPadding = (4 - ((biWidth * 3) % 4)) % 4;
+ var aImgData = oData.data;
+ var strPixelData = '';
+ var biWidth4 = biWidth << 2;
+ var y = biHeight;
+ var fromCharCode = String.fromCharCode;
+ do {
+ var iOffsetY = biWidth4 * (y - 1);
+ var strPixelRow = '';
+ for (var x = 0; x < biWidth; x++) {
+ var iOffsetX = x << 2;
+ strPixelRow += fromCharCode(aImgData[iOffsetY + iOffsetX + 2]) +
+ fromCharCode(aImgData[iOffsetY + iOffsetX + 1]) +
+ fromCharCode(aImgData[iOffsetY + iOffsetX]);
+ for (var c = 0; c < iPadding; c++) {
+ strPixelRow += String.fromCharCode(0);
+ strPixelData += strPixelRow;
+ } while (--y)
+ var strEncoded = encodeData(BITMAPFILEHEADER.concat(BITMAPINFOHEADER)) + encodeData(strPixelData);
+ return strEncoded
+ * 转换为图片base64
+ * @param type 转换图片类型
+ function convertToImage(canvasId, x, y, width, height, type, done) {
+ if (done === void 0) done = function() {};
+ if (type === undefined) {
+ type = 'png';
+ type = fixType(type);
+ if (/bmp/.test(type)) {
+ getImageData(canvasId, x, y, width, height, function(data, err) {
+ var strData = genBitmapImage(data);
+ tools_7(done) && done(makeURI(strData, 'image/' + type), err);
+ console.error('暂不支持生成\'' + type + '\'类型的base64图片');
+ var CanvasToBase64 = {
+ convertToImage: convertToImage,
+ // convertToPNG: function (width, height, done) {
+ // return convertToImage(width, height, 'png', done)
+ // convertToJPEG: function (width, height, done) {
+ // return convertToImage(width, height, 'jpeg', done)
+ // convertToGIF: function (width, height, done) {
+ // return convertToImage(width, height, 'gif', done)
+ convertToBMP: function(ref, done) {
+ if (ref === void 0) ref = {};
+ var canvasId = ref.canvasId;
+ var x = ref.x;
+ var y = ref.y;
+ var width = ref.width;
+ var height = ref.height;
+ return convertToImage(canvasId, x, y, width, height, 'bmp', done)
+ function methods() {
+ var boundWidth = self.width; // 裁剪框默认宽度,即整个画布宽度
+ var boundHeight = self.height; // 裁剪框默认高度,即整个画布高度
+ var pixelRatio = self.pixelRatio;
+ var ref = self.cut;
+ if (x === void 0) x = 0;
+ if (y === void 0) y = 0;
+ if (width === void 0) width = boundWidth;
+ if (height === void 0) height = boundHeight;
+ self.updateCanvas = function(done) {
+ if (self.croperTarget) {
+ // 画布绘制图片
+ self.ctx.drawImage(
+ self.croperTarget,
+ self.imgLeft,
+ self.imgTop,
+ self.scaleWidth,
+ self.scaleHeight
+ tools_7(self.onBeforeDraw) && self.onBeforeDraw(self.ctx, self);
+ self.setBoundStyle(self.boundStyle); // 设置边界样式
+ self.ctx.draw(false, done);
+ self.pushOrigin = self.pushOrign = function(src) {
+ self.src = src;
+ tools_7(self.onBeforeImageLoad) && self.onBeforeImageLoad(self.ctx, self);
+ return getImageInfo({
+ src: src
+ .then(function(res) {
+ var innerAspectRadio = res.width / res.height;
+ var customAspectRadio = width / height;
+ self.croperTarget = res.path;
+ if (innerAspectRadio < customAspectRadio) {
+ self.rectX = x;
+ self.baseWidth = width;
+ self.baseHeight = width / innerAspectRadio;
+ self.rectY = y - Math.abs((height - self.baseHeight) / 2);
+ self.rectY = y;
+ self.baseWidth = height * innerAspectRadio;
+ self.baseHeight = height;
+ self.rectX = x - Math.abs((width - self.baseWidth) / 2);
+ self.imgLeft = self.rectX;
+ self.imgTop = self.rectY;
+ self.scaleWidth = self.baseWidth;
+ self.scaleHeight = self.baseHeight;
+ self.update();
+ self.updateCanvas(resolve);
+ .then(function() {
+ tools_7(self.onImageLoad) && self.onImageLoad(self.ctx, self);
+ self.removeImage = function() {
+ self.src = '';
+ self.croperTarget = '';
+ return draw(self.ctx)
+ self.getCropperBase64 = function(done) {
+ CanvasToBase64.convertToBMP({
+ canvasId: id,
+ height: height
+ }, done);
+ self.getCropperImage = function(opt, fn) {
+ var customOptions = opt;
+ var canvasOptions = {
+ var task = function() {
+ return Promise.resolve();
+ tools_10(customOptions) &&
+ customOptions.original
+ // original mode
+ task = function() {
+ self.targetCtx.drawImage(
+ self.imgLeft * pixelRatio,
+ self.imgTop * pixelRatio,
+ self.scaleWidth * pixelRatio,
+ self.scaleHeight * pixelRatio
+ canvasOptions = {
+ canvasId: targetId,
+ x: x * pixelRatio,
+ y: y * pixelRatio,
+ width: width * pixelRatio,
+ height: height * pixelRatio
+ return draw(self.targetCtx)
+ return task()
+ if (tools_10(customOptions)) {
+ canvasOptions = Object.assign({}, canvasOptions, customOptions);
+ if (tools_7(customOptions)) {
+ fn = customOptions;
+ var arg = canvasOptions.componentContext ?
+ [canvasOptions, canvasOptions.componentContext] :
+ [canvasOptions];
+ return canvasToTempFilePath.apply(null, arg)
+ var tempFilePath = res.tempFilePath;
+ return tools_7(fn) ?
+ fn.call(self, tempFilePath, null) :
+ tempFilePath
+ .catch(function(err) {
+ fn.call(self, null, err);
+ throw err
+ * 获取最新缩放值
+ * @param oldScale 上一次触摸结束后的缩放值
+ * @param oldDistance 上一次触摸结束后的双指距离
+ * @param zoom 缩放系数
+ * @param touch0 第一指touch对象
+ * @param touch1 第二指touch对象
+ * @returns {*}
+ var getNewScale = function(oldScale, oldDistance, zoom, touch0, touch1) {
+ var xMove, yMove, newDistance;
+ // 计算二指最新距离
+ xMove = Math.round(touch1.x - touch0.x);
+ yMove = Math.round(touch1.y - touch0.y);
+ newDistance = Math.round(Math.sqrt(xMove * xMove + yMove * yMove));
+ return oldScale + 0.001 * zoom * (newDistance - oldDistance)
+ function update() {
+ if (!self.src) {
+ return
+ self.__oneTouchStart = function(touch) {
+ self.touchX0 = Math.round(touch.x);
+ self.touchY0 = Math.round(touch.y);
+ self.__oneTouchMove = function(touch) {
+ var xMove, yMove;
+ // 计算单指移动的距离
+ if (self.touchended) {
+ return self.updateCanvas()
+ xMove = Math.round(touch.x - self.touchX0);
+ yMove = Math.round(touch.y - self.touchY0);
+ var imgLeft = Math.round(self.rectX + xMove);
+ var imgTop = Math.round(self.rectY + yMove);
+ self.outsideBound(imgLeft, imgTop);
+ self.updateCanvas();
+ self.__twoTouchStart = function(touch0, touch1) {
+ var xMove, yMove, oldDistance;
+ self.touchX1 = Math.round(self.rectX + self.scaleWidth / 2);
+ self.touchY1 = Math.round(self.rectY + self.scaleHeight / 2);
+ // 计算两指距离
+ oldDistance = Math.round(Math.sqrt(xMove * xMove + yMove * yMove));
+ self.oldDistance = oldDistance;
+ self.__twoTouchMove = function(touch0, touch1) {
+ var oldScale = self.oldScale;
+ var oldDistance = self.oldDistance;
+ var scale = self.scale;
+ var zoom = self.zoom;
+ self.newScale = getNewScale(oldScale, oldDistance, zoom, touch0, touch1);
+ // 设定缩放范围
+ self.newScale <= 1 && (self.newScale = 1);
+ self.newScale >= scale && (self.newScale = scale);
+ self.scaleWidth = Math.round(self.newScale * self.baseWidth);
+ self.scaleHeight = Math.round(self.newScale * self.baseHeight);
+ var imgLeft = Math.round(self.touchX1 - self.scaleWidth / 2);
+ var imgTop = Math.round(self.touchY1 - self.scaleHeight / 2);
+ self.__xtouchEnd = function() {
+ self.oldScale = self.newScale;
+ self.rectX = self.imgLeft;
+ self.rectY = self.imgTop;
+ var handle = {
+ // 图片手势初始监测
+ touchStart: function touchStart(e) {
+ var ref = e.touches;
+ var touch0 = ref[0];
+ var touch1 = ref[1];
+ setTouchState(self, true, null, null);
+ // 计算第一个触摸点的位置,并参照改点进行缩放
+ self.__oneTouchStart(touch0);
+ // 两指手势触发
+ if (e.touches.length >= 2) {
+ self.__twoTouchStart(touch0, touch1);
+ // 图片手势动态缩放
+ touchMove: function touchMove(e) {
+ setTouchState(self, null, true);
+ // 单指手势时触发
+ if (e.touches.length === 1) {
+ self.__oneTouchMove(touch0);
+ self.__twoTouchMove(touch0, touch1);
+ touchEnd: function touchEnd(e) {
+ setTouchState(self, false, false, true);
+ self.__xtouchEnd();
+ function cut() {
+ var boundHeight = self.height;
+ // 裁剪框默认高度,即整个画布高度
+ * 设置边界
+ * @param imgLeft 图片左上角横坐标值
+ * @param imgTop 图片左上角纵坐标值
+ self.outsideBound = function(imgLeft, imgTop) {
+ self.imgLeft = imgLeft >= x ?
+ x :
+ self.scaleWidth + imgLeft - x <= width ?
+ x + width - self.scaleWidth :
+ imgLeft;
+ self.imgTop = imgTop >= y ?
+ y :
+ self.scaleHeight + imgTop - y <= height ?
+ y + height - self.scaleHeight :
+ imgTop;
+ * 设置边界样式
+ * @param color 边界颜色
+ self.setBoundStyle = function(ref) {
+ var color = ref.color;
+ if (color === void 0) color = '#04b00f';
+ var mask = ref.mask;
+ if (mask === void 0) mask = 'rgba(0, 0, 0, 0.3)';
+ var lineWidth = ref.lineWidth;
+ if (lineWidth === void 0) lineWidth = 1;
+ var half = lineWidth / 2;
+ var boundOption = [{
+ start: {
+ x: x - half,
+ y: y + 10 - half
+ step1: {
+ y: y - half
+ step2: {
+ x: x + 10 - half,
+ {
+ y: y + height - 10 + half
+ y: y + height + half
+ x: x + width - 10 + half,
+ x: x + width + half,
+ // 绘制半透明层
+ self.ctx.beginPath();
+ self.ctx.setFillStyle(mask);
+ self.ctx.fillRect(0, 0, x, boundHeight);
+ self.ctx.fillRect(x, 0, width, y);
+ self.ctx.fillRect(x, y + height, width, boundHeight - y - height);
+ self.ctx.fillRect(x + width, 0, boundWidth - x - width, boundHeight);
+ self.ctx.fill();
+ boundOption.forEach(function(op) {
+ self.ctx.setStrokeStyle(color);
+ self.ctx.setLineWidth(lineWidth);
+ self.ctx.moveTo(op.start.x, op.start.y);
+ self.ctx.lineTo(op.step1.x, op.step1.y);
+ self.ctx.lineTo(op.step2.x, op.step2.y);
+ self.ctx.stroke();
+ var version = "1.3.9";
+ var WeCropper = function WeCropper(params) {
+ var _default = {};
+ validator(self, DEFAULT);
+ Object.keys(DEFAULT).forEach(function(key) {
+ _default[key] = DEFAULT[key].default;
+ Object.assign(self, _default, params);
+ self.prepare();
+ self.attachPage();
+ self.createCtx();
+ self.observer();
+ self.cutt();
+ self.methods();
+ self.init();
+ WeCropper.prototype.init = function init() {
+ var src = self.src;
+ self.version = version;
+ typeof self.onReady === 'function' && self.onReady(self.ctx, self);
+ if (src) {
+ self.pushOrign(src);
+ setTouchState(self, false, false, false);
+ self.oldScale = 1;
+ self.newScale = 1;
+ Object.assign(WeCropper.prototype, handle);
+ WeCropper.prototype.prepare = prepare;
+ WeCropper.prototype.observer = observer;
+ WeCropper.prototype.methods = methods;
+ WeCropper.prototype.cutt = cut;
+ WeCropper.prototype.update = update;
+ return WeCropper;
+})));
@@ -0,0 +1,153 @@
+ <view @tap="backToTop" class="u-back-top" :class="['u-back-top--mode--' + mode]" :style="[{
+ bottom: bottom + 'rpx',
+ right: right + 'rpx',
+ borderRadius: mode == 'circle' ? '10000rpx' : '8rpx',
+ zIndex: uZIndex,
+ opacity: opacity
+ }, customStyle]">
+ <view class="u-back-top__content" v-if="!$slots.default">
+ <u-icon @click="backToTop" :name="icon" :custom-style="iconStyle"></u-icon>
+ <view class="u-back-top__content__tips">
+ {{tips}}
+ <slot v-else />
+ name: 'u-back-top',
+ // 返回顶部的形状,circle-圆形,square-方形
+ mode: {
+ default: 'circle'
+ // 自定义图标
+ default: 'arrow-upward'
+ // 提示文字
+ // 返回顶部滚动时间
+ duration: {
+ type: [Number, String],
+ default: 100
+ // 滚动距离
+ scrollTop: {
+ // 距离顶部多少距离显示,单位rpx
+ top: {
+ default: 400
+ // 返回顶部按钮到底部的距离,单位rpx
+ bottom: {
+ default: 200
+ // 返回顶部按钮到右边的距离,单位rpx
+ right: {
+ default: 40
+ // 层级
+ default: '9'
+ // 图标的样式,对象形式
+ color: '#909399',
+ fontSize: '38rpx'
+ // 整个组件的样式
+ customStyle: {
+ watch: {
+ showBackTop(nVal, oVal) {
+ // 当组件的显示与隐藏状态发生跳变时,修改组件的层级和不透明度
+ // 让组件有显示和消失的动画效果,如果用v-if控制组件状态,将无设置动画效果
+ if(nVal) {
+ this.uZIndex = this.zIndex;
+ this.opacity = 1;
+ this.uZIndex = -1;
+ this.opacity = 0;
+ showBackTop() {
+ // 由于scrollTop为页面的滚动距离,默认为px单位,这里将用于传入的top(rpx)值
+ // 转为px用于比较,如果滚动条到顶的距离大于设定的距离,就显示返回顶部的按钮
+ return this.scrollTop > uni.upx2px(this.top);
+ // 不透明度,为了让组件有一个显示和隐藏的过渡动画
+ opacity: 0,
+ // 组件的z-index值,隐藏时设置为-1,就会看不到
+ uZIndex: -1
+ backToTop() {
+ uni.pageScrollTo({
+ scrollTop: 0,
+ duration: this.duration
+ .u-back-top {
+ width: 80rpx;
+ height: 80rpx;
+ z-index: 9;
+ flex-direction: column;
+ background-color: #E1E1E1;
+ transition: opacity 0.4s;
+ &__content {
+ &__tips {
+ transform: scale(0.8);
@@ -0,0 +1,213 @@
+ <view v-if="show" class="u-badge" :class="[
+ isDot ? 'u-badge-dot' : '',
+ size == 'mini' ? 'u-badge-mini' : '',
+ type ? 'u-badge--bg--' + type : ''
+ ]" :style="[{
+ top: offset[0] + 'rpx',
+ right: offset[1] + 'rpx',
+ fontSize: fontSize + 'rpx',
+ position: absolute ? 'absolute' : 'static',
+ color: color,
+ backgroundColor: bgColor
+ }, boxStyle]"
+ {{showText}}
+ * badge 角标
+ * @description 本组件一般用于展示头像的地方,如个人中心,或者评论列表页的用户头像展示等场所。
+ * @tutorial https://www.uviewui.com/components/badge.html
+ * @property {String Number} count 展示的数字,大于 overflowCount 时显示为 ${overflowCount}+,为0且show-zero为false时隐藏
+ * @property {Boolean} is-dot 不展示数字,只有一个小点(默认false)
+ * @property {Boolean} absolute 组件是否绝对定位,为true时,offset参数才有效(默认true)
+ * @property {String Number} overflow-count 展示封顶的数字值(默认99)
+ * @property {String} type 使用预设的背景颜色(默认error)
+ * @property {Boolean} show-zero 当数值为 0 时,是否展示 Badge(默认false)
+ * @property {String} size Badge的尺寸,设为mini会得到小一号的Badge(默认default)
+ * @property {Array} offset 设置badge的位置偏移,格式为 [x, y],也即设置的为top和right的值,单位rpx。absolute为true时有效(默认[20, 20])
+ * @property {String} color 字体颜色(默认#ffffff)
+ * @property {String} bgColor 背景颜色,优先级比type高,如设置,type参数会失效
+ * @property {Boolean} is-center 组件中心点是否和父组件右上角重合,优先级比offset高,如设置,offset参数会失效(默认false)
+ * @example <u-badge type="error" count="7"></u-badge>
+ name: 'u-badge',
+ // primary,warning,success,error,info
+ default: 'error'
+ // default, mini
+ size: {
+ default: 'default'
+ //是否是圆点
+ isDot: {
+ // 显示的数值内容
+ count: {
+ // 展示封顶的数字值
+ overflowCount: {
+ type: Number,
+ default: 99
+ // 当数值为 0 时,是否展示 Badge
+ showZero: {
+ // 位置偏移
+ offset: {
+ default: () => {
+ return [20, 20]
+ // 是否开启绝对定位,开启了offset才会起作用
+ absolute: {
+ // 字体大小
+ fontSize: {
+ default: '24'
+ // 字体演示
+ default: '#ffffff'
+ // badge的背景颜色
+ // 是否让badge组件的中心点和父组件右上角重合,配置的话,offset将会失效
+ isCenter: {
+ // 是否将badge中心与父组件右上角重合
+ boxStyle() {
+ if(this.isCenter) {
+ style.top = 0;
+ style.right = 0;
+ // Y轴-50%,意味着badge向上移动了badge自身高度一半,X轴50%,意味着向右移动了自身宽度一半
+ style.transform = "translateY(-50%) translateX(50%)";
+ style.top = this.offset[0] + 'rpx';
+ style.right = this.offset[1] + 'rpx';
+ style.transform = "translateY(0) translateX(0)";
+ // 如果尺寸为mini,后接上scal()
+ if(this.size == 'mini') {
+ style.transform = style.transform + " scale(0.8)";
+ // isDot类型时,不显示文字
+ showText() {
+ if(this.isDot) return '';
+ else {
+ if(this.count > this.overflowCount) return `${this.overflowCount}+`;
+ else return this.count;
+ // 是否显示组件
+ show() {
+ // 如果count的值为0,并且showZero设置为false,不显示组件
+ if(this.count == 0 && this.showZero == false) return false;
+ else return true;
+ .u-badge {
+ display: inline-flex;
+ line-height: 24rpx;
+ padding: 4rpx 8rpx;
+ border-radius: 100rpx;
+ &--bg--primary {
+ background-color: $u-type-primary;
+ &--bg--error {
+ background-color: $u-type-error;
+ &--bg--success {
+ background-color: $u-type-success;
+ &--bg--info {
+ background-color: $u-type-info;
+ &--bg--warning {
+ background-color: $u-type-warning;
+ .u-badge-dot {
+ height: 16rpx;
+ width: 16rpx;
+ .u-badge-mini {
+ transform-origin: center center;
+ // .u-primary {
+ // background: $u-type-primary;
+ // color: #fff;
+ // .u-error {
+ // background: $u-type-error;
+ // .u-warning {
+ // background: $u-type-warning;
+ // .u-success {
+ // background: $u-type-success;
+ // .u-black {
+ // background: #585858;
+ .u-info {
+ background: $u-type-info;
+ color: #fff;
@@ -0,0 +1,578 @@
+ <button
+ id="u-wave-btn"
+ class="u-btn u-line-1 u-fix-ios-appearance"
+ :class="[
+ 'u-size-' + size,
+ plain ? 'u-btn--' + type + '--plain' : '',
+ loading ? 'u-loading' : '',
+ shape == 'circle' ? 'u-round-circle' : '',
+ hairLine ? showHairLineBorder : 'u-btn--bold-border',
+ 'u-btn--' + type,
+ disabled ? `u-btn--${type}--disabled` : '',
+ ]"
+ :disabled="disabled"
+ :form-type="formType"
+ :open-type="openType"
+ :app-parameter="appParameter"
+ :hover-stop-propagation="hoverStopPropagation"
+ :send-message-title="sendMessageTitle"
+ send-message-path="sendMessagePath"
+ :lang="lang"
+ :data-name="dataName"
+ :session-from="sessionFrom"
+ :send-message-img="sendMessageImg"
+ :show-message-card="showMessageCard"
+ @getphonenumber="getphonenumber"
+ @getuserinfo="getuserinfo"
+ @error="error"
+ @opensetting="opensetting"
+ @launchapp="launchapp"
+ :style="[customStyle, {
+ overflow: ripple ? 'hidden' : 'visible'
+ }]"
+ @tap.stop="click($event)"
+ :hover-class="getHoverClass"
+ :loading="loading"
+ <slot></slot>
+ v-if="ripple"
+ class="u-wave-ripple"
+ :class="[waveActive ? 'u-wave-active' : '']"
+ top: rippleTop + 'px',
+ left: rippleLeft + 'px',
+ width: fields.targetWidth + 'px',
+ height: fields.targetWidth + 'px',
+ 'background-color': rippleBgColor || 'rgba(0, 0, 0, 0.15)'
+ ></view>
+ </button>
+ * button 按钮
+ * @description Button 按钮
+ * @tutorial https://www.uviewui.com/components/button.html
+ * @property {String} size 按钮的大小
+ * @property {Boolean} ripple 是否开启点击水波纹效果
+ * @property {String} ripple-bg-color 水波纹的背景色,ripple为true时有效
+ * @property {String} type 按钮的样式类型
+ * @property {Boolean} plain 按钮是否镂空,背景色透明
+ * @property {Boolean} disabled 是否禁用
+ * @property {Boolean} hair-line 是否显示按钮的细边框(默认true)
+ * @property {Boolean} shape 按钮外观形状,见文档说明
+ * @property {Boolean} loading 按钮名称前是否带 loading 图标(App-nvue 平台,在 ios 上为雪花,Android上为圆圈)
+ * @property {String} form-type 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件
+ * @property {String} open-type 开放能力
+ * @property {String} data-name 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取
+ * @property {String} hover-class 指定按钮按下去的样式类。当 hover-class="none" 时,没有点击态效果(App-nvue 平台暂不支持)
+ * @property {Number} hover-start-time 按住后多久出现点击态,单位毫秒
+ * @property {Number} hover-stay-time 手指松开后点击态保留时间,单位毫秒
+ * @property {Object} custom-style 对按钮的自定义样式,对象形式,见文档说明
+ * @event {Function} click 按钮点击
+ * @event {Function} getphonenumber open-type="getPhoneNumber"时有效
+ * @event {Function} getuserinfo 用户点击该按钮时,会返回获取到的用户信息,从返回参数的detail中获取到的值同uni.getUserInfo
+ * @event {Function} error 当使用开放能力时,发生错误的回调
+ * @event {Function} opensetting 在打开授权设置页并关闭后回调
+ * @event {Function} launchapp 打开 APP 成功的回调
+ * @example <u-button>月落</u-button>
+ name: 'u-button',
+ // 是否细边框
+ hairLine: {
+ // 按钮的预置样式,default,primary,error,warning,success
+ // 按钮尺寸,default,medium,mini
+ // 按钮形状,circle(两边为半圆),square(带圆角)
+ shape: {
+ default: 'square'
+ // 按钮是否镂空
+ plain: {
+ // 是否禁止状态
+ disabled: {
+ // 是否加载中
+ loading: {
+ // 开放能力,具体请看uniapp稳定关于button组件部分说明
+ // https://uniapp.dcloud.io/component/button
+ openType: {
+ // 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件
+ // 取值为submit(提交表单),reset(重置表单)
+ formType: {
+ // 打开 APP 时,向 APP 传递的参数,open-type=launchApp时有效
+ // 只微信小程序、QQ小程序有效
+ appParameter: {
+ // 指定是否阻止本节点的祖先节点出现点击态,微信小程序有效
+ hoverStopPropagation: {
+ // 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文。只微信小程序有效
+ lang: {
+ default: 'en'
+ // 会话来源,open-type="contact"时有效。只微信小程序有效
+ sessionFrom: {
+ // 会话内消息卡片标题,open-type="contact"时有效
+ // 默认当前标题,只微信小程序有效
+ sendMessageTitle: {
+ // 会话内消息卡片点击跳转小程序路径,open-type="contact"时有效
+ // 默认当前分享路径,只微信小程序有效
+ sendMessagePath: {
+ // 会话内消息卡片图片,open-type="contact"时有效
+ // 默认当前页面截图,只微信小程序有效
+ sendMessageImg: {
+ // 是否显示会话内消息卡片,设置此参数为 true,用户进入客服会话会在右下角显示"可能要发送的小程序"提示,
+ // 用户点击后可以快速发送小程序消息,open-type="contact"时有效
+ showMessageCard: {
+ // 手指按(触摸)按钮时按钮时的背景颜色
+ hoverBgColor: {
+ // 水波纹的背景颜色
+ rippleBgColor: {
+ // 是否开启水波纹效果
+ ripple: {
+ // 按下的类名
+ hoverClass: {
+ // 自定义样式,对象形式
+ return {};
+ // 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取
+ dataName: {
+ // 节流,一定时间内只能触发一次
+ throttleTime: {
+ default: 1000
+ // 当没有传bgColor变量时,按钮按下去的颜色类名
+ getHoverClass() {
+ // 如果开启水波纹效果,则不启用hover-class效果
+ if (this.loading || this.disabled || this.ripple || this.hoverClass) return '';
+ let hoverClass = '';
+ hoverClass = this.plain ? 'u-' + this.type + '-plain-hover' : 'u-' + this.type + '-hover';
+ return hoverClass;
+ // 在'primary', 'success', 'error', 'warning'类型下,不显示边框,否则会造成四角有毛刺现象
+ showHairLineBorder() {
+ if (['primary', 'success', 'error', 'warning'].indexOf(this.type) >= 0 && !this.plain) {
+ return '';
+ return 'u-hairline-border';
+ rippleTop: 0, // 水波纹的起点Y坐标到按钮上边界的距离
+ rippleLeft: 0, // 水波纹起点X坐标到按钮左边界的距离
+ fields: {}, // 波纹按钮节点信息
+ waveActive: false // 激活水波纹
+ // 按钮点击
+ click(e) {
+ // 进行节流控制,每this.throttle毫秒内,只在开始处执行
+ this.$u.throttle(() => {
+ // 如果按钮时disabled和loading状态,不触发水波纹效果
+ if (this.loading === true || this.disabled === true) return;
+ if (this.ripple) {
+ // 每次点击时,移除上一次的类,再次添加,才能触发动画效果
+ this.waveActive = false;
+ this.$nextTick(function() {
+ this.getWaveQuery(e);
+ this.$emit('click', e);
+ }, this.throttleTime);
+ // 查询按钮的节点信息
+ getWaveQuery(e) {
+ this.getElQuery().then(res => {
+ // 查询返回的是一个数组节点
+ let data = res[0];
+ // 查询不到节点信息,不操作
+ if (!data.width || !data.width) return;
+ // 水波纹的最终形态是一个正方形(通过border-radius让其变为一个圆形),这里要保证正方形的边长等于按钮的最长边
+ // 最终的方形(变换后的圆形)才能覆盖整个按钮
+ data.targetWidth = data.height > data.width ? data.height : data.width;
+ if (!data.targetWidth) return;
+ this.fields = data;
+ let touchesX = '',
+ touchesY = '';
+ // #ifdef MP-BAIDU
+ touchesX = e.changedTouches[0].clientX;
+ touchesY = e.changedTouches[0].clientY;
+ // #endif
+ // #ifdef MP-ALIPAY
+ touchesX = e.detail.clientX;
+ touchesY = e.detail.clientY;
+ // #ifndef MP-BAIDU || MP-ALIPAY
+ touchesX = e.touches[0].clientX;
+ touchesY = e.touches[0].clientY;
+ // 获取触摸点相对于按钮上边和左边的x和y坐标,原理是通过屏幕的触摸点(touchesY),减去按钮的上边界data.top
+ // 但是由于`transform-origin`默认是center,所以这里再减去半径才是水波纹view应该的位置
+ // 总的来说,就是把水波纹的矩形(变换后的圆形)的中心点,移动到我们的触摸点位置
+ this.rippleTop = touchesY - data.top - data.targetWidth / 2;
+ this.rippleLeft = touchesX - data.left - data.targetWidth / 2;
+ this.$nextTick(() => {
+ this.waveActive = true;
+ // 获取节点信息
+ getElQuery() {
+ return new Promise(resolve => {
+ let queryInfo = '';
+ // 获取元素节点信息,请查看uniapp相关文档
+ // https://uniapp.dcloud.io/api/ui/nodes-info?id=nodesrefboundingclientrect
+ queryInfo = uni.createSelectorQuery().in(this);
+ //#ifdef MP-ALIPAY
+ queryInfo = uni.createSelectorQuery();
+ //#endif
+ queryInfo.select('.u-btn').boundingClientRect();
+ queryInfo.exec(data => {
+ resolve(data);
+ // 下面为对接uniapp官方按钮开放能力事件回调的对接
+ getphonenumber(res) {
+ this.$emit('getphonenumber', res);
+ getuserinfo(res) {
+ this.$emit('getuserinfo', res);
+ error(res) {
+ this.$emit('error', res);
+ opensetting(res) {
+ this.$emit('opensetting', res);
+ launchapp(res) {
+ this.$emit('launchapp', res);
+<style scoped lang="scss">
+.u-btn::after {
+ border: none;
+.u-btn {
+ border: 0;
+ //border-radius: 10rpx;
+ display: inline-block;
+ // 避免边框某些场景可能被“裁剪”,不能设置为hidden
+ overflow: visible;
+ cursor: pointer;
+ padding: 0 40rpx;
+ z-index: 1;
+ box-sizing: border-box;
+ transition: all 0.15s;
+ &--bold-border {
+ border: 1px solid #ffffff;
+ &--default {
+ border-color: #c0c4cc;
+ background-color: #ffffff;
+ &--primary {
+ color: #ffffff;
+ border-color: $u-type-primary;
+ &--success {
+ border-color: $u-type-success;
+ &--error {
+ border-color: $u-type-error;
+ &--warning {
+ border-color: $u-type-warning;
+ &--default--disabled {
+ border-color: #e4e7ed;
+ &--primary--disabled {
+ color: #ffffff!important;
+ border-color: $u-type-primary-disabled!important;
+ background-color: $u-type-primary-disabled!important;
+ &--success--disabled {
+ border-color: $u-type-success-disabled!important;
+ background-color: $u-type-success-disabled!important;
+ &--error--disabled {
+ border-color: $u-type-error-disabled!important;
+ background-color: $u-type-error-disabled!important;
+ &--warning--disabled {
+ border-color: $u-type-warning-disabled!important;
+ background-color: $u-type-warning-disabled!important;
+ &--primary--plain {
+ color: $u-type-primary!important;
+ background-color: $u-type-primary-light!important;
+ &--success--plain {
+ color: $u-type-success!important;
+ background-color: $u-type-success-light!important;
+ &--error--plain {
+ color: $u-type-error!important;
+ background-color: $u-type-error-light!important;
+ &--warning--plain {
+ color: $u-type-warning!important;
+ background-color: $u-type-warning-light!important;
+.u-hairline-border:after {
+ content: ' ';
+ pointer-events: none;
+ // 设置为border-box,意味着下面的scale缩小为0.5,实际上缩小的是伪元素的内容(border-box意味着内容不含border)
+ // 中心点作为变形(scale())的原点
+ -webkit-transform-origin: 0 0;
+ transform-origin: 0 0;
+ width: 199.8%;
+ height: 199.7%;
+ -webkit-transform: scale(0.5, 0.5);
+ transform: scale(0.5, 0.5);
+ border: 1px solid currentColor;
+.u-wave-ripple {
+ z-index: 0;
+ border-radius: 100%;
+ background-clip: padding-box;
+ user-select: none;
+ transform: scale(0);
+ opacity: 1;
+ transform-origin: center;
+.u-wave-ripple.u-wave-active {
+ transform: scale(2);
+ transition: opacity 1s linear, transform 0.4s linear;
+.u-round-circle {
+.u-round-circle::after {
+.u-loading::after {
+ background-color: hsla(0, 0%, 100%, 0.35);
+.u-size-default {
+ font-size: 30rpx;
+ line-height: 80rpx;
+.u-size-medium {
+ width: auto;
+ height: 70rpx;
+ line-height: 70rpx;
+ padding: 0 80rpx;
+.u-size-mini {
+ font-size: 22rpx;
+ padding-top: 1px;
+ height: 50rpx;
+ line-height: 50rpx;
+ padding: 0 20rpx;
+.u-primary-plain-hover {
+ color: #ffffff !important;
+ background: $u-type-primary-dark !important;
+.u-default-plain-hover {
+ color: $u-type-primary-dark !important;
+ background: $u-type-primary-light !important;
+.u-success-plain-hover {
+ background: $u-type-success-dark !important;
+.u-warning-plain-hover {
+ background: $u-type-warning-dark !important;
+.u-error-plain-hover {
+ background: $u-type-error-dark !important;
+.u-info-plain-hover {
+ background: $u-type-info-dark !important;
+.u-default-hover {
+ border-color: $u-type-primary-dark !important;
+ background-color: $u-type-primary-light !important;
+.u-primary-hover {
+.u-success-hover {
+.u-info-hover {
+.u-warning-hover {
+.u-error-hover {
@@ -0,0 +1,641 @@
+ <u-popup closeable :maskCloseAble="maskCloseAble" mode="bottom" :popup="false" v-model="value" length="auto"
+ :safeAreaInsetBottom="safeAreaInsetBottom" @close="close" :z-index="uZIndex" :border-radius="borderRadius" :closeable="closeable">
+ <view class="u-calendar">
+ <view class="u-calendar__header">
+ <view class="u-calendar__header__text" v-if="!$slots['tooltip']">
+ {{toolTip}}
+ <slot v-else name="tooltip" />
+ <view class="u-calendar__action u-flex u-row-center">
+ <view class="u-calendar__action__icon">
+ <u-icon v-if="changeYear" name="arrow-left-double" :color="yearArrowColor" @click="changeYearHandler(0)"></u-icon>
+ <u-icon v-if="changeMonth" name="arrow-left" :color="monthArrowColor" @click="changeMonthHandler(0)"></u-icon>
+ <view class="u-calendar__action__text">{{ showTitle }}</view>
+ <u-icon v-if="changeMonth" name="arrow-right" :color="monthArrowColor" @click="changeMonthHandler(1)"></u-icon>
+ <u-icon v-if="changeYear" name="arrow-right-double" :color="yearArrowColor" @click="changeYearHandler(1)"></u-icon>
+ <view class="u-calendar__week-day">
+ <view class="u-calendar__week-day__text" v-for="(item, index) in weekDayZh" :key="index">{{item}}</view>
+ <view class="u-calendar__content">
+ <!-- 前置空白部分 -->
+ <block v-for="(item, index) in weekdayArr" :key="index">
+ <view class="u-calendar__content__item"></view>
+ <view class="u-calendar__content__item" :class="{
+ 'u-hover-class':openDisAbled(year,month,index+1),
+ 'u-calendar__content--start-date': (mode == 'range' && startDate==`${year}-${month}-${index+1}`) || mode== 'date',
+ 'u-calendar__content--end-date':(mode== 'range' && endDate==`${year}-${month}-${index+1}`) || mode == 'date'
+ }" :style="{backgroundColor: getColor(index,1)}" v-for="(item, index) in daysArr" :key="index"
+ @tap="dateClick(index)">
+ <view class="u-calendar__content__item__inner" :style="{color: getColor(index,2)}">
+ <view>{{ index + 1 }}</view>
+ <view class="u-calendar__content__item__tips" :style="{color:activeColor}" v-if="mode== 'range' && startDate==`${year}-${month}-${index+1}` && startDate!=endDate">{{startText}}</view>
+ <view class="u-calendar__content__item__tips" :style="{color:activeColor}" v-if="mode== 'range' && endDate==`${year}-${month}-${index+1}`">{{endText}}</view>
+ <view class="u-calendar__content__bg-month">{{month}}</view>
+ <view class="u-calendar__bottom">
+ <view class="u-calendar__bottom__choose">
+ <text>{{mode == 'date' ? activeDate : startDate}}</text>
+ <text v-if="endDate">至{{endDate}}</text>
+ <view class="u-calendar__bottom__btn">
+ <u-button :type="btnType" shape="circle" size="default" @click="btnFix(false)">确定</u-button>
+ * calendar 日历
+ * @description 此组件用于单个选择日期,范围选择日期等,日历被包裹在底部弹起的容器中。
+ * @tutorial http://uviewui.com/components/calendar.html
+ * @property {String} mode 选择日期的模式,date-为单个日期,range-为选择日期范围
+ * @property {Boolean} v-model 布尔值变量,用于控制日历的弹出与收起
+ * @property {Boolean} safe-area-inset-bottom 是否开启底部安全区适配(默认false)
+ * @property {Boolean} change-year 是否显示顶部的切换年份方向的按钮(默认true)
+ * @property {Boolean} change-month 是否显示顶部的切换月份方向的按钮(默认true)
+ * @property {String Number} max-year 可切换的最大年份(默认2050)
+ * @property {String Number} min-year 最小可选日期(默认1950)
+ * @property {String Number} min-date 可切换的最小年份(默认1950-01-01)
+ * @property {String Number} max-date 最大可选日期(默认当前日期)
+ * @property {String Number} 弹窗顶部左右两边的圆角值,单位rpx(默认20)
+ * @property {Boolean} mask-close-able 是否允许通过点击遮罩关闭日历(默认true)
+ * @property {String} month-arrow-color 月份切换按钮箭头颜色(默认#606266)
+ * @property {String} year-arrow-color 年份切换按钮箭头颜色(默认#909399)
+ * @property {String} color 日期字体的默认颜色(默认#303133)
+ * @property {String} active-bg-color 起始/结束日期按钮的背景色(默认#2979ff)
+ * @property {String Number} z-index 弹出时的z-index值(默认10075)
+ * @property {String} active-color 起始/结束日期按钮的字体颜色(默认#ffffff)
+ * @property {String} range-bg-color 起始/结束日期之间的区域的背景颜色(默认rgba(41,121,255,0.13))
+ * @property {String} range-color 选择范围内字体颜色(默认#2979ff)
+ * @property {String} start-text 起始日期底部的提示文字(默认 '开始')
+ * @property {String} end-text 结束日期底部的提示文字(默认 '结束')
+ * @property {String} btn-type 底部确定按钮的主题(默认 'primary')
+ * @property {String} toolTip 顶部提示文字,如设置名为tooltip的slot,此参数将失效(默认 '选择日期')
+ * @property {Boolean} closeable 是否显示右上角的关闭图标(默认true)
+ * @example <u-calendar v-model="show" :mode="mode"></u-calendar>
+ name: 'u-calendar',
+ // 是否允许通过点击遮罩关闭Picker
+ // 是否允许切换年份
+ changeYear: {
+ // 是否允许切换月份
+ changeMonth: {
+ // date-单个日期选择,range-开始日期+结束日期选择
+ default: 'date'
+ // 可切换的最大年份
+ maxYear: {
+ default: 2050
+ // 可切换的最小年份
+ minYear: {
+ default: 1950
+ // 最小可选日期(不在范围内日期禁用不可选)
+ minDate: {
+ default: '1950-01-01'
+ * 最大可选日期
+ * 默认最大值为今天,之后的日期不可选
+ * 2030-12-31
+ * */
+ maxDate: {
+ // 弹窗顶部左右两边的圆角值
+ default: 20
+ // 月份切换按钮箭头颜色
+ monthArrowColor: {
+ default: '#606266'
+ // 年份切换按钮箭头颜色
+ yearArrowColor: {
+ default: '#909399'
+ // 默认日期字体颜色
+ default: '#303133'
+ // 选中|起始结束日期背景色
+ activeBgColor: {
+ default: '#2979ff'
+ // 选中|起始结束日期字体颜色
+ activeColor: {
+ // 范围内日期背景色
+ rangeBgColor: {
+ default: 'rgba(41,121,255,0.13)'
+ // 范围内日期字体颜色
+ rangeColor: {
+ // mode=range时生效,起始日期自定义文案
+ startText: {
+ default: '开始'
+ // mode=range时生效,结束日期自定义文案
+ endText: {
+ default: '结束'
+ //按钮样式类型
+ btnType: {
+ default: 'primary'
+ // 当前选中日期带选中效果
+ isActiveCurrent: {
+ // 切换年月是否触发事件 mode=date时生效
+ isChange: {
+ // 是否显示右上角的关闭图标
+ closeable: {
+ toolTip: {
+ default: '选择日期'
+ // 星期几,值为1-7
+ weekday: 1,
+ weekdayArr:[],
+ // 当前月有多少天
+ days: 0,
+ daysArr:[],
+ showTitle: '',
+ year: 2020,
+ month: 0,
+ day: 0,
+ startYear: 0,
+ startMonth: 0,
+ startDay: 0,
+ endYear: 0,
+ endMonth: 0,
+ endDay: 0,
+ today: '',
+ activeDate: '',
+ startDate: '',
+ endDate: '',
+ isStart: true,
+ min: null,
+ max: null,
+ weekDayZh: ['日', '一', '二', '三', '四', '五', '六']
+ dataChange() {
+ return `${this.mode}-${this.minDate}-${this.maxDate}`;
+ dataChange(val) {
+ this.init()
+ created() {
+ getColor(index, type) {
+ let color = type == 1 ? '' : this.color;
+ let day = index + 1
+ let date = `${this.year}-${this.month}-${day}`
+ let timestamp = new Date(date.replace(/\-/g, '/')).getTime();
+ let start = this.startDate.replace(/\-/g, '/')
+ let end = this.endDate.replace(/\-/g, '/')
+ if ((this.isActiveCurrent && this.activeDate == date) || this.startDate == date || this.endDate == date) {
+ color = type == 1 ? this.activeBgColor : this.activeColor;
+ } else if (this.endDate && timestamp > new Date(start).getTime() && timestamp < new Date(end).getTime()) {
+ color = type == 1 ? this.rangeBgColor : this.rangeColor;
+ return color;
+ init() {
+ let now = new Date();
+ this.year = now.getFullYear();
+ this.month = now.getMonth() + 1;
+ this.day = now.getDate();
+ this.today = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`;
+ this.activeDate = this.today;
+ this.min = this.initDate(this.minDate);
+ this.max = this.initDate(this.maxDate || this.today);
+ this.startDate = "";
+ this.startYear = 0;
+ this.startMonth = 0;
+ this.startDay = 0;
+ this.endYear = 0;
+ this.endMonth = 0;
+ this.endDay = 0;
+ this.endDate = "";
+ this.isStart = true;
+ this.changeData();
+ //日期处理
+ initDate(date) {
+ let fdate = date.split('-');
+ year: Number(fdate[0] || 1920),
+ month: Number(fdate[1] || 1),
+ day: Number(fdate[2] || 1)
+ openDisAbled: function(year, month, day) {
+ let bool = true;
+ let date = `${year}/${month}/${day}`;
+ // let today = this.today.replace(/\-/g, '/');
+ let min = `${this.min.year}/${this.min.month}/${this.min.day}`;
+ let max = `${this.max.year}/${this.max.month}/${this.max.day}`;
+ let timestamp = new Date(date).getTime();
+ if (timestamp >= new Date(min).getTime() && timestamp <= new Date(max).getTime()) {
+ bool = false;
+ return bool;
+ generateArray: function(start, end) {
+ return Array.from(new Array(end + 1).keys()).slice(start);
+ formatNum: function(num) {
+ return num < 10 ? '0' + num : num + '';
+ //一个月有多少天
+ getMonthDay(year, month) {
+ let days = new Date(year, month, 0).getDate();
+ return days;
+ getWeekday(year, month) {
+ let date = new Date(`${year}/${month}/01 00:00:00`);
+ return date.getDay();
+ checkRange(year) {
+ let overstep = false;
+ if (year < this.minYear || year > this.maxYear) {
+ title: "日期超出范围啦~",
+ icon: 'none'
+ overstep = true;
+ return overstep;
+ changeMonthHandler(isAdd) {
+ if (isAdd) {
+ let month = this.month + 1;
+ let year = month > 12 ? this.year + 1 : this.year;
+ if (!this.checkRange(year)) {
+ this.month = month > 12 ? 1 : month;
+ this.year = year;
+ let month = this.month - 1;
+ let year = month < 1 ? this.year - 1 : this.year;
+ this.month = month < 1 ? 12 : month;
+ changeYearHandler(isAdd) {
+ let year = isAdd ? this.year + 1 : this.year - 1;
+ changeData() {
+ this.days = this.getMonthDay(this.year, this.month);
+ this.daysArr=this.generateArray(1,this.days)
+ this.weekday = this.getWeekday(this.year, this.month);
+ this.weekdayArr=this.generateArray(1,this.weekday)
+ this.showTitle = `${this.year}年${this.month}月`;
+ if (this.isChange && this.mode == 'date') {
+ this.btnFix(true);
+ dateClick: function(day) {
+ day += 1;
+ if (!this.openDisAbled(this.year, this.month, day)) {
+ this.day = day;
+ let date = `${this.year}-${this.month}-${day}`;
+ if (this.mode == 'date') {
+ this.activeDate = date;
+ let compare = new Date(date.replace(/\-/g, '/')).getTime() < new Date(this.startDate.replace(/\-/g, '/')).getTime()
+ if (this.isStart || compare) {
+ this.startDate = date;
+ this.startYear = this.year;
+ this.startMonth = this.month;
+ this.startDay = this.day;
+ this.activeDate = "";
+ this.isStart = false;
+ this.endDate = date;
+ this.endYear = this.year;
+ this.endMonth = this.month;
+ this.endDay = this.day;
+ // 修改通过v-model绑定的父组件变量的值为false,从而隐藏日历弹窗
+ getWeekText(date) {
+ date = new Date(`${date.replace(/\-/g, '/')} 00:00:00`);
+ let week = date.getDay();
+ return '星期' + ['日', '一', '二', '三', '四', '五', '六'][week];
+ btnFix(show) {
+ if (!show) {
+ this.close();
+ let arr = this.activeDate.split('-')
+ let year = this.isChange ? this.year : Number(arr[0]);
+ let month = this.isChange ? this.month : Number(arr[1]);
+ let day = this.isChange ? this.day : Number(arr[2]);
+ //当前月有多少天
+ let days = this.getMonthDay(year, month);
+ let result = `${year}-${this.formatNum(month)}-${this.formatNum(day)}`;
+ let weekText = this.getWeekText(result);
+ let isToday = false;
+ if (`${year}-${month}-${day}` == this.today) {
+ //今天
+ isToday = true;
+ this.$emit('change', {
+ year: year,
+ month: month,
+ day: day,
+ days: days,
+ result: result,
+ week: weekText,
+ isToday: isToday,
+ // switch: show //是否是切换年月操作
+ if (!this.startDate || !this.endDate) return;
+ let startMonth = this.formatNum(this.startMonth);
+ let startDay = this.formatNum(this.startDay);
+ let startDate = `${this.startYear}-${startMonth}-${startDay}`;
+ let startWeek = this.getWeekText(startDate)
+ let endMonth = this.formatNum(this.endMonth);
+ let endDay = this.formatNum(this.endDay);
+ let endDate = `${this.endYear}-${endMonth}-${endDay}`;
+ let endWeek = this.getWeekText(endDate);
+ startYear: this.startYear,
+ startMonth: this.startMonth,
+ startDay: this.startDay,
+ startDate: startDate,
+ startWeek: startWeek,
+ endYear: this.endYear,
+ endMonth: this.endMonth,
+ endDay: this.endDay,
+ endDate: endDate,
+ endWeek: endWeek
+ .u-calendar {
+ &__header {
+ background-color: #fff;
+ &__text {
+ margin-top: 30rpx;
+ padding: 0 60rpx;
+ &__action {
+ padding: 40rpx 0 40rpx 0;
+ &__icon {
+ margin: 0 16rpx;
+ padding: 0 16rpx;
+ font-size: 32rpx;
+ line-height: 32rpx;
+ font-weight: bold;
+ &__week-day {
+ padding: 6px 0;
+ overflow: hidden;
+ flex: 1;
+ flex-wrap: wrap;
+ &--end-date {
+ border-top-right-radius: 8rpx;
+ border-bottom-right-radius: 8rpx;
+ &--start-date {
+ border-top-left-radius: 8rpx;
+ border-bottom-left-radius: 8rpx;
+ &__item {
+ width: 14.2857%;
+ z-index: 2;
+ &__inner {
+ height: 84rpx;
+ display: -webkit-box;
+ display: -webkit-flex;
+ border-radius: 50%;
+ &__desc {
+ transform: scale(0.75);
+ bottom: 2rpx;
+ bottom: 8rpx;
+ &__bg-month {
+ font-size: 130px;
+ line-height: 130px;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ color: #e4e7ed;
+ &__bottom {
+ padding: 0 40rpx 30rpx;
+ &__choose {
+ &__btn {
@@ -0,0 +1,254 @@
+ <view class="u-keyboard" @touchmove.stop.prevent>
+ <view class="u-keyboard-grids">
+ <block>
+ <view class="u-keyboard-grids-item" v-for="(group, i) in abc ? EngKeyBoardList : areaList" :key="i">
+ <view :hover-stay-time="100" @tap="carInputClick(i, j)" hover-class="u-carinput-hover" class="u-keyboard-grids-btn"
+ v-for="(item, j) in group" :key="j">
+ {{ item }}
+ <view @touchstart="backspaceClick" @touchend="clearTimer" :hover-stay-time="100" class="u-keyboard-back"
+ hover-class="u-hover-class">
+ <u-icon :size="38" name="backspace" :bold="true"></u-icon>
+ <view :hover-stay-time="100" class="u-keyboard-change" hover-class="u-carinput-hover" @tap="changeCarInputMode">
+ <text class="zh" :class="[!abc ? 'active' : 'inactive']">中</text>
+ /
+ <text class="en" :class="[abc ? 'active' : 'inactive']">英</text>
+ name: "u-keyboard",
+ // 是否打乱键盘按键的顺序
+ random: {
+ // 车牌输入时,abc=true为输入车牌号码,bac=false为输入省份中文简称
+ abc: false
+ areaList() {
+ let data = [
+ '京',
+ '沪',
+ '粤',
+ '津',
+ '冀',
+ '豫',
+ '云',
+ '辽',
+ '黑',
+ '湘',
+ '皖',
+ '鲁',
+ '苏',
+ '浙',
+ '赣',
+ '鄂',
+ '桂',
+ '甘',
+ '晋',
+ '陕',
+ '蒙',
+ '吉',
+ '闽',
+ '贵',
+ '渝',
+ '川',
+ '青',
+ '琼',
+ '宁',
+ '挂',
+ '藏',
+ '港',
+ '澳',
+ '新',
+ '使',
+ '学'
+ let tmp = [];
+ // 打乱顺序
+ if (this.random) data = this.$u.randomArray(data);
+ // 切割成二维数组
+ tmp[0] = data.slice(0, 10);
+ tmp[1] = data.slice(10, 20);
+ tmp[2] = data.slice(20, 30);
+ tmp[3] = data.slice(30, 36);
+ return tmp;
+ EngKeyBoardList() {
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 0,
+ 'Q',
+ 'W',
+ 'E',
+ 'R',
+ 'T',
+ 'Y',
+ 'U',
+ 'I',
+ 'O',
+ 'P',
+ 'A',
+ 'S',
+ 'D',
+ 'F',
+ 'G',
+ 'H',
+ 'J',
+ 'K',
+ 'L',
+ 'Z',
+ 'X',
+ 'C',
+ 'V',
+ 'B',
+ 'N',
+ 'M'
+ // 点击键盘按钮
+ carInputClick(i, j) {
+ let value = '';
+ // 不同模式,获取不同数组的值
+ if (this.abc) value = this.EngKeyBoardList[i][j];
+ else value = this.areaList[i][j];
+ this.$emit('change', value);
+ // 修改汽车牌键盘的输入模式,中文|英文
+ changeCarInputMode() {
+ this.abc = !this.abc;
+ // 点击退格键
+ backspaceClick() {
+ this.$emit('backspace');
+ clearInterval(this.timer); //再次清空定时器,防止重复注册定时器
+ this.timer = null;
+ this.timer = setInterval(() => {
+ }, 250);
+ clearTimer() {
+ clearInterval(this.timer);
+ .u-keyboard-grids {
+ background: rgb(215, 215, 217);
+ padding: 24rpx 0;
+ .u-keyboard-grids-item {
+ .u-keyboard-grids-btn {
+ text-decoration: none;
+ width: 62rpx;
+ flex: 0 0 64rpx;
+ font-size: 36rpx;
+ margin: 8rpx 5rpx;
+ box-shadow: 0 2rpx 0rpx #888992;
+ font-weight: 500;
+ .u-carinput-hover {
+ background-color: rgb(185, 188, 195) !important;
+ .u-keyboard-back {
+ width: 96rpx;
+ right: 22rpx;
+ bottom: 32rpx;
+ background-color: rgb(185, 188, 195);
+ .u-keyboard-change {
+ left: 22rpx;
+ .u-keyboard-change .inactive.zh {
+ transform: scale(0.85) translateY(-10rpx);
+ .u-keyboard-change .inactive.en {
+ transform: scale(0.85) translateY(10rpx);
+ .u-keyboard-change .active {
+ color: rgb(237, 112, 64);
+ .u-keyboard-change .zh {
+ transform: translateY(-10rpx);
+ .u-keyboard-change .en {
+ transform: translateY(10rpx);
@@ -0,0 +1,298 @@
+ class="u-card"
+ @tap.stop="click"
+ :class="{ 'u-border': border, 'u-card-full': full, 'u-card--border': borderRadius > 0 }"
+ borderRadius: borderRadius + 'rpx',
+ margin: margin,
+ boxShadow: boxShadow
+ v-if="showHead"
+ class="u-card__head"
+ :style="[{padding: padding + 'rpx'}, headStyle]"
+ :class="{
+ 'u-border-bottom': headBorderBottom
+ @tap="headClick"
+ <view v-if="!$slots.head" class="u-flex u-row-between">
+ <view class="u-card__head--left u-flex u-line-1" v-if="title">
+ <image
+ :src="thumb"
+ class="u-card__head--left__thumb"
+ mode="aspectfull"
+ v-if="thumb"
+ height: thumbWidth + 'rpx',
+ width: thumbWidth + 'rpx',
+ borderRadius: thumbCircle ? '100rpx' : '6rpx'
+ ></image>
+ <text
+ class="u-card__head--left__title u-line-1"
+ fontSize: titleSize + 'rpx',
+ color: titleColor
+ {{ title }}
+ </text>
+ <view class="u-card__head--right u-line-1" v-if="subTitle">
+ class="u-card__head__title__text"
+ fontSize: subTitleSize + 'rpx',
+ color: subTitleColor
+ {{ subTitle }}
+ <slot name="head" v-else />
+ <view @tap="bodyClick" class="u-card__body" :style="[{padding: padding + 'rpx'}, bodyStyle]"><slot name="body" /></view>
+ v-if="showFoot"
+ class="u-card__foot"
+ @tap="footClick"
+ :style="[{padding: $slots.foot ? padding + 'rpx' : 0}, footStyle]"
+ 'u-border-top': footBorderTop
+ <slot name="foot" />
+ * card 卡片
+ * @description 卡片组件一般用于多个列表条目,且风格统一的场景
+ * @tutorial https://www.uviewui.com/components/line.html
+ * @property {Boolean} full 卡片与屏幕两侧是否留空隙(默认false)
+ * @property {String} title 头部左边的标题
+ * @property {String} title-color 标题颜色(默认#303133)
+ * @property {String | Number} title-size 标题字体大小,单位rpx(默认30)
+ * @property {String} sub-title 头部右边的副标题
+ * @property {String} sub-title-color 副标题颜色(默认#909399)
+ * @property {String | Number} sub-title-size 副标题字体大小(默认26)
+ * @property {Boolean} border 是否显示边框(默认true)
+ * @property {String | Number} index 用于标识点击了第几个卡片
+ * @property {String} box-shadow 卡片外围阴影,字符串形式(默认none)
+ * @property {String} margin 卡片与屏幕两边和上下元素的间距,需带单位,如"30rpx 20rpx"(默认30rpx)
+ * @property {String | Number} border-radius 卡片整体的圆角值,单位rpx(默认16)
+ * @property {Object} head-style 头部自定义样式,对象形式
+ * @property {Object} body-style 中部自定义样式,对象形式
+ * @property {Object} foot-style 底部自定义样式,对象形式
+ * @property {Boolean} head-border-bottom 是否显示头部的下边框(默认true)
+ * @property {Boolean} foot-border-top 是否显示底部的上边框(默认true)
+ * @property {Boolean} show-head 是否显示头部(默认true)
+ * @property {Boolean} show-head 是否显示尾部(默认true)
+ * @property {String} thumb 缩略图路径,如设置将显示在标题的左边,不建议使用相对路径
+ * @property {String | Number} thumb-width 缩略图的宽度,高等于宽,单位rpx(默认60)
+ * @property {Boolean} thumb-circle 缩略图是否为圆形(默认false)
+ * @event {Function} click 整个卡片任意位置被点击时触发
+ * @event {Function} head-click 卡片头部被点击时触发
+ * @event {Function} body-click 卡片主体部分被点击时触发
+ * @event {Function} foot-click 卡片底部部分被点击时触发
+ * @example <u-card padding="30" title="card"></u-card>
+ name: 'u-card',
+ // 与屏幕两侧是否留空隙
+ full: {
+ // 标题
+ // 标题颜色
+ titleColor: {
+ // 标题字体大小,单位rpx
+ titleSize: {
+ default: '30'
+ // 副标题
+ subTitle: {
+ // 副标题颜色
+ subTitleColor: {
+ // 副标题字体大小,单位rpx
+ subTitleSize: {
+ default: '26'
+ // 是否显示外部边框,只对full=false时有效(卡片与边框有空隙时)
+ border: {
+ // 用于标识点击了第几个
+ index: {
+ type: [Number, String, Object],
+ // 用于隔开上下左右的边距,带单位的写法,如:"30rpx 30rpx","20rpx 20rpx 30rpx 30rpx"
+ margin: {
+ default: '30rpx'
+ // card卡片的圆角
+ default: '16'
+ // 头部自定义样式,对象形式
+ headStyle: {
+ // 主体自定义样式,对象形式
+ bodyStyle: {
+ // 底部自定义样式,对象形式
+ footStyle: {
+ // 头部是否下边框
+ headBorderBottom: {
+ // 底部是否有上边框
+ footBorderTop: {
+ // 标题左边的缩略图
+ thumb: {
+ // 缩略图宽高,单位rpx
+ thumbWidth: {
+ default: '60'
+ // 缩略图是否为圆形
+ thumbCircle: {
+ // 给head,body,foot的内边距
+ padding: {
+ // 是否显示头部
+ showHead: {
+ // 是否显示尾部
+ showFoot: {
+ // 卡片外围阴影,字符串形式
+ boxShadow: {
+ default: 'none'
+ this.$emit('click', this.index);
+ headClick() {
+ this.$emit('head-click', this.index);
+ bodyClick() {
+ this.$emit('body-click', this.index);
+ footClick() {
+ this.$emit('foot-click', this.index);
+@import "../../libs/css/style.components.scss";
+.u-card {
+ &-full {
+ // 如果是与屏幕之间不留空隙,应该设置左右边距为0
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+ &--border:after {
+ border-radius: 16rpx;
+ &__head {
+ &--left {
+ &__thumb {
+ &__title {
+ max-width: 400rpx;
+ &--right {
+ margin-left: 6rpx;
+ &__body {
+ &__foot {
@@ -0,0 +1,69 @@
+ <view class="u-cell-box">
+ <view class="u-cell-title" v-if="title" :style="[titleStyle]">
+ <view class="u-cell-item-box" :class="{'u-border-bottom u-border-top': border}">
+ <slot />
+ * cellGroup 单元格父组件Group
+ * @description cell单元格一般用于一组列表的情况,比如个人中心页,设置页等。搭配u-cell-item
+ * @tutorial https://www.uviewui.com/components/cell.html
+ * @property {String} title 分组标题
+ * @property {Boolean} border 是否显示外边框(默认true)
+ * @property {Object} title-style 分组标题的的样式,对象形式,如{'font-size': '24rpx'} 或 {'fontSize': '24rpx'}
+ * @example <u-cell-group title="设置喜好">
+ name: "u-cell-group",
+ // 分组标题
+ // 是否显示分组list上下边框
+ // 分组标题的样式,对象形式,注意驼峰属性写法
+ // 类似 {'font-size': '24rpx'} 和 {'fontSize': '24rpx'}
+ index: 0,
+ .u-cell-box {
+ .u-cell-title {
+ padding: 30rpx 32rpx 10rpx 32rpx;
+ .u-cell-item-box {
+ background-color: #FFFFFF;
@@ -0,0 +1,300 @@
+ @tap="click"
+ class="u-cell"
+ :class="{ 'u-border-bottom': borderBottom, 'u-border-top': borderTop, 'u-col-center': center, 'u-cell--required': required }"
+ hover-stay-time="150"
+ :hover-class="hoverClass"
+ <u-icon :size="iconSize" :name="icon" v-if="icon" :custom-style="iconStyle" class="u-cell__left-icon-wrap"></u-icon>
+ <view class="u-flex" v-else>
+ <slot name="icon"></slot>
+ class="u-cell_title"
+ :style="[
+ width: titleWidth ? titleWidth + 'rpx' : 'auto'
+ titleStyle
+ <block v-if="title">{{ title }}</block>
+ <slot name="title" v-else></slot>
+ <view class="u-cell__label" v-if="label || $slots.label" :style="[labelStyle]">
+ <block v-if="label">{{ label }}</block>
+ <slot name="label" v-else></slot>
+ <view class="u-cell__value" :style="[valueStyle]">
+ <block class="u-cell__value" v-if="value">{{ value }}</block>
+ <slot v-else></slot>
+ <view class="u-flex" v-if="$slots['right-icon']">
+ <slot name="right-icon"></slot>
+ <u-icon v-if="arrow" name="arrow-right" :style="[arrowStyle]" class="u-icon-wrap u-cell__right-icon-wrap"></u-icon>
+ * cellItem 单元格Item
+ * @description cell单元格一般用于一组列表的情况,比如个人中心页,设置页等。搭配u-cell-group使用
+ * @property {String} title 左侧标题
+ * @property {String} icon 左侧图标名,只支持uView内置图标,见Icon 图标
+ * @property {Object} icon-style 左边图标的样式,对象形式
+ * @property {String} value 右侧内容
+ * @property {String} label 标题下方的描述信息
+ * @property {Boolean} border-bottom 是否显示cell的下边框(默认true)
+ * @property {Boolean} border-top 是否显示cell的上边框(默认false)
+ * @property {Boolean} center 是否使内容垂直居中(默认false)
+ * @property {String} hover-class 是否开启点击反馈,none为无效果(默认true)
+ * // @property {Boolean} border-gap border-bottom为true时,Cell列表中间的条目的下边框是否与左边有一个间隔(默认true)
+ * @property {Boolean} arrow 是否显示右侧箭头(默认true)
+ * @property {Boolean} required 箭头方向,可选值(默认right)
+ * @property {Boolean} arrow-direction 是否显示左边表示必填的星号(默认false)
+ * @property {Object} title-style 标题样式,对象形式
+ * @property {Object} value-style 右侧内容样式,对象形式
+ * @property {Object} label-style 标题下方描述信息的样式,对象形式
+ * @property {String} bg-color 背景颜色(默认transparent)
+ * @property {String Number} index 用于在click事件回调中返回,标识当前是第几个Item
+ * @property {String Number} title-width 标题的宽度,单位rpx
+ * @example <u-cell-item icon="integral-fill" title="会员等级" value="新版本"></u-cell-item>
+ name: 'u-cell-item',
+ // 左侧图标名称(只能uView内置图标),或者图标src
+ // 左侧标题
+ // 右侧内容
+ // 标题下方的描述信息
+ label: {
+ // 是否显示下边框
+ borderBottom: {
+ // 是否显示上边框
+ borderTop: {
+ // 多个cell中,中间的cell显示下划线时,下划线是否给一个到左边的距离
+ // 1.4.0版本废除此参数,默认边框由border-top和border-bottom提供,此参数会造成干扰
+ // borderGap: {
+ // type: Boolean,
+ // default: true
+ // 是否开启点击反馈,即点击时cell背景为灰色,none为无效果
+ default: 'u-cell-hover'
+ // 是否显示右侧箭头
+ arrow: {
+ // 内容是否垂直居中
+ center: {
+ // 是否显示左边表示必填的星号
+ required: {
+ // 标题的宽度,单位rpx
+ titleWidth: {
+ // 右侧箭头方向,可选值:right|up|down,默认为right
+ arrowDirection: {
+ default: 'right'
+ // 控制标题的样式
+ // 右侧显示内容的样式
+ valueStyle: {
+ // 描述信息的样式
+ labelStyle: {
+ default: 'transparent'
+ // 用于识别被点击的是第几个cell
+ // 是否使用lable插槽
+ useLabelSlot: {
+ // 左边图标的大小,单位rpx,只对传入icon字段时有效
+ iconSize: {
+ default: 34
+ // 左边图标的样式,对象形式
+ arrowStyle() {
+ if (this.arrowDirection == 'up') style.transform = 'rotate(-90deg)';
+ else if (this.arrowDirection == 'down') style.transform = 'rotate(90deg)';
+ else style.transform = 'rotate(0deg)';
+.u-cell {
+ padding: 26rpx 32rpx;
+ line-height: 54rpx;
+.u-cell_title {
+.u-cell__left-icon-wrap {
+ margin-right: 10rpx;
+.u-cell__right-icon-wrap {
+ margin-left: 10rpx;
+ color: #969799;
+.u-cell__left-icon-wrap,
+ height: 48rpx;
+.u-cell-border:after {
+ right: 0;
+ border-bottom: 1px solid $u-border-color;
+ -webkit-transform: scaleY(0.5);
+ transform: scaleY(0.5);
+.u-cell-border {
+.u-cell__label {
+ margin-top: 6rpx;
+ line-height: 36rpx;
+ word-wrap: break-word;
+.u-cell__value {
+ vertical-align: middle;
+.u-cell__title,
+.u-cell--required {
+.u-cell--required:before {
+ content: '*';
+ left: 8px;
+ margin-top: 4rpx;
+ font-size: 14px;
+ color: $u-type-error;
@@ -0,0 +1,122 @@
+ <view class="u-checkbox-group u-clearfix">
+ import Emitter from '../../libs/util/emitter.js';
+ * checkboxGroup 开关选择器父组件Group
+ * @description 复选框组件一般用于需要多个选择的场景,该组件功能完整,使用方便
+ * @tutorial https://www.uviewui.com/components/checkbox.html
+ * @property {String Number} max 最多能选中多少个checkbox(默认999)
+ * @property {String Number} size 组件整体的大小,单位rpx(默认40)
+ * @property {Boolean} disabled 是否禁用所有checkbox(默认false)
+ * @property {String Number} icon-size 图标大小,单位rpx(默认20)
+ * @property {Boolean} label-disabled 是否禁止点击文本操作checkbox(默认false)
+ * @property {String} width 宽度,需带单位
+ * @property {String} shape 外观形状,shape-方形,circle-圆形(默认circle)
+ * @property {Boolean} wrap 是否每个checkbox都换行(默认false)
+ * @property {String} active-color 选中时的颜色,应用到所有子Checkbox组件(默认#2979ff)
+ * @event {Function} change 任一个checkbox状态发生变化时触发,回调为一个对象
+ * @example <u-checkbox-group></u-checkbox-group>
+ name: 'u-checkbox-group',
+ mixins: [Emitter],
+ // 最多能选中多少个checkbox
+ max: {
+ default: 999
+ // 所有选中项的 name
+ // value: {
+ // default: Array,
+ // default() {
+ // return []
+ // 是否禁用所有复选框
+ // 在表单内提交时的标识符
+ name: {
+ type: [Boolean, String],
+ // 是否禁止点击提示语选中复选框
+ labelDisabled: {
+ // 形状,square为方形,circle为原型
+ // 选中状态下的颜色
+ // 组件的整体大小
+ // 每个checkbox占u-checkbox-group的宽度
+ default: 'auto'
+ // 是否每个checkbox都换行
+ wrap: {
+ // 图标的大小,单位rpx
+ // 如果将children定义在data中,在微信小程序会造成循环引用而报错
+ this.children = [];
+ emitEvent() {
+ let values = [];
+ this.children.map(val => {
+ if(val.value) values.push(val.name);
+ this.$emit('change', values);
+ // 发出事件,用于在表单组件中嵌入checkbox的情况,进行验证
+ // 将当前的值发送到 u-form-item 进行校验
+ this.dispatch('u-form-item', 'on-form-change', values);
+ .u-checkbox-group {
+ /* #ifndef MP */
+ /* #endif */
@@ -0,0 +1,275 @@
+ <view class="u-checkbox" :style="[checkboxStyle]">
+ <view class="u-checkbox__icon-wrap" @tap="toggle" :class="[iconClass]" :style="[iconStyle]">
+ <u-icon name="checkbox-mark" :size="checkboxIconSize" :color="iconColor"/>
+ <view class="u-checkbox__label" @tap="onClickLabel" :style="{
+ fontSize: $u.addUnit(labelSize)
+ * checkbox 复选框
+ * @description 该组件需要搭配checkboxGroup组件使用,以便用户进行操作时,获得当前复选框组的选中情况。
+ * @property {String Number} label-size label字体大小,单位rpx(默认28)
+ * @property {String Number} name checkbox组件的标示符
+ * @property {String} shape 形状,见官网说明(默认circle)
+ * @property {Boolean} label-disabled 是否禁止点击文本操作checkbox
+ * @property {String} active-color 选中时的颜色,如设置CheckboxGroup的active-color将失效
+ * @event {Function} change 某个checkbox状态发生变化时触发,回调为一个对象
+ * @example <u-checkbox v-model="checked" :disabled="false">天涯</u-checkbox>
+ name: "u-checkbox",
+ // checkbox的名称
+ // 是否为选中状态
+ // 是否禁用
+ type: [String, Boolean],
+ // 选中状态下的颜色,如设置此值,将会覆盖checkboxGroup的activeColor值
+ // label的字体大小,rpx单位
+ labelSize: {
+ parentDisabled: false,
+ newParams: {},
+ // 支付宝小程序不支持provide/inject,所以使用这个方法获取整个父组件,在created定义,避免循环应用
+ this.parent = this.$u.$parent.call(this, 'u-checkbox-group');
+ // 如果存在u-checkbox-group,将本组件的this塞进父组件的children中
+ this.parent && this.parent.children.push(this);
+ // 是否禁用,如果父组件u-checkbox-group禁用的话,将会忽略子组件的配置
+ isDisabled() {
+ return this.disabled !== '' ? this.disabled : this.parent ? this.parent.disabled : false;
+ // 是否禁用label点击
+ isLabelDisabled() {
+ return this.labelDisabled !== '' ? this.labelDisabled : this.parent ? this.parent.labelDisabled : false;
+ // 组件尺寸,对应size的值,默认值为34rpx
+ checkboxSize() {
+ return this.size ? this.size : (this.parent ? this.parent.size : 34);
+ // 组件的勾选图标的尺寸,默认20
+ checkboxIconSize() {
+ return this.iconSize ? this.iconSize : (this.parent ? this.parent.iconSize : 20);
+ // 组件选中激活时的颜色
+ elActiveColor() {
+ return this.activeColor ? this.activeColor : (this.parent ? this.parent.activeColor : 'primary');
+ // 组件的形状
+ elShape() {
+ return this.shape ? this.shape : (this.parent ? this.parent.shape : 'square');
+ iconStyle() {
+ // 既要判断是否手动禁用,还要判断用户v-model绑定的值,如果绑定为false,那么也无法选中
+ if (this.elActiveColor && this.value && !this.isDisabled) {
+ style.borderColor = this.elActiveColor;
+ style.backgroundColor = this.elActiveColor;
+ style.width = this.$u.addUnit(this.checkboxSize);
+ style.height = this.$u.addUnit(this.checkboxSize);
+ // checkbox内部的勾选图标,如果选中状态,为白色,否则为透明色即可
+ iconColor() {
+ return this.value ? '#ffffff' : 'transparent';
+ iconClass() {
+ let classes = [];
+ classes.push('u-checkbox__icon-wrap--' + this.elShape);
+ if (this.value == true) classes.push('u-checkbox__icon-wrap--checked');
+ if (this.isDisabled) classes.push('u-checkbox__icon-wrap--disabled');
+ if (this.value && this.isDisabled) classes.push('u-checkbox__icon-wrap--disabled--checked');
+ // 支付宝小程序无法动态绑定一个数组类名,否则解析出来的结果会带有",",而导致失效
+ return classes.join(' ');
+ checkboxStyle() {
+ if(this.parent && this.parent.width) {
+ style.width = this.parent.width;
+ // #ifdef MP
+ // 各家小程序因为它们特殊的编译结构,使用float布局
+ style.float = 'left';
+ // #ifndef MP
+ // H5和APP使用flex布局
+ style.flex = `0 0 ${this.parent.width}`;
+ if(this.parent && this.parent.wrap) {
+ style.width = '100%';
+ // H5和APP使用flex布局,将宽度设置100%,即可自动换行
+ style.flex = '0 0 100%';
+ onClickLabel() {
+ if (!this.isLabelDisabled && !this.isDisabled) {
+ this.setValue();
+ toggle() {
+ if (!this.isDisabled) {
+ value: this.value,
+ name: this.name
+ // 执行父组件u-checkbox-group的事件方法
+ if(this.parent && this.parent.emitEvent) this.parent.emitEvent();
+ // 设置input的值,这里通过input事件,设置通过v-model绑定的组件的值
+ setValue() {
+ // 判断是否超过了可选的最大数量
+ let checkedNum = 0;
+ if(this.parent && this.parent.children) {
+ // 只要父组件的某一个子元素的value为true,就加1(已有的选中数量)
+ this.parent.children.map(val => {
+ if (val.value) checkedNum++;
+ // 如果原来为选中状态,那么可以取消
+ if (this.value == true) {
+ this.$emit('input', !this.value);
+ // 等待下一个周期再执行,因为this.$emit('input')作用于父组件,再反馈到子组件内部,需要时间
+ this.emitEvent();
+ } else if ((this.parent && checkedNum < this.parent.max) || !this.parent) {
+ // 如果原来为未选中状态,需要选中的数量少于父组件中设置的max值,才可以选中
+ .u-checkbox {
+ line-height: 1.8;
+ &__icon-wrap {
+ flex: none;
+ width: 42rpx;
+ height: 42rpx;
+ color: transparent;
+ transition-property: color, border-color, background-color;
+ font-size: 20px;
+ border: 1px solid #c8c9cc;
+ transition-duration: 0.2s;
+ &--circle {
+ &--square {
+ border-radius: 6rpx;
+ &--checked {
+ &--disabled {
+ background-color: #ebedf0;
+ border-color: #c8c9cc;
+ &--disabled--checked {
+ color: #c8c9cc !important;
+ &__label {
+ margin-right: 24rpx;
+ color: #c8c9cc;
@@ -0,0 +1,218 @@
+ class="u-circle-progress"
+ width: widthPx + 'px',
+ height: widthPx + 'px',
+ <!-- 支付宝小程序不支持canvas-id属性,必须用id属性 -->
+ class="u-canvas-bg"
+ :canvas-id="elBgId"
+ :id="elBgId"
+ height: widthPx + 'px'
+ class="u-canvas"
+ :canvas-id="elId"
+ :id="elId"
+ * circleProgress 环形进度条
+ * @description 展示操作或任务的当前进度,比如上传文件,是一个圆形的进度条。注意:此组件的percent值只能动态增加,不能动态减少。
+ * @tutorial https://www.uviewui.com/components/circleProgress.html
+ * @property {String Number} percent 圆环进度百分比值,为数值类型,0-100
+ * @property {String} inactive-color 圆环的底色,默认为灰色(该值无法动态变更)(默认#ececec)
+ * @property {String} active-color 圆环激活部分的颜色(该值无法动态变更)(默认#19be6b)
+ * @property {String Number} width 整个圆环组件的宽度,高度默认等于宽度值,单位rpx(默认200)
+ * @property {String Number} border-width 圆环的边框宽度,单位rpx(默认14)
+ * @property {String Number} duration 整个圆环执行一圈的时间,单位ms(默认呢1500)
+ * @property {String} type 如设置,active-color值将会失效
+ * @property {String} bg-color 整个组件背景颜色,默认为白色
+ * @example <u-circle-progress active-color="#2979ff" :percent="80"></u-circle-progress>
+ name: 'u-circle-progress',
+ // 圆环进度百分比值
+ percent: {
+ default: 0,
+ // 限制值在0到100之间
+ validator: val => {
+ return val >= 0 && val <= 100;
+ // 底部圆环的颜色(灰色的圆环)
+ inactiveColor: {
+ default: '#ececec'
+ // 圆环激活部分的颜色
+ default: '#19be6b'
+ // 圆环线条的宽度,单位rpx
+ borderWidth: {
+ default: 14
+ // 整个圆形的宽度,单位rpx
+ // 整个圆环执行一圈的时间,单位ms
+ default: 1500
+ // 主题类型
+ // 整个圆环进度区域的背景色
+ // #ifdef MP-WEIXIN
+ elBgId: 'uCircleProgressBgId', // 微信小程序中不能使用this.$u.guid()形式动态生成id值,否则会报错
+ elId: 'uCircleProgressElId',
+ // #ifndef MP-WEIXIN
+ elBgId: this.$u.guid(), // 非微信端的时候,需用动态的id,否则一个页面多个圆形进度条组件数据会混乱
+ elId: this.$u.guid(),
+ widthPx: uni.upx2px(this.width), // 转成px后的整个组件的背景宽度
+ borderWidthPx: uni.upx2px(this.borderWidth), // 转成px后的圆环的宽度
+ startAngle: -Math.PI / 2, // canvas画圆的起始角度,默认为3点钟方向,定位到12点钟方向
+ progressContext: null, // 活动圆的canvas上下文
+ newPercent: 0, // 当动态修改进度值的时候,保存进度值的变化前后值,用于比较用
+ oldPercent: 0 // 当动态修改进度值的时候,保存进度值的变化前后值,用于比较用
+ percent(nVal, oVal = 0) {
+ if (nVal > 100) nVal = 100;
+ if (nVal < 0) oVal = 0;
+ // 此值其实等于this.percent,命名一个新
+ this.newPercent = nVal;
+ this.oldPercent = oVal;
+ setTimeout(() => {
+ // 无论是百分比值增加还是减少,需要操作还是原来的旧的百分比值
+ // 将此值减少或者新增到新的百分比值
+ this.drawCircleByProgress(oVal);
+ }, 50);
+ // 赋值,用于加载后第一个画圆使用
+ this.newPercent = this.percent;
+ this.oldPercent = 0;
+ // 有type主题时,优先起作用
+ circleColor() {
+ if (['success', 'error', 'info', 'primary', 'warning'].indexOf(this.type) >= 0) return this.$u.color[this.type];
+ else return this.activeColor;
+ // 在h5端,必须要做一点延时才起作用,this.$nextTick()无效(HX2.4.7)
+ this.drawProgressBg();
+ this.drawCircleByProgress(this.oldPercent);
+ drawProgressBg() {
+ let ctx = uni.createCanvasContext(this.elBgId, this);
+ ctx.setLineWidth(this.borderWidthPx); // 设置圆环宽度
+ ctx.setStrokeStyle(this.inactiveColor); // 线条颜色
+ ctx.beginPath(); // 开始描绘路径
+ // 设置一个原点(110,110),半径为100的圆的路径到当前路径
+ let radius = this.widthPx / 2;
+ ctx.arc(radius, radius, radius - this.borderWidthPx, 0, 2 * Math.PI, false);
+ ctx.stroke(); // 对路径进行描绘
+ ctx.draw();
+ drawCircleByProgress(progress) {
+ // 第一次操作进度环时将上下文保存到了this.data中,直接使用即可
+ let ctx = this.progressContext;
+ if (!ctx) {
+ ctx = uni.createCanvasContext(this.elId, this);
+ this.progressContext = ctx;
+ // 表示进度的两端为圆形
+ ctx.setLineCap('round');
+ // 设置线条的宽度和颜色
+ ctx.setLineWidth(this.borderWidthPx);
+ ctx.setStrokeStyle(this.circleColor);
+ // 将总过渡时间除以100,得出每修改百分之一进度所需的时间
+ let time = Math.floor(this.duration / 100);
+ // 结束角的计算依据为:将2π分为100份,乘以当前的进度值,得出终止点的弧度值,加起始角,为整个圆从默认的
+ // 3点钟方向开始画图,转为更好理解的12点钟方向开始作图,这需要起始角和终止角同时加上this.startAngle值
+ let endAngle = ((2 * Math.PI) / 100) * progress + this.startAngle;
+ ctx.beginPath();
+ // 半径为整个canvas宽度的一半
+ ctx.arc(radius, radius, radius - this.borderWidthPx, this.startAngle, endAngle, false);
+ ctx.stroke();
+ // 如果变更后新值大于旧值,意味着增大了百分比
+ if (this.newPercent > this.oldPercent) {
+ // 每次递增百分之一
+ progress++;
+ // 如果新增后的值,大于需要设置的值百分比值,停止继续增加
+ if (progress > this.newPercent) return;
+ // 同理于上面
+ progress--;
+ if (progress < this.newPercent) return;
+ // 定时器,每次操作间隔为time值,为了让进度条有动画效果
+ this.drawCircleByProgress(progress);
+ }, time);
+.u-circle-progress {
+.u-canvas-bg {
+.u-canvas {
@@ -0,0 +1,135 @@
+ <view class="u-col" :class="[
+ 'u-col-' + span
+ padding: `0 ${Number(gutter)/2 + 'rpx'}`,
+ marginLeft: 100 / 12 * offset + '%',
+ flex: `0 0 ${100 / 12 * span}%`,
+ alignItems: uAlignItem,
+ justifyContent: uJustify,
+ textAlign: textAlign
+ }" @tap.stop.prevent="click">
+ * col 布局单元格
+ * @description 通过基础的 12 分栏,迅速简便地创建布局(搭配<u-row>使用)
+ * @tutorial https://www.uviewui.com/components/layout.html
+ * @property {String Number} span 栅格占据的列数,总12等分(默认0)
+ * @property {String} text-align 文字水平对齐方式(默认left)
+ * @property {String Number} offset 分栏左边偏移,计算方式与span相同(默认0)
+ * @example <u-col span="3"><view class="demo-layout bg-purple"></view></u-col>
+ name: "u-col",
+ // 占父容器宽度的多少等分,总分为12份
+ span: {
+ default: 12
+ // 指定栅格左侧的间隔数(总12栏)
+ // 水平排列方式,可选值为`start`(或`flex-start`)、`end`(或`flex-end`)、`center`、`around`(或`space-around`)、`between`(或`space-between`)
+ justify: {
+ default: 'start'
+ // 垂直对齐方式,可选值为top、center、bottom
+ align: {
+ default: 'center'
+ // 文字对齐方式
+ textAlign: {
+ default: 'left'
+ inject: ['gutter'],
+ uJustify() {
+ if (this.justify == 'end' || this.justify == 'start') return 'flex-' + this.justify;
+ else if (this.justify == 'around' || this.justify == 'between') return 'space-' + this.justify;
+ else return this.justify;
+ uAlignItem() {
+ if (this.align == 'top') return 'flex-start';
+ if (this.align == 'bottom') return 'flex-end';
+ else return this.align;
+ .u-col {
+ /* #ifdef MP-WEIXIN || MP-QQ */
+ float: left;
+ .u-col-0 {
+ width: 0;
+ .u-col-1 {
+ width: calc(100%/12);
+ .u-col-2 {
+ width: calc(100%/12 * 2);
+ .u-col-3 {
+ width: calc(100%/12 * 3);
+ .u-col-4 {
+ width: calc(100%/12 * 4);
+ .u-col-5 {
+ width: calc(100%/12 * 5);
+ .u-col-6 {
+ width: calc(100%/12 * 6);
+ .u-col-7 {
+ width: calc(100%/12 * 7);
+ .u-col-8 {
+ width: calc(100%/12 * 8);
+ .u-col-9 {
+ width: calc(100%/12 * 9);
+ .u-col-10 {
+ width: calc(100%/12 * 10);
+ .u-col-11 {
+ width: calc(100%/12 * 11);
+ .u-col-12 {
+ width: calc(100%/12 * 12);
@@ -0,0 +1,206 @@
+ <view class="u-collapse-item" :style="[itemStyle]">
+ <view :hover-stay-time="200" class="u-collapse-head" @tap.stop="headClick" :hover-class="hoverClass" :style="[headStyle]">
+ <block v-if="!$slots['title-all']">
+ <view v-if="!$slots['title']" class="u-collapse-title u-line-1" :style="[{ textAlign: align ? align : 'left' },
+ isShow && activeStyle && !arrow ? activeStyle : '']">
+ <slot v-else name="title" />
+ <u-icon v-if="arrow" :color="arrowColor" :class="{ 'u-arrow-down-icon-active': isShow }"
+ class="u-arrow-down-icon" name="arrow-down"></u-icon>
+ <slot v-else name="title-all" />
+ <view class="u-collapse-body" :style="[{
+ height: isShow ? height + 'px' : '0'
+ }]">
+ <view class="u-collapse-content" :id="elId" :style="[bodyStyle]">
+ * collapseItem 手风琴Item
+ * @description 通过折叠面板收纳内容区域(搭配u-collapse使用)
+ * @tutorial https://www.uviewui.com/components/collapse.html
+ * @property {String} title 面板标题
+ * @property {String Number} index 主要用于事件的回调,标识那个Item被点击
+ * @property {Boolean} disabled 面板是否可以打开或收起(默认false)
+ * @property {Boolean} open 设置某个面板的初始状态是否打开(默认false)
+ * @property {String Number} name 唯一标识符,如不设置,默认用当前collapse-item的索引值
+ * @property {String} align 标题的对齐方式(默认left)
+ * @property {Object} active-style 不显示箭头时,可以添加当前选择的collapse-item活动样式,对象形式
+ * @event {Function} change 某个item被打开或者收起时触发
+ * @example <u-collapse-item :title="item.head" v-for="(item, index) in itemList" :key="index">{{item.body}}</u-collapse-item>
+ name: "u-collapse-item",
+ // 标题的对齐方式
+ // 是否可以点击收起
+ // collapse显示与否
+ open: {
+ // 唯一标识符
+ //活动样式
+ activeStyle: {
+ // 标识当前为第几个
+ inject: ['uCollapse'],
+ isShow: false,
+ height: 0, // body内容的高度
+ headStyle: {}, // 头部样式,对象形式
+ bodyStyle: {}, // 主体部分样式
+ //itemStyle: {}, // 每个item的整体样式
+ arrowColor: '', // 箭头的颜色
+ hoverClass: '', // 头部按下时的效果样式类
+ this.init();
+ open(val) {
+ this.isShow = val;
+ arrow() {
+ return this.uCollapse.arrow;
+ return this.uCollapse.itemStyle;
+ // 获取u-collapse的信息,放在u-collapse是为了方便,不用每个u-collapse-item写一遍
+ this.isShow = this.open;
+ this.nameSync = this.name ? this.name : this.uCollapse.childrens.length;
+ this.uCollapse.childrens.push(this);
+ //this.itemStyle = this.uCollapse.itemStyle;
+ this.headStyle = this.uCollapse.headStyle;
+ this.bodyStyle = this.uCollapse.bodyStyle;
+ this.arrowColor = this.uCollapse.arrowColor;
+ this.hoverClass = this.uCollapse.hoverClass;
+ // 异步获取内容,或者动态修改了内容时,需要重新初始化
+ this.queryRect();
+ // 点击collapsehead头部
+ if (this.disabled) return;
+ if (this.uCollapse.accordion == true) {
+ this.uCollapse.childrens.map(val => {
+ // 自身不设置为false,因为后面有this.isShow = !this.isShow;处理了
+ if (this != val) {
+ val.isShow = false;
+ this.isShow = !this.isShow;
+ // 触发本组件的事件
+ index: this.index,
+ show: this.isShow
+ // 只有在打开时才发出事件
+ if (this.isShow) this.uCollapse.onChange();
+ this.$forceUpdate();
+ // 查询内容高度
+ queryRect() {
+ // $uGetRect为uView自带的节点查询简化方法,详见文档介绍:https://www.uviewui.com/js/getRect.html
+ // 组件内部一般用this.$uGetRect,对外的为this.$u.getRect,二者功能一致,名称不同
+ this.$uGetRect('#' + this.elId).then(res => {
+ this.height = res.height;
+ .u-collapse-head {
+ .u-collapse-title {
+ .u-arrow-down-icon {
+ transition: all 0.3s;
+ margin-right: 20rpx;
+ margin-left: 14rpx;
+ .u-arrow-down-icon-active {
+ transform: rotate(180deg);
+ .u-collapse-body {
+ .u-collapse-content {
@@ -0,0 +1,104 @@
+ <view class="u-collapse">
+ * collapse 手风琴
+ * @description 通过折叠面板收纳内容区域
+ * @property {Boolean} accordion 是否手风琴模式(默认true)
+ * @property {Boolean} arrow 是否显示标题右侧的箭头(默认true)
+ * @property {String} arrow-color 标题右侧箭头的颜色(默认#909399)
+ * @property {Object} head-style 标题自定义样式,对象形式
+ * @property {Object} body-style 主体自定义样式,对象形式
+ * @property {String} hover-class 样式类名,按下时有效(默认u-hover-class)
+ * @event {Function} change 当前激活面板展开时触发(如果是手风琴模式,参数activeNames类型为String,否则为Array)
+ * @example <u-collapse></u-collapse>
+ name:"u-collapse",
+ // 是否手风琴模式
+ accordion: {
+ // 头部的样式
+ // 主体的样式
+ // 每一个item的样式
+ itemStyle: {
+ // 是否显示右侧的箭头
+ // 箭头的颜色
+ arrowColor: {
+ // 标题部分按压时的样式类,"none"为无效果
+ default: 'u-hover-class'
+ provide() {
+ uCollapse: this
+ this.childrens = []
+ // 重新初始化一次内部的所有子元素的高度计算,用于异步获取数据渲染的情况
+ this.childrens.forEach((vm, index) => {
+ vm.init();
+ // collapse item被点击,由collapse item调用父组件方法
+ onChange() {
+ let activeItem = [];
+ if (vm.isShow) {
+ activeItem.push(vm.nameSync);
+ // 如果是手风琴模式,只有一个匹配结果,也即activeItem长度为1,将其转为字符串
+ if (this.accordion) activeItem = activeItem.join('');
+ this.$emit('change', activeItem);
@@ -0,0 +1,233 @@
+ class="u-notice-bar"
+ background: computeBgColor,
+ padding: padding
+ type ? `u-type-${type}-light-bg` : ''
+ <u-icon class="u-left-icon" v-if="volumeIcon" name="volume-fill" :size="volumeSize" :color="computeColor"></u-icon>
+ <swiper :disable-touch="disableTouch" @change="change" :autoplay="autoplay && playState == 'play'" :vertical="vertical" circular :interval="duration" class="u-swiper">
+ <swiper-item v-for="(item, index) in list" :key="index" class="u-swiper-item">
+ class="u-news-item u-line-1"
+ :style="[textStyle]"
+ @tap="click(index)"
+ :class="['u-type-' + type]"
+ </swiper-item>
+ </swiper>
+ <u-icon @click="getMore" class="u-right-icon" v-if="moreIcon" name="arrow-right" :size="26" :color="computeColor"></u-icon>
+ <u-icon @click="close" class="u-right-icon" v-if="closeIcon" name="close" :size="24" :color="computeColor"></u-icon>
+ // 显示的内容,数组
+ // 显示的主题,success|error|primary|info|warning
+ // 是否显示左侧的音量图标
+ volumeIcon: {
+ // 是否显示右侧的右箭头图标
+ moreIcon: {
+ // 是否显示右侧的关闭图标
+ closeIcon: {
+ // 是否自动播放
+ autoplay: {
+ // 文字颜色,各图标也会使用文字颜色
+ // 滚动方向,row-水平滚动,column-垂直滚动
+ direction: {
+ default: 'row'
+ // 字体大小,单位rpx
+ default: 26
+ // 滚动一个周期的时间长,单位ms
+ default: 2000
+ // 音量喇叭的大小
+ volumeSize: {
+ // 水平滚动时的滚动速度,即每秒滚动多少rpx,这有利于控制文字无论多少时,都能有一个恒定的速度
+ speed: {
+ default: 160
+ // 水平滚动时,是否采用衔接形式滚动
+ isCircular: {
+ // 滚动方向,horizontal-水平滚动,vertical-垂直滚动
+ default: 'horizontal'
+ // 播放状态,play-播放,paused-暂停
+ playState: {
+ default: 'play'
+ // 是否禁止用手滑动切换
+ // 目前HX2.6.11,只支持App 2.5.5+、H5 2.5.5+、支付宝小程序、字节跳动小程序
+ disableTouch: {
+ // 通知的边距
+ default: '18rpx 24rpx'
+ // 计算字体颜色,如果没有自定义的,就用uview主题颜色
+ computeColor() {
+ if (this.color) return this.color;
+ // 如果是无主题,就默认使用content-color
+ else if(this.type == 'none') return '#606266';
+ else return this.type;
+ // 文字内容的样式
+ textStyle() {
+ if (this.color) style.color = this.color;
+ else if(this.type == 'none') style.color = '#606266';
+ style.fontSize = this.fontSize + 'rpx';
+ // 垂直或者水平滚动
+ vertical() {
+ if(this.mode == 'horizontal') return false;
+ // 计算背景颜色
+ computeBgColor() {
+ if (this.bgColor) return this.bgColor;
+ else if(this.type == 'none') return 'transparent';
+ // animation: false
+ // 点击通告栏
+ click(index) {
+ // 点击更多箭头按钮
+ getMore() {
+ this.$emit('getMore');
+ change(e) {
+ let index = e.detail.current;
+ if(index == this.list.length - 1) {
+ this.$emit('end');
+.u-notice-bar {
+ flex-wrap: nowrap;
+ padding: 18rpx 24rpx;
+.u-swiper {
+ height: 32rpx;
+ margin-left: 12rpx;
+.u-swiper-item {
+.u-news-item {
+.u-right-icon {
+.u-left-icon {
@@ -0,0 +1,315 @@
+ <view class="u-countdown">
+ <view class="u-countdown-item" :style="[itemStyle]" v-if="showDays && (hideZeroDay || (!hideZeroDay && d != '0'))">
+ <view class="u-countdown-time" :style="[letterStyle]">
+ {{ d }}
+ class="u-countdown-colon"
+ :style="{fontSize: separatorSize + 'rpx', color: separatorColor, paddingBottom: separator == 'colon' ? '4rpx' : 0}"
+ v-if="showDays && (hideZeroDay || (!hideZeroDay && d != '0'))"
+ {{ separator == 'colon' ? ':' : '天' }}
+ <view class="u-countdown-item" :style="[itemStyle]" v-if="showHours">
+ <view class="u-countdown-time" :style="{ fontSize: fontSize + 'rpx', color: color}">
+ {{ h }}
+ v-if="showHours"
+ {{ separator == 'colon' ? ':' : '时' }}
+ <view class="u-countdown-item" :style="[itemStyle]" v-if="showMinutes">
+ {{ i }}
+ v-if="showMinutes"
+ {{ separator == 'colon' ? ':' : '分' }}
+ <view class="u-countdown-item" :style="[itemStyle]" v-if="showSeconds">
+ {{ s }}
+ v-if="showSeconds && separator == 'zh'"
+ 秒
+ * countDown 倒计时
+ * @description 该组件一般使用于某个活动的截止时间上,通过数字的变化,给用户明确的时间感受,提示用户进行某一个行为操作。
+ * @tutorial https://www.uviewui.com/components/countDown.html
+ * @property {String Number} timestamp 倒计时,单位为秒
+ * @property {Boolean} autoplay 是否自动开始倒计时,如果为false,需手动调用开始方法。见官网说明(默认true)
+ * @property {String} separator 分隔符,colon为英文冒号,zh为中文(默认colon)
+ * @property {String Number} separator-size 分隔符的字体大小,单位rpx(默认30)
+ * @property {String} separator-color 分隔符的颜色(默认#303133)
+ * @property {String Number} font-size 倒计时字体大小,单位rpx(默认30)
+ * @property {Boolean} show-border 是否显示倒计时数字的边框(默认false)
+ * @property {Boolean} hide-zero-day 当"天"的部分为0时,隐藏该字段 (默认true)
+ * @property {String} border-color 数字边框的颜色(默认#303133)
+ * @property {String} bg-color 倒计时数字的背景颜色(默认#ffffff)
+ * @property {String} color 倒计时数字的颜色(默认#303133)
+ * @property {String} height 数字高度值(宽度等同此值),设置边框时看情况是否需要设置此值,单位rpx(默认auto)
+ * @property {Boolean} show-days 是否显示倒计时的"天"部分(默认true)
+ * @property {Boolean} show-hours 是否显示倒计时的"时"部分(默认true)
+ * @property {Boolean} show-minutes 是否显示倒计时的"分"部分(默认true)
+ * @property {Boolean} show-seconds 是否显示倒计时的"秒"部分(默认true)
+ * @event {Function} end 倒计时结束
+ * @event {Function} change 每秒触发一次,回调为当前剩余的倒计秒数
+ * @example <u-count-down ref="uCountDown" :timestamp="86400" :autoplay="false"></u-count-down>
+ name: 'u-count-down',
+ // 倒计时的时间,秒为单位
+ timestamp: {
+ // 是否自动开始倒计时
+ // 用英文冒号(colon)或者中文(zh)当做分隔符,false的时候为中文,如:"11:22"或"11时22秒"
+ separator: {
+ default: 'colon'
+ // 分隔符的大小,单位rpx
+ separatorSize: {
+ default: 30
+ // 分隔符颜色
+ separatorColor: {
+ default: "#303133"
+ // 字体颜色
+ default: '#fff'
+ // 数字框高度,单位rpx
+ // 是否显示数字框
+ showBorder: {
+ // 是否显示秒
+ showSeconds: {
+ // 是否显示分钟
+ showMinutes: {
+ // 是否显示小时
+ showHours: {
+ // 是否显示“天”
+ showDays: {
+ // 当"天"的部分为0时,不显示
+ hideZeroDay: {
+ // 监听时间戳的变化
+ timestamp(newVal, oldVal) {
+ // 如果倒计时间发生变化,清除定时器,重新开始倒计时
+ this.clearTimer();
+ this.start();
+ d: '00', // 天的默认值
+ h: '00', // 小时的默认值
+ i: '00', // 分钟的默认值
+ s: '00', // 秒的默认值
+ timer: null ,// 定时器
+ seconds: 0, // 记录不停倒计过程中变化的秒数
+ // 倒计时item的样式,item为分别的时分秒部分的数字
+ if(this.height) {
+ style.height = this.height + 'rpx';
+ style.width = this.height + 'rpx';
+ if(this.showBorder) {
+ style.borderStyle = 'solid';
+ style.borderColor = this.borderColor;
+ style.borderWidth = '1px';
+ if(this.bgColor) {
+ style.backgroundColor = this.bgColor;
+ // 倒计时数字的样式
+ letterStyle() {
+ if(this.fontSize) style.fontSize = this.fontSize + 'rpx';
+ if(this.color) style.color = this.color;
+ // 如果自动倒计时
+ this.autoplay && this.timestamp && this.start();
+ // 倒计时
+ start() {
+ // 避免可能出现的倒计时重叠情况
+ if (this.timestamp <= 0) return;
+ this.seconds = Number(this.timestamp);
+ this.formatTime(this.seconds);
+ this.seconds--;
+ // 发出change事件
+ this.$emit('change', this.seconds);
+ if (this.seconds < 0) {
+ return this.end();
+ }, 1000);
+ // 格式化时间
+ formatTime(seconds) {
+ // 小于等于0的话,结束倒计时
+ seconds <= 0 && this.end();
+ let [day, hour, minute, second] = [0, 0, 0, 0];
+ day = Math.floor(seconds / (60 * 60 * 24));
+ // 判断是否显示“天”参数,如果不显示,将天部分的值,加入到小时中
+ // hour为给后面计算秒和分等用的(基于显示天的前提下计算)
+ hour = Math.floor(seconds / (60 * 60)) - day * 24;
+ // showHour为需要显示的小时
+ let showHour = null;
+ if(this.showDays) {
+ showHour = hour;
+ // 如果不显示天数,将“天”部分的时间折算到小时中去
+ showHour = Math.floor(seconds / (60 * 60));
+ minute = Math.floor(seconds / 60) - hour * 60 - day * 24 * 60;
+ second = Math.floor(seconds) - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60;
+ // 如果小于10,在前面补上一个"0"
+ showHour = showHour < 10 ? '0' + showHour : showHour;
+ minute = minute < 10 ? '0' + minute : minute;
+ second = second < 10 ? '0' + second : second;
+ this.d = day;
+ this.h = showHour;
+ this.i = minute;
+ this.s = second;
+ // 停止倒计时
+ end() {
+ this.$emit('end', {});
+ // 清除定时器
+ if(this.timer) {
+ beforeDestroy() {
+ .u-countdown {
+ .u-countdown-item {
+ padding: 2rpx;
+ white-space: nowrap;
+ transform: translateZ(0);
+ .u-countdown-time {
+ margin: 0;
+ padding: 0;
+ .u-countdown-colon {
+ padding: 0 5rpx;
+ padding-bottom: 4rpx;
+ .u-countdown-scale {
+ transform: scale(0.9);
@@ -0,0 +1,239 @@
+ class="u-count-num"
+ fontWeight: bold ? 'bold' : 'normal',
+ color: color
+ {{ displayValue }}
+ * countTo 数字滚动
+ * @description 该组件一般用于需要滚动数字到某一个值的场景,目标要求是一个递增的值。
+ * @tutorial https://www.uviewui.com/components/countTo.html
+ * @property {String Number} start-val 开始值
+ * @property {String Number} end-val 结束值
+ * @property {String Number} duration 滚动过程所需的时间,单位ms(默认2000)
+ * @property {Boolean} autoplay 是否自动开始滚动(默认true)
+ * @property {String Number} decimals 要显示的小数位数,见官网说明(默认0)
+ * @property {Boolean} use-easing 滚动结束时,是否缓动结尾,见官网说明(默认true)
+ * @property {String} separator 千位分隔符,见官网说明
+ * @property {String} color 字体颜色(默认#303133)
+ * @property {String Number} font-size 字体大小,单位rpx(默认50)
+ * @property {Boolean} bold 字体是否加粗(默认false)
+ * @event {Function} end 数值滚动到目标值时触发
+ * @example <u-count-to ref="uCountTo" :end-val="endVal" :autoplay="autoplay"></u-count-to>
+ name: 'u-count-to',
+ // 开始的数值,默认从0增长到某一个数
+ startVal: {
+ // 要滚动的目标数值,必须
+ endVal: {
+ required: true
+ // 滚动到目标数值的动画持续时间,单位为毫秒(ms)
+ // 设置数值后是否自动开始滚动
+ // 要显示的小数位数
+ decimals: {
+ // 是否在即将到达目标数值的时候,使用缓慢滚动的效果
+ useEasing: {
+ // 十进制分割
+ decimal: {
+ default: '.'
+ default: 50
+ // 是否加粗字体
+ bold: {
+ // 千位分隔符,类似金额的分割(¥23,321.05中的",")
+ localStartVal: this.startVal,
+ displayValue: this.formatNumber(this.startVal),
+ printVal: null,
+ paused: false, // 是否暂停
+ localDuration: Number(this.duration),
+ startTime: null, // 开始的时间
+ timestamp: null, // 时间戳
+ remaining: null, // 停留的时间
+ rAF: null,
+ lastTime: 0 // 上一次的时间
+ countDown() {
+ return this.startVal > this.endVal;
+ startVal() {
+ this.autoplay && this.start();
+ endVal() {
+ easingFn(t, b, c, d) {
+ return (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b;
+ requestAnimationFrame(callback) {
+ const currTime = new Date().getTime();
+ // 为了使setTimteout的尽可能的接近每秒60帧的效果
+ const timeToCall = Math.max(0, 16 - (currTime - this.lastTime));
+ const id = setTimeout(() => {
+ callback(currTime + timeToCall);
+ }, timeToCall);
+ this.lastTime = currTime + timeToCall;
+ return id;
+ cancelAnimationFrame(id) {
+ clearTimeout(id);
+ // 开始滚动数字
+ this.localStartVal = this.startVal;
+ this.startTime = null;
+ this.localDuration = this.duration;
+ this.paused = false;
+ this.rAF = this.requestAnimationFrame(this.count);
+ // 暂定状态,重新再开始滚动;或者滚动状态下,暂停
+ reStart() {
+ if (this.paused) {
+ this.resume();
+ this.stop();
+ this.paused = true;
+ // 暂停
+ stop() {
+ this.cancelAnimationFrame(this.rAF);
+ // 重新开始(暂停的情况下)
+ resume() {
+ this.localDuration = this.remaining;
+ this.localStartVal = this.printVal;
+ this.requestAnimationFrame(this.count);
+ // 重置
+ reset() {
+ this.displayValue = this.formatNumber(this.startVal);
+ count(timestamp) {
+ if (!this.startTime) this.startTime = timestamp;
+ this.timestamp = timestamp;
+ const progress = timestamp - this.startTime;
+ this.remaining = this.localDuration - progress;
+ if (this.useEasing) {
+ if (this.countDown) {
+ this.printVal = this.localStartVal - this.easingFn(progress, 0, this.localStartVal - this.endVal, this.localDuration);
+ this.printVal = this.easingFn(progress, this.localStartVal, this.endVal - this.localStartVal, this.localDuration);
+ this.printVal = this.localStartVal - (this.localStartVal - this.endVal) * (progress / this.localDuration);
+ this.printVal = this.localStartVal + (this.endVal - this.localStartVal) * (progress / this.localDuration);
+ this.printVal = this.printVal < this.endVal ? this.endVal : this.printVal;
+ this.printVal = this.printVal > this.endVal ? this.endVal : this.printVal;
+ this.displayValue = this.formatNumber(this.printVal);
+ if (progress < this.localDuration) {
+ // 判断是否数字
+ isNumber(val) {
+ return !isNaN(parseFloat(val));
+ formatNumber(num) {
+ // 将num转为Number类型,因为其值可能为字符串数值,调用toFixed会报错
+ num = Number(num);
+ num = num.toFixed(Number(this.decimals));
+ num += '';
+ const x = num.split('.');
+ let x1 = x[0];
+ const x2 = x.length > 1 ? this.decimal + x[1] : '';
+ const rgx = /(\d+)(\d{3})/;
+ if (this.separator && !this.isNumber(this.separator)) {
+ while (rgx.test(x1)) {
+ x1 = x1.replace(rgx, '$1' + this.separator + '$2');
+ return x1 + x2;
+ destroyed() {
+.u-count-num {
@@ -0,0 +1,151 @@
+ <view class="u-divider" :style="{
+ height: height == 'auto' ? 'auto' : height + 'rpx',
+ marginBottom: marginBottom + 'rpx',
+ marginTop: marginTop + 'rpx'
+ }" @tap="click">
+ <view class="u-divider-line" :class="[type ? 'u-divider-line--bordercolor--' + type : '']" :style="[lineStyle]"></view>
+ <view v-if="useSlot" class="u-divider-text" :style="{
+ fontSize: fontSize + 'rpx'
+ }"><slot /></view>
+ * divider 分割线
+ * @description 区隔内容的分割线,一般用于页面底部"没有更多"的提示。
+ * @tutorial https://www.uviewui.com/components/divider.html
+ * @property {String Number} half-width 文字左或右边线条宽度,数值或百分比,数值时单位为rpx
+ * @property {String} border-color 线条颜色,优先级高于type(默认#dcdfe6)
+ * @property {String} color 文字颜色(默认#909399)
+ * @property {String Number} fontSize 字体大小,单位rpx(默认26)
+ * @property {String} bg-color 整个divider的背景颜色(默认呢#ffffff)
+ * @property {String Number} height 整个divider的高度,单位rpx(默认40)
+ * @property {String} type 将线条设置主题色(默认primary)
+ * @property {Boolean} useSlot 是否使用slot传入内容,如果不传入,中间不会有空隙(默认true)
+ * @property {String Number} margin-top 与前一个组件的距离,单位rpx(默认0)
+ * @property {String Number} margin-bottom 与后一个组件的距离,单位rpx(0)
+ * @event {Function} click divider组件被点击时触发
+ * @example <u-divider color="#fa3534">长河落日圆</u-divider>
+ name: 'u-divider',
+ // 单一边divider横线的宽度(数值),单位rpx。或者百分比
+ halfWidth: {
+ default: 150
+ // divider横线的颜色,如设置,
+ default: '#dcdfe6'
+ // 主题色,可以是primary|info|success|warning|error之一值
+ // 文字颜色
+ // 文字大小,单位rpx
+ // 整个divider的背景颜色
+ // 整个divider的高度单位rpx
+ // 上边距
+ marginTop: {
+ // 下边距
+ marginBottom: {
+ // 是否使用slot传入内容,如果不用slot传入内容,先的中间就不会有空隙
+ useSlot: {
+ lineStyle() {
+ if(String(this.halfWidth).indexOf('%') != -1) style.width = this.halfWidth;
+ else style.width = this.halfWidth + 'rpx';
+ // borderColor优先级高于type值
+ if(this.borderColor) style.borderColor = this.borderColor;
+.u-divider {
+.u-divider-line {
+ transform: scale(1, 0.5);
+ &--bordercolor--primary {
+ &--bordercolor--success {
+ &--bordercolor--error {
+ &--bordercolor--info {
+ border-color: $u-type-info;
+ &--bordercolor--warning {
+.u-divider-text {
@@ -0,0 +1,108 @@
+ <view class="u-dropdown-item" v-if="active" @touchmove.stop.prevent @tap.stop.prevent>
+ <view class="u-dropdown-item__options" v-if="!$slots.default">
+ <u-cell-group>
+ <u-cell-item @click="cellClick(item.value)" :arrow="false" :title="item.label" v-for="(item, index) in options" :key="index" :title-style="{
+ color: value == item.value ? activeColor : inactiveColor
+ <u-icon v-if="value == item.value" name="checkbox-mark" :color="activeColor" size="32"></u-icon>
+ </u-cell-item>
+ </u-cell-group>
+ name: 'u-dropdown-item',
+ // 当前选中项的value值
+ type: [Number, String, Array],
+ // 菜单项标题
+ // 选项数据,如果传入了默认slot,此参数无效
+ options: {
+ return []
+ // 是否禁用此菜单项
+ active: false, // 当前项是否处于展开状态
+ activeColor: '#2979ff', // 激活时左边文字和右边对勾图标的颜色
+ inactiveColor: '#606266', // 未激活时左边文字和右边对勾图标的颜色
+ // 监听props是否发生了变化,有些值需要传递给父组件u-dropdown,无法双向绑定
+ propsChange() {
+ return `${this.title}-${this.disabled}`;
+ propsChange(n) {
+ // 当值变化时,通知父组件重新初始化,让父组件执行每个子组件的init()方法
+ // 将所有子组件数据重新整理一遍
+ if(this.parent) this.parent.init();
+ // 父组件的实例
+ this.parent = false;
+ // 获取父组件u-dropdown
+ let parent = this.$u.$parent.call(this, 'u-dropdown');
+ if(parent) {
+ this.parent = parent;
+ // 将子组件的激活颜色配置为父组件设置的激活和未激活时的颜色
+ this.activeColor = parent.activeColor;
+ this.inactiveColor = parent.inactiveColor;
+ // 将本组件的this,放入到父组件的children数组中,让父组件可以操作本(子)组件的方法和属性
+ // push进去前,显判断是否已经存在了本实例,因为在子组件内部数据变化时,会通过父组件重新初始化子组件
+ let exist = parent.children.find(val => {
+ return this === val;
+ if(!exist) parent.children.push(this);
+ if(parent.children.length == 1) this.active = true;
+ // 父组件无法监听children的变化,故将子组件的title,传入父组件的menuList数组中
+ parent.menuList.push({
+ title: this.title,
+ disabled: this.disabled
+ // cell被点击
+ cellClick(value) {
+ // 修改通过v-model绑定的值
+ this.$emit('input', value);
+ // 通知父组件(u-dropdown)收起菜单
+ this.parent.close();
+ // 发出事件,抛出当前勾选项的value
@@ -0,0 +1,231 @@
+ <view class="u-dropdown">
+ <view class="u-dropdown__menu" :style="{
+ height: $u.addUnit(height)
+ }" :class="{
+ 'u-border-bottom': borderBottom
+ <view class="u-dropdown__menu__item" v-for="(item, index) in menuList" :key="index" @tap.stop="menuClick(index)">
+ <view class="u-flex">
+ <text class="u-dropdown__menu__item__text" :style="{
+ color: item.disabled ? '#c0c4cc' : (index === current || highlightIndex == index) ? activeColor : inactiveColor,
+ fontSize: $u.addUnit(titleSize)
+ }">{{item.title}}</text>
+ <view class="u-dropdown__menu__item__arrow" :class="{
+ 'u-dropdown__menu__item__arrow--rotate': index === current
+ <u-icon :custom-style="{display: 'flex'}" name="arrow-down" size="26" :color="index === current || highlightIndex == index ? activeColor : '#c0c4cc'"></u-icon>
+ <view class="u-dropdown__content" :style="[contentStyle, {
+ transition: `opacity ${duration / 1000}s linear`
+ }]" @tap="maskClick" @touchmove.stop.prevent>
+ <view @tap.stop.prevent class="u-dropdown__content__popup" :style="[popupStyle]">
+ name: 'u-dropdown',
+ // 菜单标题和选项的激活态颜色
+ // 菜单标题和选项的未激活态颜色
+ // 点击遮罩是否关闭菜单
+ closeOnClickMask: {
+ // 点击当前激活项标题是否关闭菜单
+ closeOnClickSelf: {
+ // 过渡时间
+ default: 300
+ // 标题菜单的高度,单位任意,数值默认为rpx单位
+ default: 80
+ // 标题的字体大小
+ default: 28
+ showDropdown: true, // 是否打开下来菜单,
+ menuList: [], // 显示的菜单
+ active: false, // 下拉菜单的状态
+ // 当前是第几个菜单处于激活状态,小程序中此处不能写成false或者"",否则后续将current赋值为0,
+ // 无能的TX没有使用===而是使用==判断,导致程序认为前后二者没有变化,从而不会触发视图更新
+ current: 99999,
+ // 外层内容的样式,初始时处于底层,且透明
+ contentStyle: {
+ zIndex: -1,
+ opacity: 0
+ // 让某个菜单保持高亮的状态
+ highlightIndex: 99999
+ // 下拉出来部分的样式
+ popupStyle() {
+ // 进行Y轴位移,展开状态时,恢复原位。收齐状态时,往上位移100%,进行隐藏
+ style.transform = `translateY(${this.active ? 0 : '-100%'})`
+ // 引用所有子组件(u-dropdown-item)的this,不能在data中声明变量,否则在微信小程序会造成循环引用而报错
+ // 当某个子组件内容变化时,触发父组件的init,父组件再让每一个子组件重新初始化一遍
+ // 以保证数据的正确性
+ this.menuList = [];
+ this.children.map(child => {
+ child.init();
+ // 点击菜单
+ menuClick(index) {
+ // 判断是否被禁用
+ if(this.menuList[index].disabled) return ;
+ // 如果点击时的索引和当前激活项索引相同,意味着点击了激活项,需要收起下拉菜单
+ if (index === this.current && this.closeOnClickSelf) {
+ // 等动画结束后,再移除下拉菜单中的内容,否则直接移除,也就没有下拉菜单收起的效果了
+ this.children[index].active = false;
+ }, this.duration)
+ return;
+ this.open(index);
+ // 打开下拉菜单
+ open(index) {
+ // 重置高亮索引,否则会造成多个菜单同时高亮
+ // this.highlightIndex = 9999;
+ // 展开时,设置下拉内容的样式
+ this.contentStyle = {
+ zIndex: 11,
+ opacity: 1
+ // 标记展开状态以及当前展开项的索引
+ this.active = true;
+ this.current = index;
+ // 历遍所有的子元素,将索引匹配的项标记为激活状态,因为子元素是通过v-if控制切换的
+ // 之所以不是因display: none,是因为nvue没有display这个属性
+ this.children.map((val, idx) => {
+ val.active = index == idx ? true : false;
+ this.$emit('open', this.current);
+ // 设置下拉菜单处于收起状态
+ this.$emit('close', this.current);
+ // 设置为收起状态,同时current归位,设置为空字符串
+ this.active = false;
+ this.current = 99999;
+ // 下拉内容的样式进行调整,不透明度设置为0
+ // 点击遮罩
+ maskClick() {
+ // 如果不允许点击遮罩,直接返回
+ if(!this.closeOnClickMask) return;
+ // 外部手动设置某个菜单高亮
+ highlight(index = undefined) {
+ this.highlightIndex = index !== undefined ? index : 99999;
+ .u-dropdown {
+ &__menu {
+ &__arrow {
+ transition: transform .3s;
+ &--rotate {
+ left: 0px;
+ background: rgba(0, 0, 0, .3);
+ &__popup {
+ transform: translate3D(0, -100%, 0);
@@ -0,0 +1,193 @@
+ <view class="u-empty" v-if="show" :style="{
+ <u-icon
+ :name="src ? src : 'empty-' + mode"
+ :custom-style="iconStyle"
+ :label="text ? text : icons[mode]"
+ label-pos="bottom"
+ :label-color="color"
+ :label-size="fontSize"
+ :size="iconSize"
+ :color="iconColor"
+ margin-top="14"
+ ></u-icon>
+ <view class="u-slot-wrap">
+ <slot name="bottom"></slot>
+ * empty 内容为空
+ * @description 该组件用于需要加载内容,但是加载的第一页数据就为空,提示一个"没有内容"的场景, 我们精心挑选了十几个场景的图标,方便您使用。
+ * @tutorial https://www.uviewui.com/components/empty.html
+ * @property {String} color 文字颜色(默认#c0c4cc)
+ * @property {String} text 文字提示(默认“无内容”)
+ * @property {String} src 自定义图标路径,如定义,mode参数会失效
+ * @property {String Number} font-size 提示文字的大小,单位rpx(默认28)
+ * @property {String} mode 内置的图标,见官网说明(默认data)
+ * @property {String Number} img-width 图标的宽度,单位rpx(默认240)
+ * @property {String} img-height 图标的高度,单位rpx(默认auto)
+ * @property {String Number} margin-top 组件距离上一个元素之间的距离(默认0)
+ * @property {Boolean} show 是否显示组件(默认true)
+ * @example <u-empty text="所谓伊人,在水一方" mode="list"></u-empty>
+ name: "u-empty",
+ // 图标路径
+ text: {
+ default: '#c0c4cc'
+ // 图标的颜色
+ iconColor: {
+ // 图标的大小
+ default: 120
+ // 选择预置的图标类型
+ default: 'data'
+ // 图标宽度,单位rpx
+ imgWidth: {
+ // 图标高度,单位rpx
+ imgHeight: {
+ // 组件距离上一个元素之间的距离
+ icons: {
+ car: '购物车为空',
+ page: '页面不存在',
+ search: '没有搜索结果',
+ address: '没有收货地址',
+ wifi: '没有WiFi',
+ order: '订单为空',
+ coupon: '没有优惠券',
+ favor: '暂无收藏',
+ permission: '无权限',
+ history: '无历史记录',
+ news: '无新闻列表',
+ message: '消息列表为空',
+ list: '列表为空',
+ data: '数据为空'
+ // icons: [{
+ // icon: 'car',
+ // text: '购物车为空'
+ // },{
+ // icon: 'page',
+ // text: '页面不存在'
+ // icon: 'search',
+ // text: '没有搜索结果'
+ // icon: 'address',
+ // text: '没有收货地址'
+ // icon: 'wifi',
+ // text: '没有WiFi'
+ // icon: 'order',
+ // text: '订单为空'
+ // icon: 'coupon',
+ // text: '没有优惠券'
+ // icon: 'favor',
+ // text: '暂无收藏'
+ // icon: 'permission',
+ // text: '无权限'
+ // icon: 'history',
+ // text: '无历史记录'
+ // icon: 'news',
+ // text: '无新闻列表'
+ // icon: 'message',
+ // text: '消息列表为空'
+ // icon: 'list',
+ // text: '列表为空'
+ // icon: 'data',
+ // text: '数据为空'
+ // }],
+ .u-empty {
+ .u-image {
+ margin-bottom: 20rpx;
+ .u-slot-wrap {
+ margin-top: 20rpx;
@@ -0,0 +1,382 @@
+ <view class="u-field" :class="{'u-border-top': borderTop, 'u-border-bottom': borderBottom }">
+ <view class="u-field-inner" :class="[type == 'textarea' ? 'u-textarea-inner' : '', 'u-label-postion-' + labelPosition]">
+ <view class="u-label" :class="[required ? 'u-required' : '']" :style="{
+ justifyContent: justifyContent,
+ flex: labelPosition == 'left' ? `0 0 ${labelWidth}rpx` : '1'
+ <view class="u-icon-wrap" v-if="icon">
+ <u-icon size="32" :custom-style="iconStyle" :name="icon" :color="iconColor" class="u-icon"></u-icon>
+ <text class="u-label-text" :class="[this.$slots.icon || icon ? 'u-label-left-gap' : '']">{{ label }}</text>
+ <view class="fild-body">
+ <view class="u-flex-1 u-flex" :style="[inputWrapStyle]">
+ <textarea v-if="type == 'textarea'" class="u-flex-1 u-textarea-class" :style="[fieldStyle]" :value="value"
+ :placeholder="placeholder" :placeholderStyle="placeholderStyle" :disabled="disabled" :maxlength="inputMaxlength"
+ :focus="focus" :autoHeight="autoHeight" :fixed="fixed" @input="onInput" @blur="onBlur" @focus="onFocus" @confirm="onConfirm"
+ @tap="fieldClick" />
+ <input
+ v-else
+ :style="[fieldStyle]"
+ :type="type"
+ class="u-flex-1 u-field__input-wrap"
+ :value="value"
+ :password="password || this.type === 'password'"
+ :placeholder="placeholder"
+ :placeholderStyle="placeholderStyle"
+ :maxlength="inputMaxlength"
+ :focus="focus"
+ :confirmType="confirmType"
+ @focus="onFocus"
+ @blur="onBlur"
+ @input="onInput"
+ @confirm="onConfirm"
+ @tap="fieldClick"
+ />
+ <u-icon :size="clearSize" v-if="clearable && value != '' && focused" name="close-circle-fill" color="#c0c4cc" class="u-clear-icon" @click="onClear"/>
+ <view class="u-button-wrap"><slot name="right" /></view>
+ <u-icon v-if="rightIcon" @click="rightIconClick" :name="rightIcon" color="#c0c4cc" :style="[rightIconStyle]" size="26" class="u-arror-right" />
+ <view v-if="errorMessage !== false && errorMessage != ''" class="u-error-message" :style="{
+ paddingLeft: labelWidth + 'rpx'
+ }">{{ errorMessage }}</view>
+ * field 输入框
+ * @description 借助此组件,可以实现表单的输入, 有"text"和"textarea"类型的,此外,借助uView的picker和actionSheet组件可以快速实现上拉菜单,时间,地区选择等, 为表单解决方案的利器。
+ * @tutorial https://www.uviewui.com/components/field.html
+ * @property {String} type 输入框的类型(默认text)
+ * @property {String} icon label左边的图标,限uView的图标名称
+ * @property {Boolean} right-icon 输入框右边的图标名称,限uView的图标名称(默认false)
+ * @property {Boolean} required 是否必填,左边您显示红色"*"号(默认false)
+ * @property {String} label 输入框左边的文字提示
+ * @property {Boolean} password 是否密码输入方式(用点替换文字),type为text时有效(默认false)
+ * @property {Boolean} clearable 是否显示右侧清空内容的图标控件(输入框有内容,且获得焦点时才显示),点击可清空输入框内容(默认true)
+ * @property {Number String} label-width label的宽度,单位rpx(默认130)
+ * @property {String} label-align label的文字对齐方式(默认left)
+ * @property {Object} field-style 自定义输入框的样式,对象形式
+ * @property {Number | String} clear-size 清除图标的大小,单位rpx(默认30)
+ * @property {String} input-align 输入框内容对齐方式(默认left)
+ * @property {Boolean} border-bottom 是否显示field的下边框(默认true)
+ * @property {Boolean} border-top 是否显示field的上边框(默认false)
+ * @property {String} icon-color 左边通过icon配置的图标的颜色(默认#606266)
+ * @property {Boolean} auto-height 是否自动增高输入区域,type为textarea时有效(默认true)
+ * @property {String Boolean} error-message 显示的错误提示内容,如果为空字符串或者false,则不显示错误信息
+ * @property {String} placeholder 输入框的提示文字
+ * @property {String} placeholder-style placeholder的样式(内联样式,字符串),如"color: #ddd"
+ * @property {Boolean} focus 是否自动获得焦点(默认false)
+ * @property {Boolean} fixed 如果type为textarea,且在一个"position:fixed"的区域,需要指明为true(默认false)
+ * @property {Boolean} disabled 是否不可输入(默认false)
+ * @property {Number String} maxlength 最大输入长度,设置为 -1 的时候不限制最大长度(默认140)
+ * @property {String} confirm-type 设置键盘右下角按钮的文字,仅在type="text"时生效(默认done)
+ * @event {Function} input 输入框内容发生变化时触发
+ * @event {Function} focus 输入框获得焦点时触发
+ * @event {Function} blur 输入框失去焦点时触发
+ * @event {Function} confirm 点击完成按钮时触发
+ * @event {Function} right-icon-click 通过right-icon生成的图标被点击时触发
+ * @event {Function} click 输入框被点击或者通过right-icon生成的图标被点击时触发,这样设计是考虑到传递右边的图标,一般都为需要弹出"picker"等操作时的场景,点击倒三角图标,理应发出此事件,见上方说明
+ * @example <u-field v-model="mobile" label="手机号" required :error-message="errorMessage"></u-field>
+ name:"u-field",
+ icon: String,
+ rightIcon: String,
+ // arrowDirection: {
+ // default: 'right'
+ required: Boolean,
+ label: String,
+ password: Boolean,
+ clearable: {
+ // 左边标题的宽度单位rpx
+ labelWidth: {
+ default: 130
+ // 对齐方式,left|center|right
+ labelAlign: {
+ inputAlign: {
+ autoHeight: {
+ errorMessage: {
+ placeholder: String,
+ placeholderStyle: String,
+ focus: Boolean,
+ fixed: Boolean,
+ value: [Number, String],
+ default: 'text'
+ maxlength: {
+ default: 140
+ confirmType: {
+ default: 'done'
+ // lable的位置,可选为 left-左边,top-上边
+ labelPosition: {
+ // 输入框的自定义样式
+ fieldStyle: {
+ // 清除按钮的大小
+ clearSize: {
+ // lable左边的图标样式,对象形式
+ // 是否自动去除两端的空格
+ trim: {
+ focused: false,
+ itemIndex: 0,
+ inputWrapStyle() {
+ style.textAlign = this.inputAlign;
+ // 判断lable的位置,如果是left的话,让input左边两边有间隙
+ if(this.labelPosition == 'left') {
+ style.margin = `0 8rpx`;
+ // 如果lable是top的,input的左边就没必要有间隙了
+ style.marginRight = `8rpx`;
+ rightIconStyle() {
+ if (this.arrowDirection == 'top') style.transform = 'roate(-90deg)';
+ if (this.arrowDirection == 'bottom') style.transform = 'roate(90deg)';
+ else style.transform = 'roate(0deg)';
+ labelStyle() {
+ if(this.labelAlign == 'left') style.justifyContent = 'flext-start';
+ if(this.labelAlign == 'center') style.justifyContent = 'center';
+ if(this.labelAlign == 'right') style.justifyContent = 'flext-end';
+ // uni不支持在computed中写style.justifyContent = 'center'的形式,故用此方法
+ justifyContent() {
+ if(this.labelAlign == 'left') return 'flex-start';
+ if(this.labelAlign == 'center') return 'center';
+ if(this.labelAlign == 'right') return 'flex-end';
+ // 因为uniapp的input组件的maxlength组件必须要数值,这里转为数值,给用户可以传入字符串数值
+ inputMaxlength() {
+ return Number(this.maxlength)
+ // label的位置
+ fieldInnerStyle() {
+ style.flexDirection = 'row';
+ style.flexDirection = 'column';
+ onInput(event) {
+ let value = event.detail.value;
+ // 判断是否去除空格
+ if(this.trim) value = this.$u.trim(value);
+ onFocus(event) {
+ this.focused = true;
+ this.$emit('focus', event);
+ onBlur(event) {
+ // 最开始使用的是监听图标@touchstart事件,自从hx2.8.4后,此方法在微信小程序出错
+ // 这里改为监听点击事件,手点击清除图标时,同时也发生了@blur事件,导致图标消失而无法点击,这里做一个延时
+ this.focused = false;
+ }, 100)
+ this.$emit('blur', event);
+ onConfirm(e) {
+ this.$emit('confirm', e.detail.value);
+ onClear(event) {
+ this.$emit('input', '');
+ rightIconClick() {
+ this.$emit('right-icon-click');
+ fieldClick() {
+.u-field {
+ padding: 20rpx 28rpx;
+.u-field-inner {
+.u-textarea-inner {
+ align-items: flex-start;
+.u-textarea-class {
+ min-height: 96rpx;
+.fild-body {
+.u-arror-right {
+ margin-left: 8rpx;
+.u-label-text {
+.u-label-left-gap {
+.u-label-postion-top {
+.u-label {
+ width: 130rpx;
+ flex: 1 1 130rpx;
+.u-required::before {
+ left: -16rpx;
+ height: 9px;
+.u-field__input-wrap {
+.u-clear-icon {
+.u-error-message {
+.placeholder-style {
+ color: rgb(150, 151, 153);
+.u-input-class {
+.u-button-wrap {
@@ -0,0 +1,401 @@
+ <view class="u-form-item" :class="{'u-border-bottom': elBorderBottom, 'u-form-item__border-bottom--error': validateState === 'error' && showError('border-bottom')}">
+ <view class="u-form-item__body" :style="{
+ flexDirection: elLabelPosition == 'left' ? 'row' : 'column'
+ <!-- 微信小程序中,将一个参数设置空字符串,结果会变成字符串"true" -->
+ <view class="u-form-item--left" :style="{
+ width: uLabelWidth,
+ flex: `0 0 ${uLabelWidth}`,
+ marginBottom: elLabelPosition == 'left' ? 0 : '10rpx',
+ <!-- 为了块对齐 -->
+ <view class="u-form-item--left__content">
+ <!-- nvue不支持伪元素before -->
+ <text v-if="required" class="u-form-item--left__content--required">*</text>
+ <view class="u-form-item--left__content__icon" v-if="leftIcon">
+ <u-icon :name="leftIcon" :custom-style="leftIconStyle"></u-icon>
+ <view class="u-form-item--left__content__label" :style="[elLabelStyle, {
+ 'justify-content': elLabelAlign == 'left' ? 'flex-start' : elLabelAlign == 'center' ? 'center' : 'flex-end'
+ {{label}}
+ <view class="u-form-item--right u-flex">
+ <view class="u-form-item--right__content">
+ <view class="u-form-item--right__content__slot ">
+ <view class="u-form-item--right__content__icon u-flex" v-if="$slots.right || rightIcon">
+ <u-icon :custom-style="rightIconStyle" v-if="rightIcon" :name="rightIcon"></u-icon>
+ <slot name="right" />
+ <view class="u-form-item__message" v-if="validateState === 'error' && showError('message')" :style="{
+ paddingLeft: elLabelPosition == 'left' ? $u.addUnit(elLabelWidth) : '0',
+ }">{{validateMessage}}</view>
+import Emitter from '../../libs/util/emitter.js';
+import schema from '../../libs/util/async-validator';
+// 去除警告信息
+schema.warning = function(){};
+ * form-item 表单item
+ * @description 此组件一般用于表单场景,可以配置Input输入框,Select弹出框,进行表单验证等。
+ * @tutorial http://uviewui.com/components/form.html
+ * @property {String} label 左侧提示文字
+ * @property {Object} prop 表单域model对象的属性名,在使用 validate、resetFields 方法的情况下,该属性是必填的
+ * @property {Boolean} border-bottom 是否显示表单域的下划线边框
+ * @property {String} label-position 表单域提示文字的位置,left-左侧,top-上方
+ * @property {String Number} label-width 提示文字的宽度,单位rpx(默认90)
+ * @property {Object} label-style lable的样式,对象形式
+ * @property {String} label-align lable的对齐方式
+ * @property {String} right-icon 右侧自定义字体图标(限uView内置图标)或图片地址
+ * @property {String} left-icon 左侧自定义字体图标(限uView内置图标)或图片地址
+ * @property {Object} left-icon-style 左侧图标的样式,对象形式
+ * @property {Object} right-icon-style 右侧图标的样式,对象形式
+ * @property {Boolean} required 是否显示左边的"*"号,这里仅起展示作用,如需校验必填,请通过rules配置必填规则(默认false)
+ * @example <u-form-item label="姓名"><u-input v-model="form.name" /></u-form-item>
+ name: 'u-form-item',
+ inject: {
+ uForm: {
+ return null
+ // input的label提示语
+ // 绑定的值
+ prop: {
+ // 是否显示表单域的下划线边框
+ // label的位置,left-左边,top-上边
+ // label的宽度,单位rpx
+ // lable的样式,对象形式
+ // lable字体的对齐方式
+ // 右侧图标
+ rightIcon: {
+ // 左侧图标
+ leftIcon: {
+ // 左侧图标的样式
+ leftIconStyle: {
+ rightIconStyle: {
+ // 是否显示左边的必填星号,只作显示用,具体校验必填的逻辑,请在rules中配置
+ initialValue: '', // 存储的默认值
+ // isRequired: false, // 是否必填,由于人性化考虑,必填"*"号通过props的required配置,不再通过rules的规则自动生成
+ validateState: '', // 是否校验成功
+ validateMessage: '' ,// 校验失败的提示语
+ // 有错误时的提示方式,message-提示信息,border-如果input设置了边框,变成呈红色,
+ errorType: ['message'],
+ this.parent = this.$u.$parent.call(this, 'u-form');
+ validateState(val) {
+ this.broadcastInputError();
+ // 监听u-form组件的errorType的变化
+ "uForm.errorType"(val) {
+ this.errorType = val;
+ // 计算后的label宽度,由于需要多个判断,故放到computed中
+ uLabelWidth() {
+ // 如果用户设置label为空字符串(微信小程序空字符串最终会变成字符串的'true'),意味着要将label的位置宽度设置为auto
+ return this.elLabelPosition == 'left' ? (this.label === 'true' || this.label === '' ? 'auto' : this.$u.addUnit(this.elLabelWidth)) : '100%';
+ fieldValue() {
+ return this.uForm.model[this.prop];
+ showError() {
+ return type => {
+ // 如果errorType数组中含有none,或者toast提示类型
+ if(this.errorType.indexOf('none') >= 0) return false;
+ else if(this.errorType.indexOf(type) >= 0) return true;
+ else return false;
+ // label的宽度
+ elLabelWidth() {
+ // label默认宽度为90,优先使用本组件的值,如果没有(如果设置为0,也算是配置了值,依然起效),则用u-form的值
+ return (this.labelWidth != 0 || this.labelWidth != '') ? this.labelWidth : (this.parent ? this.parent.labelWidth : 90);
+ // label的样式
+ elLabelStyle() {
+ return Object.keys(this.labelStyle).length ? this.labelStyle : (this.parent ? this.parent.labelStyle : {});
+ // label的位置,左侧或者上方
+ elLabelPosition() {
+ return this.labelPosition ? this.labelPosition : (this.parent ? this.parent.labelPosition : 'left');
+ // label的对齐方式
+ elLabelAlign() {
+ return this.labelAlign ? this.labelAlign : (this.parent ? this.parent.labelAlign : 'left');
+ // label的下划线
+ elBorderBottom() {
+ // 子组件的borderBottom默认为空字符串,如果不等于空字符串,意味着子组件设置了值,优先使用子组件的值
+ return this.borderBottom !== '' ? this.borderBottom : this.parent ? this.parent.borderBottom : true;
+ broadcastInputError() {
+ // 子组件发出事件,第三个参数为true或者false,true代表有错误
+ this.broadcast('u-input', 'on-form-item-error', this.validateState === 'error' && this.showError('border'));
+ // 判断是否需要required校验
+ setRules() {
+ let that = this;
+ // 由于人性化考虑,必填"*"号通过props的required配置,不再通过rules的规则自动生成
+ // 从父组件u-form拿到当前u-form-item需要验证 的规则
+ // let rules = this.getRules();
+ // if (rules.length) {
+ // this.isRequired = rules.some(rule => {
+ // // 如果有必填项,就返回,没有的话,就是undefined
+ // return rule.required;
+ // });
+ // blur事件
+ this.$on('on-form-blur', that.onFieldBlur);
+ // change事件
+ this.$on('on-form-change', that.onFieldChange);
+ // 从u-form的rules属性中,取出当前u-form-item的校验规则
+ getRules() {
+ // 父组件的所有规则
+ let rules = this.uForm.rules;
+ rules = rules ? rules[this.prop] : [];
+ // 保证返回的是一个数组形式
+ return [].concat(rules || []);
+ // blur事件时进行表单校验
+ onFieldBlur() {
+ this.validation('blur');
+ // change事件进行表单校验
+ onFieldChange() {
+ this.validation('change');
+ // 过滤出符合要求的rule规则
+ getFilteredRule(triggerType = '') {
+ let rules = this.getRules();
+ // 整体验证表单时,triggerType为空字符串,此时返回所有规则进行验证
+ if(!triggerType) return rules;
+ // 历遍判断规则是否有对应的事件,比如blur,change触发等的事件
+ // 使用indexOf判断,是因为某些时候设置的验证规则的trigger属性可能为多个,比如['blur','change']
+ // 某些场景可能的判断规则,可能不存在trigger属性,故先判断是否存在此属性
+ return rules.filter(res => res.trigger && res.trigger.indexOf(triggerType) !== -1);
+ // 校验数据
+ validation(trigger, callback = () => {}) {
+ // blur和change是否有当前方式的校验规则
+ let rules = this.getFilteredRule(trigger);
+ // 判断是否有验证规则,如果没有规则,也调用回调方法,否则父组件u-form会因为
+ // 对count变量的统计错误而无法进入上一层的回调
+ if (!rules || rules.length === 0) {
+ return callback('');
+ // 设置当前的装填,标识为校验中
+ this.validateState = 'validating';
+ // 调用async-validator的方法
+ let validator = new schema({ [this.prop]: rules });
+ validator.validate({ [this.prop]: this.fieldValue }, { firstFields: true }, (errors, fields) => {
+ // 记录状态和报错信息
+ this.validateState = !errors ? 'success' : 'error';
+ this.validateMessage = errors ? errors[0].message : '';
+ // 调用回调方法
+ callback(this.validateMessage);
+ // 清空当前的u-form-item
+ resetField() {
+ this.uForm.model[this.prop] = this.initialValue;
+ // 设置为`success`状态,只是为了清空错误标记
+ this.validateState = 'success';
+ // 组件创建完成时,将当前实例保存到u-form中
+ // 如果没有传入prop,或者uForm为空(如果u-form-input单独使用,就不会有uForm注入),就不进行校验
+ if (!this.prop || this.uForm === null) return;
+ // 发出事件,让父组件将本实例加入到管理数组中
+ this.dispatch('u-form', 'on-form-item-add', this);
+ this.errorType = this.uForm.errorType;
+ // 设置初始值
+ this.initialValue = this.fieldValue;
+ // 添加表单校验,这里必须要写在$nextTick中,因为u-form的rules是通过ref手动传入的
+ // 不在$nextTick中的话,可能会造成执行此处代码时,父组件还没通过ref把规则给u-form,导致规则为空
+ this.$nextTick(() =>{
+ this.setRules();
+ // 组件销毁前,将实例从 Form 的缓存中移除
+ this.dispatch('u-form', 'on-form-item-remove', this);
+ .u-form-item {
+ // align-items: flex-start;
+ padding: 20rpx 0;
+ line-height: $u-form-item-height;
+ &__border-bottom--error:after {
+ padding-right: 10rpx;
+ margin-right: 8rpx;
+ &--required {
+ padding-top: 6rpx;
+ &__slot {
+ color: $u-light-color;
+ &__message {
+ margin-top: 12rpx;
@@ -0,0 +1,149 @@
+ <view class="u-form"><slot /></view>
+ * form 表单
+ * @property {Object} model 表单数据对象
+ * @property {Object} rules 通过ref设置,见官网说明
+ * @property {Array} error-type 错误的提示方式,数组形式,见上方说明(默认['message'])
+ * @example <u-form :model="form" ref="uForm"></u-form>
+ name: 'u-form',
+ // 当前form的需要验证字段的集合
+ model: {
+ // 验证规则
+ // rules: {
+ // type: [Object, Function, Array],
+ // return {};
+ // border-bottom-下边框呈现红色,none-无提示
+ errorType: {
+ return ['message', 'toast']
+ default: 90
+ uForm: this
+ rules: {}
+ // 存储当前form下的所有u-form-item的实例
+ // 不能定义在data中,否则微信小程序会造成循环引用而报错
+ this.fields = [];
+ // 存当前实例
+ // 监听on-form-item-add事件,将子组件添加到fields中
+ this.$on('on-form-item-add', item => {
+ if (item) {
+ that.fields.push(item);
+ // 删除当前有的实例
+ this.$on('on-form-item-remove', item => {
+ // 如果当前没有prop的话表示当前不要进行删除(因为没有注入)
+ if (item.prop) {
+ that.fields.splice(that.fields.indexOf(item), 1);
+ setRules(rules) {
+ this.rules = rules;
+ // 清空所有u-form-item组件的内容,本质上是调用了u-form-item组件中的resetField()方法
+ resetFields() {
+ this.fields.map(field => {
+ field.resetField();
+ // 校验全部数据
+ validate(callback) {
+ // 对所有的u-form-item进行校验
+ let valid = true; // 默认通过
+ let count = 0; // 用于标记是否检查完毕
+ let errorArr = []; // 存放错误信息
+ // 调用每一个u-form-item实例的validation的校验方法
+ field.validation('', error => {
+ // 如果任意一个u-form-item校验不通过,就意味着整个表单不通过
+ if (error) {
+ valid = false;
+ errorArr.push(error);
+ // 当历遍了所有的u-form-item时,调用promise的then方法
+ if (++count === this.fields.length) {
+ resolve(valid); // 进入promise的then方法
+ // 判断是否设置了toast的提示方式,只提示最前面的表单域的第一个错误信息
+ if(this.errorType.indexOf('none') === -1 && this.errorType.indexOf('toast') >= 0 && errorArr.length) {
+ this.$u.toast(errorArr[0]);
+ if (typeof callback == 'function') callback(valid);
@@ -0,0 +1,52 @@
+ <u-modal v-model="show" :show-cancel-button="true" confirm-text="升级" title="发现新版本" @cancel="cancel" @confirm="confirm">
+ <view class="u-update-content">
+ <rich-text :nodes="content"></rich-text>
+ </u-modal>
+ show: false,
+ content: `
+ 1. 修复badge组件的size参数无效问题<br>
+ 2. 新增Modal模态框组件<br>
+ 3. 新增压窗屏组件,可以在APP上以弹窗的形式遮盖导航栏和底部tabbar<br>
+ 4. 修复键盘组件在微信小程序上遮罩无效的问题
+ `,
+ onReady() {
+ this.show = true;
+ cancel() {
+ this.closeModal();
+ confirm() {
+ closeModal() {
+ uni.navigateBack();
+ .u-full-content {
+ background-color: #00C777;
+ .u-update-content {
+ line-height: 1.7;
+ padding: 30rpx;
@@ -0,0 +1,54 @@
+ <view class="u-gap" :style="[gapStyle]"></view>
+ * gap 间隔槽
+ * @description 该组件一般用于内容块之间的用一个灰色块隔开的场景,方便用户风格统一,减少工作量
+ * @tutorial https://www.uviewui.com/components/gap.html
+ * @property {String} bg-color 背景颜色(默认#f3f4f6)
+ * @property {String Number} height 分割槽高度,单位rpx(默认30)
+ * @example <u-gap height="80" bg-color="#bbb"></u-gap>
+ name: "u-gap",
+ default: 'transparent ' // 背景透明
+ // 高度
+ // 与上一个组件的距离
+ // 与下一个组件的距离
+ gapStyle() {
+ backgroundColor: this.bgColor,
+ height: this.height + 'rpx',
+ marginTop: this.marginTop + 'rpx',
+ marginBottom: this.marginBottom + 'rpx'
@@ -0,0 +1,113 @@
+ <view class="u-grid-item" :hover-class="hoverClass"
+ :hover-stay-time="200" @tap="click" :style="{
+ background: bgColor,
+ <view class="u-grid-item-box" :class="[showBorder ? 'u-border-right u-border-bottom' : '']">
+ * gridItem 提示
+ * @description 宫格组件一般用于同时展示多个同类项目的场景,可以给宫格的项目设置徽标组件(badge),或者图标等,也可以扩展为左右滑动的轮播形式。搭配u-grid使用
+ * @tutorial https://www.uviewui.com/components/grid.html
+ * @property {String} bg-color 宫格的背景颜色(默认#ffffff)
+ * @property {String Number} index 点击宫格时,返回的值
+ * @event {Function} click 点击宫格触发
+ * @example <u-grid-item></u-grid-item>
+ name: "u-grid-item",
+ // 点击时返回的index
+ // 父组件通过provide传递过来的整个this
+ inject: ['uGrid'],
+ hoverClass: '', // 按下去的时候,是否显示背景灰色
+ this.hoverClass = this.uGrid.hoverClass;
+ // 小于2,显示2列;大于12,显示12列
+ colNum() {
+ return this.col < 2 ? 2 : this.col > 12 ? 12 : this.col;
+ // 每个grid-item的宽度
+ width() {
+ return 100 / Number(this.uGrid.col) + '%';
+ // 是否显示边框
+ // 为了迎合演示的需要,从created生命周期移到此,以为演示中可能需要动态修改有无边框
+ showBorder() {
+ return this.uGrid.border;
+ this.uGrid.click(this.index);
+ .u-grid-item {
+ background: #fff;
+ /* #ifdef MP */
+ .u-grid-item-hover {
+ background: #f7f7f7 !important;
+ .u-grid-marker-box {
+ line-height: 0;
+ .u-grid-marker-wrap {
+ .u-grid-item-box {
+ padding: 30rpx 0;
@@ -0,0 +1,95 @@
+ <view class="u-grid" :class="{'u-border-top u-border-left': border}" :style="[gridStyle]"><slot /></view>
+ * grid 宫格布局
+ * @description 宫格组件一般用于同时展示多个同类项目的场景,可以给宫格的项目设置徽标组件(badge),或者图标等,也可以扩展为左右滑动的轮播形式。
+ * @property {String Number} col 宫格的列数(默认3)
+ * @property {Boolean} border 是否显示宫格的边框(默认true)
+ * @property {Boolean} hover-class 点击宫格的时候,是否显示按下的灰色背景(默认false)
+ * @example <u-grid :col="3" @click="click"></u-grid>
+ name: 'u-grid',
+ // 分成几列
+ col: {
+ default: 3
+ // 宫格对齐方式,表现为数量少的时候,靠左,居中,还是靠右
+ // 宫格按压时的样式类,"none"为无效果
+ uGrid: this
+ // 宫格对齐方式
+ gridStyle() {
+ switch(this.align) {
+ case 'left':
+ style.justifyContent = 'flex-start';
+ break;
+ case 'center':
+ style.justifyContent = 'center';
+ case 'right':
+ style.justifyContent = 'flex-end';
+ default: style.justifyContent = 'flex-start';
+.u-grid {
@@ -0,0 +1,262 @@
+ <view :style="[customStyle]" class="u-icon" @tap="click" :class="['u-icon--' + labelPos]">
+ <image class="u-icon__img" v-if="isImg" :src="name" :mode="imgMode" :style="[imgStyle]"></image>
+ <text v-else class="u-icon__icon" :class="customClass" :style="[iconStyle]" :hover-class="hoverClass" @touchstart="touchstart"></text>
+ <!-- 这里进行空字符串判断,如果仅仅是v-if="label",可能会出现传递0的时候,结果也无法显示 -->
+ <text v-if="label !== ''" class="u-icon__label" :style="{
+ color: labelColor,
+ fontSize: $u.addUnit(labelSize),
+ marginLeft: labelPos == 'right' ? $u.addUnit(marginLeft) : 0,
+ marginTop: labelPos == 'bottom' ? $u.addUnit(marginTop) : 0,
+ marginRight: labelPos == 'left' ? $u.addUnit(marginRight) : 0,
+ marginBottom: labelPos == 'top' ? $u.addUnit(marginBottom) : 0,
+ }">{{label}}</text>
+ * icon 图标
+ * @description 基于字体的图标集,包含了大多数常见场景的图标。
+ * @tutorial https://www.uviewui.com/components/icon.html
+ * @property {String} name 图标名称,见示例图标集
+ * @property {String} color 图标颜色(默认inherit)
+ * @property {String | Number} size 图标字体大小,单位rpx(默认32)
+ * @property {String | Number} label-size label字体大小,单位rpx(默认28)
+ * @property {String} label 图标右侧的label文字(默认28)
+ * @property {String} label-pos label文字相对于图标的位置,只能right或bottom(默认right)
+ * @property {String} label-color label字体颜色(默认#606266)
+ * @property {Object} custom-style icon的样式,对象形式
+ * @property {String} custom-prefix 自定义字体图标库时,需要写上此值
+ * @property {String | Number} margin-left label在右侧时与图标的距离,单位rpx(默认6)
+ * @property {String | Number} margin-top label在下方时与图标的距离,单位rpx(默认6)
+ * @property {String | Number} margin-bottom label在上方时与图标的距离,单位rpx(默认6)
+ * @property {String | Number} margin-right label在左侧时与图标的距离,单位rpx(默认6)
+ * @property {String} label-pos label相对于图标的位置,只能right或bottom(默认right)
+ * @property {String} index 一个用于区分多个图标的值,点击图标时通过click事件传出
+ * @property {String} hover-class 图标按下去的样式类,用法同uni的view组件的hover-class参数,详情见官网
+ * @property {String} width 显示图片小图标时的宽度
+ * @property {String} height 显示图片小图标时的高度
+ * @property {String} top 图标在垂直方向上的定位
+ * @event {Function} click 点击图标时触发
+ * @example <u-icon name="photo" color="#2979ff" size="28"></u-icon>
+ name: 'u-icon',
+ // 图标类名
+ // 图标颜色,可接受主题色
+ default: 'inherit'
+ // 是否显示粗体
+ // 点击图标的时候传递事件出去的index(用于区分点击了哪一个)
+ // 触摸图标时的类名
+ // 自定义扩展前缀,方便用户扩展自己的图标库
+ customPrefix: {
+ default: 'uicon'
+ // 图标右边或者下面的文字
+ // label的位置,只能右边或者下边
+ labelPos: {
+ // label的大小
+ default: '28'
+ // label的颜色
+ labelColor: {
+ // label与图标的距离(横向排列)
+ marginLeft: {
+ default: '6'
+ // label与图标的距离(竖向排列)
+ marginRight: {
+ // 图片的mode
+ imgMode: {
+ default: 'widthFix'
+ // 自定义样式
+ // 用于显示图片小图标时,图片的宽度
+ // 用于显示图片小图标时,图片的高度
+ // 用于解决某些情况下,让图标垂直居中的用途
+ customClass() {
+ classes.push(this.customPrefix + '-' + this.name);
+ // uView的自定义图标类名为u-iconfont
+ if (this.customPrefix == 'uicon') classes.push('u-iconfont');
+ else classes.push(this.customPrefix);
+ // 主题色,通过类配置
+ if (this.color && this.$u.config.type.includes(this.color)) classes.push('u-icon__icon--' + this.color);
+ // 阿里,头条,百度小程序通过数组绑定类名时,无法直接使用[a, b, c]的形式,否则无法识别
+ // 故需将其拆成一个字符串的形式,通过空格隔开各个类名
+ //#ifdef MP-ALIPAY || MP-TOUTIAO || MP-BAIDU
+ classes = classes.join(' ');
+ return classes;
+ style = {
+ fontSize: this.size == 'inherit' ? 'inherit' : this.$u.addUnit(this.size),
+ fontWeight: this.bold ? 'bold' : 'normal',
+ // 某些特殊情况需要设置一个到顶部的距离,才能更好的垂直居中
+ top: this.$u.addUnit(this.top)
+ // 非主题色值时,才当作颜色值
+ if (this.color && !this.$u.config.type.includes(this.color)) style.color = this.color;
+ // 判断传入的name属性,是否图片路径,只要带有"/"均认为是图片形式
+ isImg() {
+ return this.name.indexOf('/') !== -1;
+ imgStyle() {
+ // 如果设置width和height属性,则优先使用,否则使用size属性
+ style.width = this.width ? this.$u.addUnit(this.width) : this.$u.addUnit(this.size);
+ style.height = this.height ? this.$u.addUnit(this.height) : this.$u.addUnit(this.size);
+ touchstart() {
+ this.$emit('touchstart', this.index);
+@import '../../iconfont.css';
+.u-icon {
+ flex-direction: row-reverse;
+ &--top {
+ flex-direction: column-reverse;
+ &--bottom {
+ color: $u-type-primary;
+ color: $u-type-success;
+ color: $u-type-warning;
+ &--info {
+ color: $u-type-info;
+ &__img {
+ height: auto;
+ will-change: transform;
+ <view class="u-image" @tap="onClick" :style="[wrapStyle, backgroundStyle]">
+ v-if="!isError"
+ :src="src"
+ :mode="mode"
+ @error="onErrorHandler"
+ @load="onLoadHandler"
+ :lazy-load="lazyLoad"
+ class="u-image__image"
+ borderRadius: shape == 'circle' ? '50%' : $u.addUnit(borderRadius)
+ v-if="showLoading && loading"
+ class="u-image__loading"
+ borderRadius: shape == 'circle' ? '50%' : $u.addUnit(borderRadius),
+ backgroundColor: this.bgColor
+ <slot v-if="$slots.loading" name="loading" />
+ <u-icon v-else :name="loadingIcon" :width="width" :height="height"></u-icon>
+ v-if="showError && isError && !loading"
+ class="u-image__error"
+ <slot v-if="$slots.error" name="error" />
+ <u-icon v-else :name="errorIcon" :width="width" :height="height"></u-icon>
+ * Image 图片
+ * @description 此组件为uni-app的image组件的加强版,在继承了原有功能外,还支持淡入动画、加载中、加载失败提示、圆角值和形状等。
+ * @tutorial https://uviewui.com/components/image.html
+ * @property {String} src 图片地址
+ * @property {String} mode 裁剪模式,见官网说明
+ * @property {String | Number} width 宽度,单位任意,如果为数值,则为rpx单位(默认100%)
+ * @property {String | Number} height 高度,单位任意,如果为数值,则为rpx单位(默认 auto)
+ * @property {String} shape 图片形状,circle-圆形,square-方形(默认square)
+ * @property {String | Number} border-radius 圆角值,单位任意,如果为数值,则为rpx单位(默认 0)
+ * @property {Boolean} lazy-load 是否懒加载,仅微信小程序、App、百度小程序、字节跳动小程序有效(默认 true)
+ * @property {Boolean} show-menu-by-longpress 是否开启长按图片显示识别小程序码菜单,仅微信小程序有效(默认 false)
+ * @property {String} loading-icon 加载中的图标,或者小图片(默认 photo)
+ * @property {String} error-icon 加载失败的图标,或者小图片(默认 error-circle)
+ * @property {Boolean} show-loading 是否显示加载中的图标或者自定义的slot(默认 true)
+ * @property {Boolean} show-error 是否显示加载错误的图标或者自定义的slot(默认 true)
+ * @property {Boolean} fade 是否需要淡入效果(默认 true)
+ * @property {String Number} width 传入图片路径时图片的宽度
+ * @property {String Number} height 传入图片路径时图片的高度
+ * @property {Boolean} webp 只支持网络资源,只对微信小程序有效(默认 false)
+ * @property {String | Number} duration 搭配fade参数的过渡时间,单位ms(默认 500)
+ * @event {Function} click 点击图片时触发
+ * @event {Function} error 图片加载失败时触发
+ * @event {Function} load 图片加载成功时触发
+ * @example <u-image width="100%" height="300rpx" :src="src"></u-image>
+ name: 'u-image',
+ // 图片地址
+ // 裁剪模式
+ default: 'aspectFill'
+ // 宽度,单位任意
+ default: '100%'
+ // 高度,单位任意
+ // 图片形状,circle-圆形,square-方形
+ // 圆角,单位任意
+ // 是否懒加载,微信小程序、App、百度小程序、字节跳动小程序
+ lazyLoad: {
+ // 开启长按图片显示识别微信小程序码菜单
+ showMenuByLongpress: {
+ // 加载中的图标,或者小图片
+ loadingIcon: {
+ default: 'photo'
+ // 加载失败的图标,或者小图片
+ errorIcon: {
+ default: 'error-circle'
+ // 是否显示加载中的图标或者自定义的slot
+ showLoading: {
+ // 是否显示加载错误的图标或者自定义的slot
+ showError: {
+ // 是否需要淡入效果
+ fade: {
+ // 只支持网络资源,只对微信小程序有效
+ webp: {
+ // 过渡时间,单位ms
+ default: 500
+ // 背景颜色,用于深色页面加载图片时,为了和背景色融合
+ default: '#f3f4f6'
+ // 图片是否加载错误,如果是,则显示错误占位图
+ isError: false,
+ // 初始化组件时,默认为加载中状态
+ loading: true,
+ // 不透明度,为了实现淡入淡出的效果
+ opacity: 1,
+ // 过渡时间,因为props的值无法修改,故需要一个中间值
+ durationTime: this.duration,
+ // 图片加载完成时,去掉背景颜色,因为如果是png图片,就会显示灰色的背景
+ backgroundStyle: {}
+ src(n) {
+ if(!n) {
+ // 如果传入null或者'',或者false,或者undefined,标记为错误状态
+ this.isError = true;
+ this.isError = false;
+ wrapStyle() {
+ // 通过调用addUnit()方法,如果有单位,如百分比,px单位等,直接返回,如果是纯粹的数值,则加上rpx单位
+ style.width = this.$u.addUnit(this.width);
+ style.height = this.$u.addUnit(this.height);
+ // 如果是配置了圆形,设置50%的圆角,否则按照默认的配置值
+ style.borderRadius = this.shape == 'circle' ? '50%' : this.$u.addUnit(this.borderRadius);
+ // 如果设置圆角,必须要有hidden,否则可能圆角无效
+ style.overflow = this.borderRadius > 0 ? 'hidden' : 'visible';
+ if (this.fade) {
+ style.opacity = this.opacity;
+ style.transition = `opacity ${Number(this.durationTime) / 1000}s ease-in-out`;
+ // 点击图片
+ onClick() {
+ // 图片加载失败
+ onErrorHandler() {
+ this.loading = false;
+ this.$emit('error');
+ // 图片加载完成,标记loading结束
+ onLoadHandler() {
+ this.$emit('load');
+ // 如果不需要动画效果,就不执行下方代码,同时移除加载时的背景颜色
+ // 否则无需fade效果时,png图片依然能看到下方的背景色
+ if (!this.fade) return this.removeBgColor();
+ // 原来opacity为1(不透明,是为了显示占位图),改成0(透明,意味着该元素显示的是背景颜色,默认的灰色),再改成1,是为了获得过渡效果
+ // 这里设置为0,是为了图片展示到背景全透明这个过程时间为0,延时之后延时之后重新设置为duration,是为了获得背景透明(灰色)
+ // 到图片展示的过程中的淡入效果
+ this.durationTime = 0;
+ // 延时50ms,否则在浏览器H5,过渡效果无效
+ this.durationTime = this.duration;
+ this.removeBgColor();
+ }, this.durationTime);
+ // 移除图片的背景色
+ removeBgColor() {
+ // 淡入动画过渡完成后,将背景设置为透明色,否则png图片会看到灰色的背景
+ this.backgroundStyle = {
+ backgroundColor: 'transparent'
+.u-image {
+ transition: opacity 0.5s ease-in-out;
+ &__image {
+ &__loading,
+ &__error {
+ background-color: $u-bg-color;
+ font-size: 46rpx;
@@ -0,0 +1,84 @@
+ <!-- 支付宝小程序使用$u.getRect()获取组件的根元素尺寸,所以在外面套一个"壳" -->
+ <view>
+ <view class="u-index-anchor-wrapper" :id="$u.guid()" :style="[wrapperStyle]">
+ <view class="u-index-anchor " :class="[active ? 'u-index-anchor--active' : '']" :style="[customAnchorStyle]">
+ <slot v-if="useSlot" />
+ <block v-else>
+ <text>{{ index }}</text>
+ * indexAnchor 索引列表锚点
+ * @description 通过折叠面板收纳内容区域,搭配<u-index-anchor>使用
+ * @tutorial https://www.uviewui.com/components/indexList.html#indexanchor-props
+ * @property {Boolean} use-slot 是否使用自定义内容的插槽(默认false)
+ * @property {String Number} index 索引字符,如果定义了use-slot,此参数自动失效
+ * @property {Object} custStyle 自定义样式,对象形式,如"{color: 'red'}"
+ * @event {Function} default 锚点位置显示内容,默认为索引字符
+ * @example <u-index-anchor :index="item" />
+ name: "u-index-anchor",
+ active: false,
+ wrapperStyle: {},
+ anchorStyle: {}
+ inject: ['UIndexList'],
+ this.UIndexList.children.push(this);
+ this.UIndexList.updateData();
+ customAnchorStyle() {
+ return Object.assign(this.anchorStyle, this.customStyle);
+ .u-index-anchor {
+ padding: 14rpx 24rpx;
+ color: #606266;
+ line-height: 1.2;
+ background-color: rgb(245, 245, 245);
+ .u-index-anchor--active {
+ color: #2979ff;
@@ -0,0 +1,321 @@
+ <view class="u-index-bar">
+ <view v-if="showSidebar" class="u-index-bar__sidebar" @touchstart.stop.prevent="onTouchMove" @touchmove.stop.prevent="onTouchMove"
+ @touchend.stop.prevent="onTouchStop" @touchcancel.stop.prevent="onTouchStop">
+ <view v-for="(item, index) in indexList" :key="index" class="u-index-bar__index" :style="{zIndex: zIndex + 1, color: activeAnchorIndex === index ? activeColor : ''}"
+ :data-index="index">
+ <view class="u-indexed-list-alert" v-if="touchmove && indexList[touchmoveIndex]" :style="{
+ zIndex: alertZIndex
+ <text>{{indexList[touchmoveIndex]}}</text>
+ var indexList = function() {
+ var indexList = [];
+ var charCodeOfA = 'A'.charCodeAt(0);
+ for (var i = 0; i < 26; i++) {
+ indexList.push(String.fromCharCode(charCodeOfA + i));
+ return indexList;
+ * indexList 索引列表
+ * @property {Number String} scroll-top 当前滚动高度,自定义组件无法获得滚动条事件,所以依赖接入方传入
+ * @property {Array} index-list 索引字符列表,数组(默认A-Z)
+ * @property {Number String} z-index 锚点吸顶时的层级(默认965)
+ * @property {Boolean} sticky 是否开启锚点自动吸顶(默认true)
+ * @property {Number String} offset-top 锚点自动吸顶时与顶部的距离(默认0)
+ * @property {String} highlight-color 锚点和右边索引字符高亮颜色(默认#2979ff)
+ * @event {Function} select 选中右边索引字符时触发
+ * @example <u-index-list :scrollTop="scrollTop"></u-index-list>
+ name: "u-index-list",
+ sticky: {
+ offsetTop: {
+ indexList: {
+ return indexList()
+ // #ifdef H5
+ this.stickyOffsetTop = this.offsetTop ? uni.upx2px(this.offsetTop) : 44;
+ // #ifndef H5
+ this.stickyOffsetTop = this.offsetTop ? uni.upx2px(this.offsetTop) : 0;
+ // 只能在created生命周期定义children,如果在data定义,会因为在子组件中通过provide/inject
+ // 进行push时而导致的莫名其妙的错误
+ UIndexList: this
+ activeAnchorIndex: 0,
+ showSidebar: true,
+ // children: [],
+ touchmove: false,
+ touchmoveIndex: 0,
+ scrollTop() {
+ this.updateData()
+ // 弹出toast的z-index值
+ alertZIndex() {
+ return this.$u.zIndex.toast;
+ updateData() {
+ this.timer && clearTimeout(this.timer);
+ this.timer = setTimeout(() => {
+ this.showSidebar = !!this.children.length;
+ this.setRect().then(() => {
+ this.onScroll();
+ }, 0);
+ setRect() {
+ return Promise.all([
+ this.setAnchorsRect(),
+ this.setListRect(),
+ this.setSiderbarRect()
+ ]);
+ setAnchorsRect() {
+ return Promise.all(this.children.map((anchor, index) => anchor
+ .$uGetRect('.u-index-anchor-wrapper')
+ .then((rect) => {
+ Object.assign(anchor, {
+ height: rect.height,
+ top: rect.top
+ })));
+ setListRect() {
+ return this.$uGetRect('.u-index-bar').then((rect) => {
+ Object.assign(this, {
+ top: rect.top + this.scrollTop
+ setSiderbarRect() {
+ return this.$uGetRect('.u-index-bar__sidebar').then(rect => {
+ this.sidebar = {
+ getActiveAnchorIndex() {
+ const {
+ children
+ } = this;
+ sticky
+ for (let i = this.children.length - 1; i >= 0; i--) {
+ const preAnchorHeight = i > 0 ? children[i - 1].height : 0;
+ const reachTop = sticky ? preAnchorHeight : 0;
+ if (reachTop >= children[i].top) {
+ return i;
+ return -1;
+ onScroll() {
+ children = []
+ if (!children.length) {
+ sticky,
+ stickyOffsetTop,
+ zIndex,
+ scrollTop,
+ activeColor
+ const active = this.getActiveAnchorIndex();
+ this.activeAnchorIndex = active;
+ if (sticky) {
+ let isActiveAnchorSticky = false;
+ if (active !== -1) {
+ isActiveAnchorSticky =
+ children[active].top <= 0;
+ children.forEach((item, index) => {
+ if (index === active) {
+ let wrapperStyle = '';
+ let anchorStyle = {
+ color: `${activeColor}`
+ if (isActiveAnchorSticky) {
+ wrapperStyle = {
+ height: `${children[index].height}px`
+ anchorStyle = {
+ top: `${stickyOffsetTop}px`,
+ zIndex: `${zIndex ? zIndex : this.$u.zIndex.indexListSticky}`,
+ item.active = active;
+ item.wrapperStyle = wrapperStyle;
+ item.anchorStyle = anchorStyle;
+ } else if (index === active - 1) {
+ const currentAnchor = children[index];
+ const currentOffsetTop = currentAnchor.top;
+ const targetOffsetTop = index === children.length - 1 ?
+ this.top :
+ children[index + 1].top;
+ const parentOffsetHeight = targetOffsetTop - currentOffsetTop;
+ const translateY = parentOffsetHeight - currentAnchor.height;
+ const anchorStyle = {
+ position: 'relative',
+ transform: `translate3d(0, ${translateY}px, 0)`,
+ item.active = false;
+ item.anchorStyle = '';
+ item.wrapperStyle = '';
+ onTouchMove(event) {
+ this.touchmove = true;
+ const sidebarLength = this.children.length;
+ const touch = event.touches[0];
+ const itemHeight = this.sidebar.height / sidebarLength;
+ let clientY = 0;
+ clientY = touch.clientY;
+ let index = Math.floor((clientY - this.sidebar.top) / itemHeight);
+ if (index < 0) {
+ index = 0;
+ } else if (index > sidebarLength - 1) {
+ index = sidebarLength - 1;
+ this.touchmoveIndex = index;
+ this.scrollToAnchor(index);
+ onTouchStop() {
+ this.touchmove = false;
+ this.scrollToAnchorIndex = null;
+ scrollToAnchor(index) {
+ if (this.scrollToAnchorIndex === index) {
+ this.scrollToAnchorIndex = index;
+ const anchor = this.children.find((item) => item.index === this.indexList[index]);
+ if (anchor) {
+ this.$emit('select', anchor.index);
+ duration: 0,
+ scrollTop: anchor.top + this.scrollTop
+ .u-index-bar {
+ position: relative
+ .u-index-bar__sidebar {
+ transform: translateY(-50%);
+ z-index: 99;
+ .u-index-bar__index {
+ padding: 8rpx 18rpx;
+ line-height: 1
+ .u-indexed-list-alert {
+ width: 120rpx;
+ height: 120rpx;
+ right: 90rpx;
+ margin-top: -60rpx;
+ border-radius: 24rpx;
+ font-size: 50rpx;
+ background-color: rgba(0, 0, 0, 0.65);
+ z-index: 9999999;
+ .u-indexed-list-alert text {
@@ -0,0 +1,366 @@
+ class="u-input"
+ 'u-input--border': border,
+ 'u-input--error': validateState
+ padding: `0 ${border ? 20 : 0}rpx`,
+ borderColor: borderColor,
+ textAlign: inputAlign
+ @tap.stop="inputClick"
+ <textarea
+ v-if="type == 'textarea'"
+ class="u-input__input u-input__textarea"
+ :style="[getStyle]"
+ :value="defaultValue"
+ :fixed="fixed"
+ :autoHeight="autoHeight"
+ :selection-end="uSelectionEnd"
+ :selection-start="uSelectionStart"
+ :cursor-spacing="getCursorSpacing"
+ @input="handleInput"
+ @blur="handleBlur"
+ class="u-input__input"
+ :type="type == 'password' ? 'text' : type"
+ :password="type == 'password' && !showPassword"
+ :disabled="disabled || type === 'select'"
+ <view class="u-input__right-icon u-flex">
+ <view class="u-input__right-icon__clear u-input__right-icon__item" @tap="onClear" v-if="clearable && value != '' && focused">
+ <u-icon size="32" name="close-circle-fill" color="#c0c4cc"/>
+ <view class="u-input__right-icon__clear u-input__right-icon__item" v-if="passwordIcon && type == 'password'">
+ <u-icon size="32" :name="!showPassword ? 'eye' : 'eye-fill'" color="#c0c4cc" @click="showPassword = !showPassword"/>
+ <view class="u-input__right-icon--select u-input__right-icon__item" v-if="type == 'select'" :class="{
+ 'u-input__right-icon--select--reverse': selectOpen
+ <u-icon name="arrow-down-fill" size="26" color="#c0c4cc"></u-icon>
+ * input 输入框
+ * @description 此组件为一个输入框,默认没有边框和样式,是专门为配合表单组件u-form而设计的,利用它可以快速实现表单验证,输入内容,下拉选择等功能。
+ * @tutorial http://uviewui.com/components/input.html
+ * @property {String} type 模式选择,见官网说明
+ * @property {Boolean} clearable 是否显示右侧的清除图标(默认true)
+ * @property {} v-model 用于双向绑定输入框的值
+ * @property {String} input-align 输入框文字的对齐方式(默认left)
+ * @property {String} placeholder placeholder显示值(默认 '请输入内容')
+ * @property {Boolean} disabled 是否禁用输入框(默认false)
+ * @property {String Number} maxlength 输入框的最大可输入长度(默认140)
+ * @property {String Number} selection-start 光标起始位置,自动聚焦时有效,需与selection-end搭配使用(默认-1)
+ * @property {String Number} maxlength 光标结束位置,自动聚焦时有效,需与selection-start搭配使用(默认-1)
+ * @property {String Number} cursor-spacing 指定光标与键盘的距离,单位px(默认0)
+ * @property {String} placeholderStyle placeholder的样式,字符串形式,如"color: red;"(默认 "color: #c0c4cc;")
+ * @property {String} confirm-type 设置键盘右下角按钮的文字,仅在type为text时生效(默认done)
+ * @property {Object} custom-style 自定义输入框的样式,对象形式
+ * @property {Boolean} focus 是否自动获得焦点(默认false)
+ * @property {Boolean} fixed 如果type为textarea,且在一个"position:fixed"的区域,需要指明为true(默认false)
+ * @property {Boolean} password-icon type为password时,是否显示右侧的密码查看图标(默认true)
+ * @property {Boolean} border 是否显示边框(默认false)
+ * @property {String} border-color 输入框的边框颜色(默认#dcdfe6)
+ * @property {Boolean} auto-height 是否自动增高输入区域,type为textarea时有效(默认true)
+ * @property {String Number} height 高度,单位rpx(text类型时为70,textarea时为100)
+ * @example <u-input v-model="value" :type="type" :border="border" />
+ name: 'u-input',
+ // 输入框的类型,textarea,text,number
+ placeholder: {
+ default: '请输入内容'
+ placeholderStyle: {
+ default: 'color: #c0c4cc;'
+ // 如果 textarea 是在一个 position:fixed 的区域,需要显示指定属性 fixed 为 true
+ fixed: {
+ // 是否自动获得焦点
+ focus: {
+ // 密码类型时,是否显示右侧的密码图标
+ passwordIcon: {
+ // input|textarea是否显示边框
+ // 输入框的边框颜色
+ // type=select时,旋转右侧的图标,标识当前处于打开还是关闭select的状态
+ // open-打开,close-关闭
+ selectOpen: {
+ // 高度,单位rpx
+ // 是否可清空
+ // 指定光标与键盘的距离,单位 px
+ cursorSpacing: {
+ // 光标起始位置,自动聚焦时有效,需与selection-end搭配使用
+ selectionStart: {
+ default: -1
+ // 光标结束位置,自动聚焦时有效,需与selection-start搭配使用
+ selectionEnd: {
+ defaultValue: this.value,
+ inputHeight: 70, // input的高度
+ textareaHeight: 100, // textarea的高度
+ validateState: false, // 当前input的验证状态,用于错误时,边框是否改为红色
+ focused: false, // 当前是否处于获得焦点的状态
+ showPassword: false, // 是否预览密码
+ value(nVal, oVal) {
+ this.defaultValue = nVal;
+ // 当值发生变化,且为select类型时(此时input被设置为disabled,不会触发@input事件),模拟触发@input事件
+ if(nVal != oVal && this.type == 'select') this.handleInput({
+ detail: {
+ value: nVal
+ return Number(this.maxlength);
+ getStyle() {
+ // 如果没有自定义高度,就根据type为input还是textare来分配一个默认的高度
+ style.minHeight = this.height ? this.height + 'rpx' : this.type == 'textarea' ?
+ this.textareaHeight + 'rpx' : this.inputHeight + 'rpx';
+ style = Object.assign(style, this.customStyle);
+ getCursorSpacing() {
+ return Number(this.cursorSpacing);
+ // 光标起始位置
+ uSelectionStart() {
+ return String(this.selectionStart);
+ // 光标结束位置
+ uSelectionEnd() {
+ return String(this.selectionEnd);
+ // 监听u-form-item发出的错误事件,将输入框边框变红色
+ this.$on('on-form-item-error', this.onFormItemError);
+ * change 事件
+ * @param event
+ handleInput(event) {
+ // 当前model 赋值
+ this.defaultValue = value;
+ // vue 原生的方法 return 出去
+ // 过一个生命周期再发送事件给u-form-item,否则this.$emit('input')更新了父组件的值,但是微信小程序上
+ // 尚未更新到u-form-item,导致获取的值为空,从而校验混论
+ this.dispatch('u-form-item', 'on-form-change', value);
+ * blur 事件
+ handleBlur(event) {
+ this.$emit('blur', event.detail.value);
+ this.dispatch('u-form-item', 'on-form-blur', event.detail.value);
+ onFormItemError(status) {
+ this.validateState = status;
+ this.$emit('focus');
+ inputClick() {
+.u-input {
+ &__input {
+ //height: $u-form-item-height;
+ &__textarea {
+ padding: 10rpx 0;
+ line-height: normal;
+ &--border {
+ border-radius: 4px;
+ border: 1px solid $u-form-item-border-color;
+ border-color: $u-type-error!important;
+ &__right-icon {
+ &--select {
+ transition: transform .4s;
+ &--reverse {
+ transform: rotate(-180deg);
@@ -0,0 +1,217 @@
+ <u-popup class="" :mask="mask" :maskCloseAble="maskCloseAble" mode="bottom" :popup="false" v-model="value" length="auto"
+ :safeAreaInsetBottom="safeAreaInsetBottom" @close="popupClose" :zIndex="uZIndex">
+ <view class="u-tooltip" v-if="tooltip">
+ <view class="u-tooltip-item u-tooltip-cancel" hover-class="u-tooltip-cancel-hover" @tap="onCancel">
+ {{cancelBtn ? cancelText : ''}}
+ <view v-if="showTips" class="u-tooltip-item u-tooltip-tips">
+ {{tips ? tips : mode == 'number' ? '数字键盘' : mode == 'card' ? '身份证键盘' : '车牌号键盘'}}
+ <view v-if="confirmBtn" @tap="onConfirm" class="u-tooltip-item u-tooltips-submit" hover-class="u-tooltips-submit-hover">
+ {{confirmBtn ? confirmText : ''}}
+ <block v-if="mode == 'number' || mode == 'card'">
+ <u-number-keyboard :random="random" @backspace="backspace" @change="change" :mode="mode" :dotEnabled="dotEnabled"></u-number-keyboard>
+ <u-car-keyboard :random="random" @backspace="backspace" @change="change"></u-car-keyboard>
+ * keyboard 键盘
+ * @description 此为uViw自定义的键盘面板,内含了数字键盘,车牌号键,身份证号键盘3中模式,都有可以打乱按键顺序的选项。
+ * @tutorial https://www.uviewui.com/components/keyboard.html
+ * @property {String} mode 键盘类型,见官网基本使用的说明(默认number)
+ * @property {Boolean} dot-enabled 是否显示"."按键,只在mode=number时有效(默认true)
+ * @property {Boolean} tooltip 是否显示键盘顶部工具条(默认true)
+ * @property {String} tips 工具条中间的提示文字,见上方基本使用的说明,如不需要,请传""空字符
+ * @property {Boolean} cancel-btn 是否显示工具条左边的"取消"按钮(默认true)
+ * @property {Boolean} confirm-btn 是否显示工具条右边的"完成"按钮(默认true)
+ * @property {Boolean} mask 是否显示遮罩(默认true)
+ * @property {String} confirm-text 确认按钮的文字
+ * @property {String} cancel-text 取消按钮的文字
+ * @property {Number String} z-index 弹出键盘的z-index值(默认1075)
+ * @property {Boolean} random 是否打乱键盘按键的顺序(默认false)
+ * @property {Boolean} mask-close-able 是否允许点击遮罩收起键盘(默认true)
+ * @event {Function} change 按键被点击(不包含退格键被点击)
+ * @event {Function} cancel 键盘顶部工具条左边的"取消"按钮被点击
+ * @event {Function} confirm 键盘顶部工具条右边的"完成"按钮被点击
+ * @event {Function} backspace 键盘退格键被点击
+ * @example <u-keyboard mode="number" v-model="show"></u-keyboard>
+ // 键盘的类型,number-数字键盘,card-身份证键盘,car-车牌号键盘
+ default: 'number'
+ // 是否显示键盘的"."符号
+ dotEnabled: {
+ // 是否显示顶部工具条
+ tooltip: {
+ // 是否显示工具条中间的提示
+ showTips: {
+ // 工具条中间的提示文字
+ // 是否显示工具条左边的"取消"按钮
+ // 是否显示工具条右边的"完成"按钮
+ confirmBtn: {
+ // 是否允许通过点击遮罩关闭键盘
+ // 通过双向绑定控制键盘的弹出与收起
+ // 是否显示遮罩,某些时候数字键盘时,用户希望看到自己的数值,所以可能不想要遮罩
+ mask: {
+ // z-index值
+ // 取消按钮的文字
+ // 确认按钮的文字
+ confirmText: {
+ default: '确认'
+ //show: false
+ this.$emit('change', e);
+ // 键盘关闭
+ // 通过发送input这个特殊的事件名,可以修改父组件传给props的value的变量,也即双向绑定
+ // 输入完成
+ onConfirm() {
+ this.$emit('confirm');
+ // 取消输入
+ onCancel() {
+ this.$emit('cancel');
+ // 退格键
+ backspace() {
+ // 关闭键盘
+ // close() {
+ // this.show = false;
+ // // 打开键盘
+ // open() {
+ // this.show = true;
+ .u-keyboard {
+ z-index: 1003;
+ .u-tooltip {
+ .u-tooltip-item {
+ color: #333333;
+ flex: 0 0 33.333333%;
+ padding: 20rpx 10rpx;
+ .u-tooltips-submit {
+ flex-grow: 1;
+ flex-wrap: 0;
+ padding-right: 40rpx;
+ .u-tooltip-cancel {
+ padding-left: 40rpx;
+ color: #888888;
+ .u-tooltips-submit-hover {
+ .u-tooltip-cancel-hover {
@@ -0,0 +1,145 @@
+ <view class="u-progress" :style="{
+ borderRadius: round ? '100rpx' : 0,
+ height: height + 'rpx',
+ backgroundColor: inactiveColor
+ <view :class="[
+ type ? `u-type-${type}-bg` : '',
+ striped ? 'u-striped' : '',
+ striped && stripedActive ? 'u-striped-active' : ''
+ ]" class="u-active" :style="[progressStyle]">
+ <slot v-if="$slots.default" />
+ <block v-else-if="showPercent">
+ {{percent + '%'}}
+ * lineProgress 线型进度条
+ * @description 展示操作或任务的当前进度,比如上传文件,是一个线形的进度条。
+ * @tutorial https://www.uviewui.com/components/lineProgress.html
+ * @property {String Number} percent 进度条百分比值,为数值类型,0-100
+ * @property {Boolean} round 进度条两端是否为半圆(默认true)
+ * @property {String} active-color 进度条激活部分的颜色(默认#19be6b)
+ * @property {String} inactive-color 进度条的底色(默认#ececec)
+ * @property {Boolean} show-percent 是否在进度条内部显示当前的百分比值数值(默认true)
+ * @property {String Number} height 进度条的高度,单位rpx(默认28)
+ * @property {Boolean} striped 是否显示进度条激活部分的条纹(默认false)
+ * @property {Boolean} striped-active 条纹是否具有动态效果(默认false)
+ * @example <u-line-progress :percent="70" :show-percent="true"></u-line-progress>
+ name: "u-line-progress",
+ // 两端是否显示半圆形
+ round: {
+ // 主题颜色
+ // 激活部分的颜色
+ // 进度百分比,数值
+ // 是否在进度条内部显示百分比的值
+ showPercent: {
+ // 进度条的高度,单位rpx
+ // 是否显示条纹
+ striped: {
+ // 条纹是否显示活动状态
+ stripedActive: {
+ progressStyle() {
+ style.width = this.percent + '%';
+ if(this.activeColor) style.backgroundColor = this.activeColor;
+ .u-progress {
+ height: 15px;
+ .u-active {
+ justify-items: flex-end;
+ justify-content: space-around;
+ font-size: 20rpx;
+ transition: all 0.4s ease;
+ .u-striped {
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+ background-size: 39px 39px;
+ .u-striped-active {
+ animation: progress-stripes 2s linear infinite;
+ @keyframes progress-stripes {
+ 0% {
+ background-position: 0 0;
+ 100% {
+ background-position: 39px 0;
+ <view class="u-line" :style="[lineStyle]">
+ * line 线条
+ * @description 此组件一般用于显示一根线条,用于分隔内容块,有横向和竖向两种模式,且能设置0.5px线条,使用也很简单
+ * @property {String} color 线条的颜色(默认#e4e7ed)
+ * @property {String} length 长度,竖向时表现为高度,横向时表现为长度,可以为百分比,带rpx单位的值等
+ * @property {String} direction 线条的方向,row-横向,col-竖向(默认row)
+ * @property {String} border-style 线条的类型,solid-实线,dashed-方形虚线,dotted-圆点虚线(默认solid)
+ * @property {Boolean} hair-line 是否显示细线条(默认true)
+ * @property {String} margin 线条与上下左右元素的间距,字符串形式,如"30rpx"
+ * @example <u-line color="red"></u-line>
+ name: 'u-line',
+ default: '#e4e7ed'
+ // 长度,竖向时表现为高度,横向时表现为长度,可以为百分比,带rpx单位的值等
+ length: {
+ // 线条方向,col-竖向,row-横向
+ // 是否显示细边框
+ // 线条与上下左右元素的间距,字符串形式,如"30rpx"、"20rpx 30rpx"
+ default: '0'
+ // 线条的类型,solid-实线,dashed-方形虚线,dotted-圆点虚线
+ borderStyle: {
+ default: 'solid'
+ style.margin = this.margin;
+ // 如果是水平线条,边框高度为1px,再通过transform缩小一半,就是0.5px了
+ if(this.direction == 'row') {
+ // 此处采用兼容分开写,兼容nvue的写法
+ style.borderBottomWidth = '1px';
+ style.borderBottomStyle = this.borderStyle;
+ style.width = this.$u.addUnit(this.length);
+ if(this.hairLine) style.transform = 'scaleY(0.5)';
+ // 如果是竖向线条,边框宽度为1px,再通过transform缩小一半,就是0.5px了
+ style.borderLeftWidth = '1px';
+ style.borderLeftStyle = this.borderStyle;
+ style.height = this.$u.addUnit(this.length);
+ if(this.hairLine) style.transform = 'scaleX(0.5)';
+ style.borderColor = this.color;
+ .u-line {
@@ -0,0 +1,89 @@
+ <text class="u-link" @tap.stop="openLink" :style="{
+ borderBottom: underLine ? `1px solid ${lineColor ? lineColor : color}` : 'none',
+ paddingBottom: underLine ? '0rpx' : '0'
+ * link 超链接
+ * @description 该组件为超链接组件,在不同平台有不同表现形式:在APP平台会通过plus环境打开内置浏览器,在小程序中把链接复制到粘贴板,同时提示信息,在H5中通过window.open打开链接。
+ * @tutorial https://www.uviewui.com/components/link.html
+ * @property {String} color 文字颜色(默认#606266)
+ * @property {String Number} font-size 字体大小,单位rpx(默认28)
+ * @property {Boolean} under-line 是否显示下划线(默认false)
+ * @property {String} href 跳转的链接,要带上http(s)
+ * @property {String} line-color 下划线颜色,默认同color参数颜色
+ * @property {String} mp-tips 各个小程序平台把链接复制到粘贴板后的提示语(默认“链接已复制,请在浏览器打开”)
+ * @example <u-link href="http://www.uviewui.com">蜀道难,难于上青天</u-link>
+ name: "u-link",
+ // 是否显示下划线
+ underLine: {
+ // 要跳转的链接
+ href: {
+ // 小程序中复制到粘贴板的提示语
+ mpTips: {
+ default: '链接已复制,请在浏览器打开'
+ // 下划线颜色
+ lineColor: {
+ openLink() {
+ // #ifdef APP-PLUS
+ plus.runtime.openURL(this.href)
+ window.open(this.href)
+ uni.setClipboardData({
+ data: this.href,
+ success() {
+ uni.hideToast();
+ this.$u.toast(this.mpTips);
+ .u-link {
@@ -0,0 +1,25 @@
+ <view class="u-loading-page">
@@ -0,0 +1,101 @@
+ <view v-if="show" class="u-loading" :class="mode == 'circle' ? 'u-loading-circle' : 'u-loading-flower'" :style="[cricleStyle]">
+ * loading 加载动画
+ * @description 警此组件为一个小动画,目前用在uView的loadmore加载更多和switch开关等组件的正在加载状态场景。
+ * @tutorial https://www.uviewui.com/components/loading.html
+ * @property {String} mode 模式选择,见官网说明(默认circle)
+ * @property {String} color 动画活动区域的颜色,只对 mode = flower 模式有效(默认#c7c7c7)
+ * @property {String Number} size 加载图标的大小,单位rpx(默认34)
+ * @property {Boolean} show 是否显示动画(默认true)
+ * @example <u-loading mode="circle"></u-loading>
+ name: "u-loading",
+ // 动画的类型
+ // 动画的颜色
+ default: '#c7c7c7'
+ // 加载图标的大小,单位rpx
+ default: '34'
+ // 是否显示动画
+ // 加载中圆圈动画的样式
+ cricleStyle() {
+ style.width = this.size + 'rpx';
+ style.height = this.size + 'rpx';
+ if (this.mode == 'circle') style.borderColor = `#e4e4e4 #e4e4e4 #e4e4e4 ${this.color ? this.color : '#c7c7c7'}`;
+ .u-loading-circle {
+ width: 28rpx;
+ height: 28rpx;
+ background: 0 0;
+ border: 2px solid;
+ border-color: #e5e5e5 #e5e5e5 #e5e5e5 #8f8d8e;
+ animation: u-circle 1s linear infinite;
+ .u-loading-flower {
+ width: 20px;
+ height: 20px;
+ -webkit-animation: a 1s steps(12) infinite;
+ animation: u-flower 1s steps(12) infinite;
+ background: transparent url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAiIGhlaWdodD0iMTIwIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCI+PHBhdGggZmlsbD0ibm9uZSIgZD0iTTAgMGgxMDB2MTAwSDB6Ii8+PHJlY3Qgd2lkdGg9IjciIGhlaWdodD0iMjAiIHg9IjQ2LjUiIHk9IjQwIiBmaWxsPSIjRTlFOUU5IiByeD0iNSIgcnk9IjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTMwKSIvPjxyZWN0IHdpZHRoPSI3IiBoZWlnaHQ9IjIwIiB4PSI0Ni41IiB5PSI0MCIgZmlsbD0iIzk4OTY5NyIgcng9IjUiIHJ5PSI1IiB0cmFuc2Zvcm09InJvdGF0ZSgzMCAxMDUuOTggNjUpIi8+PHJlY3Qgd2lkdGg9IjciIGhlaWdodD0iMjAiIHg9IjQ2LjUiIHk9IjQwIiBmaWxsPSIjOUI5OTlBIiByeD0iNSIgcnk9IjUiIHRyYW5zZm9ybT0icm90YXRlKDYwIDc1Ljk4IDY1KSIvPjxyZWN0IHdpZHRoPSI3IiBoZWlnaHQ9IjIwIiB4PSI0Ni41IiB5PSI0MCIgZmlsbD0iI0EzQTFBMiIgcng9IjUiIHJ5PSI1IiB0cmFuc2Zvcm09InJvdGF0ZSg5MCA2NSA2NSkiLz48cmVjdCB3aWR0aD0iNyIgaGVpZ2h0PSIyMCIgeD0iNDYuNSIgeT0iNDAiIGZpbGw9IiNBQkE5QUEiIHJ4PSI1IiByeT0iNSIgdHJhbnNmb3JtPSJyb3RhdGUoMTIwIDU4LjY2IDY1KSIvPjxyZWN0IHdpZHRoPSI3IiBoZWlnaHQ9IjIwIiB4PSI0Ni41IiB5PSI0MCIgZmlsbD0iI0IyQjJCMiIgcng9IjUiIHJ5PSI1IiB0cmFuc2Zvcm09InJvdGF0ZSgxNTAgNTQuMDIgNjUpIi8+PHJlY3Qgd2lkdGg9IjciIGhlaWdodD0iMjAiIHg9IjQ2LjUiIHk9IjQwIiBmaWxsPSIjQkFCOEI5IiByeD0iNSIgcnk9IjUiIHRyYW5zZm9ybT0icm90YXRlKDE4MCA1MCA2NSkiLz48cmVjdCB3aWR0aD0iNyIgaGVpZ2h0PSIyMCIgeD0iNDYuNSIgeT0iNDAiIGZpbGw9IiNDMkMwQzEiIHJ4PSI1IiByeT0iNSIgdHJhbnNmb3JtPSJyb3RhdGUoLTE1MCA0NS45OCA2NSkiLz48cmVjdCB3aWR0aD0iNyIgaGVpZ2h0PSIyMCIgeD0iNDYuNSIgeT0iNDAiIGZpbGw9IiNDQkNCQ0IiIHJ4PSI1IiByeT0iNSIgdHJhbnNmb3JtPSJyb3RhdGUoLTEyMCA0MS4zNCA2NSkiLz48cmVjdCB3aWR0aD0iNyIgaGVpZ2h0PSIyMCIgeD0iNDYuNSIgeT0iNDAiIGZpbGw9IiNEMkQyRDIiIHJ4PSI1IiByeT0iNSIgdHJhbnNmb3JtPSJyb3RhdGUoLTkwIDM1IDY1KSIvPjxyZWN0IHdpZHRoPSI3IiBoZWlnaHQ9IjIwIiB4PSI0Ni41IiB5PSI0MCIgZmlsbD0iI0RBREFEQSIgcng9IjUiIHJ5PSI1IiB0cmFuc2Zvcm09InJvdGF0ZSgtNjAgMjQuMDIgNjUpIi8+PHJlY3Qgd2lkdGg9IjciIGhlaWdodD0iMjAiIHg9IjQ2LjUiIHk9IjQwIiBmaWxsPSIjRTJFMkUyIiByeD0iNSIgcnk9IjUiIHRyYW5zZm9ybT0icm90YXRlKC0zMCAtNS45OCA2NSkiLz48L3N2Zz4=) no-repeat;
+ background-size: 100%;
+ @keyframes u-flower {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ to {
+ -webkit-transform: rotate(1turn);
+ transform: rotate(1turn);
+ @-webkit-keyframes u-circle {
+ transform: rotate(0);
+ transform: rotate(360deg);
@@ -0,0 +1,199 @@
+ <view class="u-load-more-wrap" :style="{
+ marginTop: marginTop + 'rpx',
+ <!-- 加载中和没有更多的状态才显示两边的横线 -->
+ <view :class="status == 'loadmore' || status == 'nomore' ? 'u-more' : ''" class="u-load-more-inner">
+ <u-loading class="u-loadmore-icon" :color="iconColor" :mode="iconType == 'circle' ? 'circle' : 'flower'" :show="status == 'loading' && icon"></u-loading>
+ <!-- 如果没有更多的状态下,显示内容为dot(粗点),加载特定样式 -->
+ <view :style="[loadTextStyle]" :class="[(status == 'nomore' && isDot == true) ? 'u-dot-text' : 'u-more-text']" @tap="loadMore">
+ {{ showText }}
+ * loadmore 加载更多
+ * @description 此组件一般用于标识页面底部加载数据时的状态。
+ * @tutorial https://www.uviewui.com/components/loadMore.html
+ * @property {String} status 组件状态(默认loadmore)
+ * @property {String} bg-color 组件背景颜色,在页面是非白色时会用到(默认#ffffff)
+ * @property {Boolean} icon 加载中时是否显示图标(默认true)
+ * @property {String} icon-type 加载中时的图标类型(默认circle)
+ * @property {String} icon-color icon-type为circle时有效,加载中的动画图标的颜色(默认#b7b7b7)
+ * @property {Boolean} is-dot status为nomore时,内容显示为一个"●"(默认false)
+ * @property {String} color 字体颜色(默认#606266)
+ * @property {String Number} margin-top 到上一个相邻元素的距离
+ * @property {String Number} margin-bottom 到下一个相邻元素的距离
+ * @property {Object} load-text 自定义显示的文字,见上方说明示例
+ * @event {Function} loadmore status为loadmore时,点击组件会发出此事件
+ * @example <u-loadmore :status="status" icon-type="iconType" load-text="loadText" />
+ name: "u-loadmore",
+ //当前页面背景颜色,如果背景为非白色的时候,需要把此值设置为背景的颜色
+ // 是否显示加载中的图标
+ // 组件状态,loadmore-加载前的状态,loading-加载中的状态,nomore-没有更多的状态
+ status: {
+ default: 'loadmore'
+ // 加载中状态的图标,flower-花朵状图标,circle-圆圈状图标
+ iconType: {
+ // 显示的文字
+ loadText: {
+ loadmore: '加载更多',
+ loading: '正在加载...',
+ nomore: '没有更多了'
+ // 在“没有更多”状态下,是否显示粗点
+ // 加载中显示圆圈动画时,动画的颜色
+ default: '#b7b7b7'
+ // 粗点
+ dotText: "●"
+ // 加载的文字显示的样式
+ loadTextStyle() {
+ color: this.color,
+ fontSize: this.fontSize + 'rpx',
+ zIndex: 1,
+ // 如果是加载中状态,动画和文字需要距离近一点
+ padding: this.status == 'loading' ? '0 8px' : '0 12px',
+ borderColor: `#e5e5e5 #e5e5e5 #e5e5e5 ${this.circleColor}`
+ // 加载中花朵动画形式
+ // 动画由base64图片生成,暂不支持修改
+ flowerStyle() {
+ // 显示的提示文字
+ let text = '';
+ if(this.status == 'loadmore') text = this.loadText.loadmore;
+ else if(this.status == 'loading') text = this.loadText.loading;
+ else if(this.status == 'nomore' && this.isDot) text = this.dotText;
+ else text = this.loadText.nomore;
+ return text;
+ loadMore() {
+ // 只有在“加载更多”的状态下才发送点击事件,内容不满一屏时无法触发底部上拉事件,所以需要点击来触发
+ if(this.status == 'loadmore') this.$emit('loadmore');
+ .u-load-more-wrap {
+ .u-load-more-inner {
+ .u-more {
+ width: 60%;
+ .u-more::before {
+ border-bottom: 1px solid #d4d4d4;
+ .u-dot-text {
+ .u-loadmore-icon {
@@ -0,0 +1,123 @@
+ <view class="u-mask" hover-stop-propagation :style="[maskStyle, zoomStyle]" @tap="click" @touchmove.stop.prevent :class="{
+ 'u-mask-zoom': zoom,
+ 'u-mask-show': show
+ * mask 遮罩
+ * @description 创建一个遮罩层,用于强调特定的页面元素,并阻止用户对遮罩下层的内容进行操作,一般用于弹窗场景
+ * @tutorial https://www.uviewui.com/components/mask.html
+ * @property {Boolean} show 是否显示遮罩(默认false)
+ * @property {String Number} z-index z-index 层级(默认1070)
+ * @property {Object} custom-style 自定义样式对象,见上方说明
+ * @property {String Number} duration 动画时长,单位毫秒(默认300)
+ * @property {Boolean} zoom 是否使用scale对这招进行缩放(默认true)
+ * @property {Boolean} mask-click-able 遮罩是否可点击,为false时点击不会发送click事件(默认true)
+ * @event {Function} click mask-click-able为true时,点击遮罩发送此事件
+ * @example <u-mask :show="show" @click="show = false"></u-mask>
+ name: "u-mask",
+ // 是否显示遮罩
+ // 层级z-index
+ // 用户自定义样式
+ // 遮罩的动画样式, 是否使用使用zoom进行scale进行缩放
+ // 遮罩的过渡时间,单位为ms
+ // 是否可以通过点击遮罩进行关闭
+ maskClickAble: {
+ zoomStyle: {
+ transform: ''
+ scale: 'scale(1.2, 1.2)'
+ show(n) {
+ if(n && this.zoom) {
+ // 当展示遮罩的时候,设置scale为1,达到缩小(原来为1.2)的效果
+ this.zoomStyle.transform = 'scale(1, 1)';
+ } else if(!n && this.zoom) {
+ // 当隐藏遮罩的时候,设置scale为1.2,达到放大(因为显示遮罩时已重置为1)的效果
+ this.zoomStyle.transform = this.scale;
+ maskStyle() {
+ style.backgroundColor = "rgba(0, 0, 0, 0.6)";
+ if(this.show) style.zIndex = this.zIndex ? this.zIndex : this.$u.zIndex.mask;
+ else style.zIndex = -1;
+ style.transition = `all ${this.duration / 1000}s ease-in-out`;
+ // 判断用户传递的对象是否为空,不为空就进行合并
+ if (Object.keys(this.customStyle).length) style = {
+ ...style,
+ ...this.customStyle
+ if (!this.maskClickAble) return;
+ .u-mask {
+ transition: transform 0.3s;
+ .u-mask-show {
+ .u-mask-zoom {
+ transform: scale(1.2, 1.2);
@@ -0,0 +1,304 @@
+ <view class="u-char-box">
+ <view class="u-char-flex">
+ <input :disabled="disabledKeyboard" :value="valueModel" type="number" :focus="focus" :maxlength="maxlength" class="u-input" @input="getVal"/>
+ <view v-for="(item, index) in maxlength" :key="index">
+ <view :class="[breathe && charArrLength == index ? 'u-breathe' : '', 'u-char-item',
+ charArrLength === index && mode == 'box' ? 'u-box-active' : '',
+ mode === 'box' ? 'u-box' : '']" :style="{
+ width: width + 'rpx',
+ height: width + 'rpx',
+ color: inactiveColor
+ <view class="u-placeholder-line" :style="{
+ display: charArrLength === index ? 'block' : 'none',
+ height: width * 0.5 +'rpx'
+ v-if="mode !== 'middleLine'"
+ <view v-if="mode === 'middleLine' && charArrLength <= index" :class="[breathe && charArrLength == index ? 'u-breathe' : '', charArrLength === index ? 'u-middle-line-active' : '']"
+ class="u-middle-line" :style="{height: bold ? '4px' : '2px', background: charArrLength === index ? activeColor : inactiveColor}"></view>
+ <view v-if="mode === 'bottomLine'" :class="[breathe && charArrLength == index ? 'u-breathe' : '', charArrLength === index ? 'u-buttom-line-active' : '']"
+ class="u-bottom-line" :style="{height: bold ? '4px' : '2px', background: charArrLength === index ? activeColor : inactiveColor}"></view>
+ <block v-if="!dotFill"> {{ charArr[index] ? charArr[index] : ''}}</block>
+ <text class="u-dot">{{ charArr[index] ? '●' : ''}}</text>
+ * messageInput 验证码输入框
+ * @description 该组件一般用于验证用户短信验证码的场景,也可以结合uView的键盘组件使用
+ * @tutorial https://www.uviewui.com/components/messageInput.html
+ * @property {String Number} maxlength 输入字符个数(默认4)
+ * @property {Boolean} dot-fill 是否用圆点填充(默认false)
+ * @property {String} mode 模式选择,见上方"基本使用"说明(默认box)
+ * @property {String Number} value 预置值
+ * @property {Boolean} breathe 是否开启呼吸效果,见上方说明(默认true)
+ * @property {Boolean} focus 是否自动获取焦点(默认false)
+ * @property {Boolean} bold 字体和输入横线是否加粗(默认true)
+ * @property {String Number} font-size 字体大小,单位rpx(默认60)
+ * @property {String} active-color 当前激活输入框的样式(默认#2979ff)
+ * @property {String} inactive-color 非激活输入框的样式,文字颜色同此值(默认#606266)
+ * @property {String | Number} width 输入框宽度,单位rpx,高等于宽(默认80)
+ * @property {Boolean} disabled-keyboard 禁止点击输入框唤起系统键盘(默认false)
+ * @event {Function} change 输入内容发生改变时触发,具体见官网说明
+ * @event {Function} finish 输入字符个数达maxlength值时触发,见官网说明
+ * @example <u-message-input mode="bottomLine"></u-message-input>
+ name: "u-message-input",
+ // 最大输入长度
+ default: 4
+ // 是否用圆点填充
+ dotFill: {
+ // 显示模式,box-盒子模式,bottomLine-横线在底部模式,middleLine-横线在中部模式
+ default: "box"
+ // 预置值
+ // 当前激活输入item,是否带有呼吸效果
+ breathe: {
+ // 是否自动获取焦点
+ // 字体是否加粗
+ default: 60
+ // 激活样式
+ // 未激活的样式
+ // 输入框的大小,单位rpx,宽等于高
+ default: '80'
+ // 是否隐藏原生键盘,如果想用自定义键盘的话,需设置此参数为true
+ disabledKeyboard: {
+ // 此值设置为true,会在组件加载后无需maxlength变化就会执行一次本监听函数,无需再created生命周期中处理
+ immediate: true,
+ handler(val) {
+ this.maxlength = Number(val);
+ // 转为字符串
+ val = String(val);
+ // 超出部分截掉
+ this.valueModel = val.substring(0, this.maxlength);
+ valueModel: ""
+ // 是否显示呼吸灯效果
+ animationClass() {
+ if (this.breathe && this.charArr.length == index) return 'u-breathe';
+ else return '';
+ // 用于显示字符
+ charArr() {
+ return this.valueModel.split('');
+ charArrLength() {
+ return this.charArr.length;
+ getVal(e) {
+ let {
+ value
+ } = e.detail
+ this.valueModel = value;
+ // 判断长度是否超出了maxlength值,理论上不会发生,因为input组件设置了maxlength属性值
+ if (String(value).length > this.maxlength) return;
+ // 未达到maxlength之前,发送change事件,达到后发送finish事件
+ if (String(value).length == this.maxlength) {
+ this.$emit('finish', value);
+ @keyframes breathe {
+ opacity: 0.3;
+ 50% {
+ .u-char-box {
+ .u-char-flex {
+ .u-input {
+ left: -100%;
+ width: 200%;
+ background: none;
+ .u-char-item {
+ width: 90rpx;
+ height: 90rpx;
+ margin: 10rpx 10rpx;
+ font-size: 60rpx;
+ line-height: 90rpx;
+ .u-middle-line {
+ .u-box {
+ border: 2rpx solid #cccccc;
+ .u-box-active {
+ animation-timing-function: ease-in-out;
+ animation-duration: 1500ms;
+ animation-iteration-count: infinite;
+ animation-direction: alternate;
+ border: 2rpx solid $u-type-primary;
+ .u-middle-line-active {
+ background: $u-type-primary;
+ .u-breathe {
+ animation: breathe 2s infinite ease;
+ .u-placeholder-line {
+ display: none;
+ width: 2rpx;
+ height: 40rpx;
+ background: #333333;
+ animation: twinkling 1.5s infinite ease;
+ .u-animation-breathe {
+ animation-name: breathe;
+ .u-dot {
+ line-height: 34rpx;
+ height: 4px;
+ background: #000000;
+ width: 80%;
+ border-radius: 2px;
+ .u-buttom-line-active {
+ .u-bottom-line {
+ transform: translate(-50%);
@@ -0,0 +1,283 @@
+ <u-popup :zoom="zoom" mode="center" :popup="false" :z-index="uZIndex" v-model="value" :length="width"
+ :mask-close-able="maskCloseAble" :border-radius="borderRadius" @close="popupClose" :negative-top="negativeTop">
+ <view class="u-model">
+ <view v-if="showTitle" class="u-model__title u-line-1" :style="[titleStyle]">{{ title }}</view>
+ <view class="u-model__content">
+ <view :style="[contentStyle]" v-if="$slots.default">
+ <view v-else class="u-model__content__message" :style="[contentStyle]">{{ content }}</view>
+ <view class="u-model__footer u-border-top" v-if="showCancelButton || showConfirmButton">
+ <view v-if="showCancelButton" :hover-stay-time="100" hover-class="u-model__btn--hover" class="u-model__footer__button"
+ :style="[cancelBtnStyle]" @tap="cancel">
+ {{cancelText}}
+ <view v-if="showConfirmButton || $slots['confirm-button']" :hover-stay-time="100" :hover-class="asyncClose ? 'none' : 'u-model__btn--hover'"
+ class="u-model__footer__button hairline-left" :style="[confirmBtnStyle]" @tap="confirm">
+ <slot v-if="$slots['confirm-button']" name="confirm-button"></slot>
+ <u-loading mode="circle" :color="confirmColor" v-if="loading"></u-loading>
+ {{confirmText}}
+ * modal 模态框
+ * @description 弹出模态框,常用于消息提示、消息确认、在当前页面内完成特定的交互操作
+ * @tutorial https://www.uviewui.com/components/modal.html
+ * @property {Boolean} value 是否显示模态框
+ * @property {String | Number} z-index 层级
+ * @property {String} title 模态框标题(默认"提示")
+ * @property {String | Number} width 模态框宽度(默认600)
+ * @property {String} content 模态框内容(默认"内容")
+ * @property {Boolean} show-title 是否显示标题(默认true)
+ * @property {Boolean} async-close 是否异步关闭,只对确定按钮有效(默认false)
+ * @property {Boolean} show-confirm-button 是否显示确认按钮(默认true)
+ * @property {Stringr | Number} negative-top modal往上偏移的值
+ * @property {Boolean} show-cancel-button 是否显示取消按钮(默认false)
+ * @property {Boolean} mask-close-able 是否允许点击遮罩关闭modal(默认false)
+ * @property {String} confirm-text 确认按钮的文字内容(默认"确认")
+ * @property {String} cancel-text 取消按钮的文字内容(默认"取消")
+ * @property {String} cancel-color 取消按钮的颜色(默认"#606266")
+ * @property {String} confirm-color 确认按钮的文字内容(默认"#2979ff")
+ * @property {String | Number} border-radius 模态框圆角值,单位rpx(默认16)
+ * @property {Object} title-style 自定义标题样式,对象形式
+ * @property {Object} content-style 自定义内容样式,对象形式
+ * @property {Object} cancel-style 自定义取消按钮样式,对象形式
+ * @property {Object} confirm-style 自定义确认按钮样式,对象形式
+ * @property {Boolean} zoom 是否开启缩放模式(默认true)
+ * @event {Function} confirm 确认按钮被点击
+ * @event {Function} cancel 取消按钮被点击
+ * @example <u-modal :src="title" :content="content"></u-modal>
+ name: 'u-modal',
+ // 是否显示Modal
+ type: [String],
+ default: '提示'
+ // 弹窗宽度,可以是数值(rpx),百分比,auto等
+ default: 600
+ // 弹窗内容
+ content: {
+ default: '内容'
+ // 是否显示标题
+ showTitle: {
+ // 是否显示确认按钮
+ showConfirmButton: {
+ // 是否显示取消按钮
+ showCancelButton: {
+ // 确认文案
+ // 取消文案
+ // 确认按钮颜色
+ confirmColor: {
+ // 取消文字颜色
+ cancelColor: {
+ // 圆角值
+ default: 16
+ // 内容的样式
+ // 取消按钮的样式
+ cancelStyle: {
+ // 确定按钮的样式
+ confirmStyle: {
+ // 是否开启缩放效果
+ // 是否异步关闭,只对确定按钮有效
+ asyncClose: {
+ // 是否允许点击遮罩关闭modal
+ // 给一个负的margin-top,往上偏移,避免和键盘重合的情况
+ negativeTop: {
+ loading: false, // 确认按钮是否正在加载中
+ cancelBtnStyle() {
+ return Object.assign({
+ color: this.cancelColor
+ }, this.cancelStyle);
+ confirmBtnStyle() {
+ color: this.confirmColor
+ }, this.confirmStyle);
+ // 如果是异步关闭时,外部修改v-model的值为false时,重置内部的loading状态
+ // 避免下次打开的时候,状态混乱
+ value(n) {
+ if (n === true) this.loading = false;
+ // 异步关闭
+ if (this.asyncClose) {
+ this.loading = true;
+ // 目前popup弹窗关闭有一个延时操作,此处做一个延时
+ // 避免确认按钮文字变成了"确定"字样,modal还没消失,造成视觉不好的效果
+ }, 300);
+ // 点击遮罩关闭modal,设置v-model的值为false,否则无法第二次弹起modal
+ // 清除加载中的状态
+ clearLoading() {
+ .u-model {
+ &__btn--hover {
+ background-color: rgb(230, 230, 230);
+ padding-top: 48rpx;
+ padding: 48rpx;
+ &__footer {
+ &__button {
+ height: 100rpx;
+ line-height: 100rpx;
+ border-radius: 4rpx;
@@ -0,0 +1,309 @@
+ <view class="">
+ <view class="u-navbar" :style="[navbarStyle]" :class="{ 'u-navbar-fixed': isFixed, 'u-border-bottom': borderBottom }">
+ <view class="u-status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
+ <view class="u-navbar-inner" :style="[navbarInnerStyle]">
+ <view class="u-back-wrap" v-if="isBack" @tap="goBack">
+ <u-icon :name="backIconName" :color="backIconColor" :size="backIconSize"></u-icon>
+ <view class="u-icon-wrap u-back-text u-line-1" v-if="backText" :style="[backTextStyle]">{{ backText }}</view>
+ <view class="u-navbar-content-title" v-if="title" :style="[titleStyle]">
+ class="u-title u-line-1"
+ color: titleColor,
+ fontSize: titleSize + 'rpx'
+ <view class="u-slot-content">
+ <view class="u-slot-right">
+ <slot name="right"></slot>
+ <!-- 解决fixed定位后导航栏塌陷的问题 -->
+ <view class="u-navbar-placeholder" v-if="isFixed && !immersive" :style="{ width: '100%', height: Number(navbarHeight) + statusBarHeight + 'px' }"></view>
+ // 获取系统状态栏的高度
+ let systemInfo = uni.getSystemInfoSync();
+ let menuButtonInfo = {};
+ // 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容)
+ // #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ
+ menuButtonInfo = uni.getMenuButtonBoundingClientRect();
+ * navbar 自定义导航栏
+ * @description 此组件一般用于在特殊情况下,需要自定义导航栏的时候用到,一般建议使用uniapp自带的导航栏。
+ * @tutorial https://www.uviewui.com/components/navbar.html
+ * @property {String Number} height 导航栏高度(不包括状态栏高度在内,内部自动加上),注意这里的单位是px(默认44)
+ * @property {String} back-icon-color 左边返回图标的颜色(默认#606266)
+ * @property {String} back-icon-name 左边返回图标的名称,只能为uView自带的图标(默认arrow-left)
+ * @property {String Number} back-icon-size 左边返回图标的大小,单位rpx(默认30)
+ * @property {String} back-text 返回图标右边的辅助提示文字
+ * @property {Object} back-text-style 返回图标右边的辅助提示文字的样式,对象形式(默认{ color: '#606266' })
+ * @property {String} title 导航栏标题,如设置为空字符,将会隐藏标题占位区域
+ * @property {String Number} title-width 导航栏标题的最大宽度,内容超出会以省略号隐藏,单位rpx(默认250)
+ * @property {String} title-color 标题的颜色(默认#606266)
+ * @property {String Number} title-size 导航栏标题字体大小,单位rpx(默认32)
+ * @property {Function} custom-back 自定义返回逻辑方法
+ * @property {String Number} z-index 固定在顶部时的z-index值(默认980)
+ * @property {Boolean} is-back 是否显示导航栏左边返回图标和辅助文字(默认true)
+ * @property {Object} background 导航栏背景设置,见官网说明(默认{ background: '#ffffff' })
+ * @property {Boolean} is-fixed 导航栏是否固定在顶部(默认true)
+ * @property {Boolean} immersive 沉浸式,允许fixed定位后导航栏塌陷,仅fixed定位下生效(默认false)
+ * @property {Boolean} border-bottom 导航栏底部是否显示下边框,如定义了较深的背景颜色,可取消此值(默认true)
+ * @example <u-navbar back-text="返回" title="剑未配妥,出门已是江湖"></u-navbar>
+ name: "u-navbar",
+ // 导航栏高度,单位px,非rpx
+ // 返回箭头的颜色
+ backIconColor: {
+ // 左边返回的图标
+ backIconName: {
+ default: 'nav-back'
+ // 左边返回图标的大小,rpx
+ backIconSize: {
+ default: '44'
+ // 返回的文字提示
+ backText: {
+ // 返回的文字的 样式
+ backTextStyle: {
+ color: '#606266'
+ // 导航栏标题
+ // 标题的宽度,如果需要自定义右侧内容,且右侧内容很多时,可能需要减少这个宽度,单位rpx
+ default: '250'
+ // 标题的颜色
+ default: 32
+ isBack: {
+ // 对象形式,因为用户可能定义一个纯色,或者线性渐变的颜色
+ background: {
+ background: '#ffffff'
+ // 导航栏是否固定在顶部
+ isFixed: {
+ // 是否沉浸式,允许fixed定位后导航栏塌陷,仅fixed定位下生效
+ immersive: {
+ // 是否显示导航栏的下边框
+ // 自定义返回逻辑
+ customBack: {
+ type: Function,
+ default: null
+ menuButtonInfo: menuButtonInfo,
+ statusBarHeight: systemInfo.statusBarHeight
+ // 导航栏内部盒子的样式
+ navbarInnerStyle() {
+ // 导航栏宽度,如果在小程序下,导航栏宽度为胶囊的左边到屏幕左边的距离
+ style.height = this.navbarHeight + 'px';
+ // // 如果是各家小程序,导航栏内部的宽度需要减少右边胶囊的宽度
+ let rightButtonWidth = systemInfo.windowWidth - menuButtonInfo.left;
+ style.marginRight = rightButtonWidth + 'px';
+ // 整个导航栏的样式
+ navbarStyle() {
+ style.zIndex = this.zIndex ? this.zIndex : this.$u.zIndex.navbar;
+ // 合并用户传递的背景色对象
+ Object.assign(style, this.background);
+ // 导航中间的标题的样式
+ titleStyle() {
+ style.left = (systemInfo.windowWidth - uni.upx2px(this.titleWidth)) / 2 + 'px';
+ style.right = (systemInfo.windowWidth - uni.upx2px(this.titleWidth)) / 2 + 'px';
+ // 此处是为了让标题显示区域即使在小程序有右侧胶囊的情况下也能处于屏幕的中间,是通过绝对定位实现的
+ style.right = rightButtonWidth - (systemInfo.windowWidth - uni.upx2px(this.titleWidth)) / 2 + rightButtonWidth +
+ 'px';
+ style.width = uni.upx2px(this.titleWidth) + 'px';
+ // 转换字符数值为真正的数值
+ navbarHeight() {
+ // #ifdef APP-PLUS || H5
+ return this.height ? this.height : 44;
+ // 小程序特别处理,让导航栏高度 = 胶囊高度 + 两倍胶囊顶部与状态栏底部的距离之差(相当于同时获得了导航栏底部与胶囊底部的距离)
+ // 此方法有缺陷,暂不用(会导致少了几个px),采用直接固定值的方式
+ // return menuButtonInfo.height + (menuButtonInfo.top - this.statusBarHeight) * 2;//导航高度
+ let height = systemInfo.platform == 'ios' ? 44 : 48;
+ return this.height ? this.height : height;
+ created() {},
+ goBack() {
+ // 如果自定义了点击返回按钮的函数,则执行,否则执行返回逻辑
+ if (typeof this.customBack === 'function') {
+ // 在微信,支付宝等环境(H5正常),会导致父组件定义的customBack()函数体中的this变成子组件的this
+ // 通过bind()方法,绑定父组件的this,让this.customBack()的this为父组件的上下文
+ this.customBack.bind(this.$u.$parent.call(this))();
+ .u-navbar {
+ .u-navbar-fixed {
+ z-index: 991;
+ .u-status-bar {
+ .u-navbar-inner {
+ .u-back-wrap {
+ flex-grow: 0;
+ padding: 14rpx 14rpx 14rpx 24rpx;
+ .u-back-text {
+ padding-left: 4rpx;
+ .u-navbar-content-title {
+ height: 60rpx;
+ flex-shrink: 0;
+ .u-navbar-centent-slot {
+ .u-title {
+ line-height: 60rpx;
+ .u-navbar-right {
+ justify-content: flex-end;
+ .u-slot-content {
@@ -0,0 +1,272 @@
+ <view class="u-notice-bar-wrap" v-if="isShow" :style="{
+ <block v-if="mode == 'horizontal' && isCircular">
+ <u-row-notice
+ :color="color"
+ :bgColor="bgColor"
+ :list="list"
+ :volumeIcon="volumeIcon"
+ :moreIcon="moreIcon"
+ :volumeSize="volumeSize"
+ :closeIcon="closeIcon"
+ :fontSize="fontSize"
+ :speed="speed"
+ :playState="playState"
+ :padding="padding"
+ @getMore="getMore"
+ @close="close"
+ @click="click"
+ ></u-row-notice>
+ <block v-if="mode == 'vertical' || (mode == 'horizontal' && !isCircular)">
+ <u-column-notice
+ :disable-touch="disableTouch"
+ :duration="duration"
+ @end="end"
+ ></u-column-notice>
+ * noticeBar 滚动通知
+ * @description 该组件用于滚动通告场景,有多种模式可供选择
+ * @tutorial https://www.uviewui.com/components/noticeBar.html
+ * @property {Array} list 滚动内容,数组形式,见上方说明
+ * @property {String} type 显示的主题(默认warning)
+ * @property {Boolean} volume-icon 是否显示小喇叭图标(默认true)
+ * @property {Boolean} more-icon 是否显示右边的向右箭头(默认false)
+ * @property {Boolean} close-icon 是否显示关闭图标(默认false)
+ * @property {Boolean} autoplay 是否自动播放(默认true)
+ * @property {String} color 文字颜色
+ * @property {String Number} bg-color 背景颜色
+ * @property {String} mode 滚动模式(默认horizontal)
+ * @property {Boolean} show 是否显示(默认true)
+ * @property {String Number} volume-size 左边喇叭的大小(默认34)
+ * @property {String Number} duration 滚动周期时长,只对步进模式有效,横向衔接模式无效,单位ms(默认2000)
+ * @property {String Number} speed 水平滚动时的滚动速度,即每秒移动多少距离,只对水平衔接方式有效,单位rpx(默认160)
+ * @property {Boolean} is-circular mode为horizontal时,指明是否水平衔接滚动(默认true)
+ * @property {String} play-state 播放状态,play - 播放,paused - 暂停(默认play)
+ * @property {String Nubmer} border-radius 通知栏圆角(默认为0)
+ * @property {String Nubmer} padding 内边距,字符串,与普通的内边距css写法一直(默认"18rpx 24rpx")
+ * @property {Boolean} no-list-hidden 列表为空时,是否显示组件(默认false)
+ * @property {Boolean} disable-touch 是否禁止通过手动滑动切换通知,只有mode = vertical,或者mode = horizontal且is-circular = false时有效(默认true)
+ * @event {Function} click 点击通告文字触发,只有mode = vertical,或者mode = horizontal且is-circular = false时有效
+ * @event {Function} close 点击右侧关闭图标触发
+ * @event {Function} getMore 点击右侧向右图标触发
+ * @event {Function} end 列表的消息每次被播放一个周期时触发,只有mode = vertical,或者mode = horizontal且is-circular = false时有效
+ * @example <u-notice-bar :more-icon="true" :list="list"></u-notice-bar>
+ name: "u-notice-bar",
+ // 水平衔接模式,采用的是swiper组件,水平滚动
+ // 滚动通知设置圆角
+ // list列表为空时,是否显示组件
+ noListHidden: {
+ // 如果设置show为false,或者设置了noListHidden为true,且list长度又为零的话,隐藏组件
+ isShow() {
+ if(this.show == false || (this.noListHidden == true && this.list.length == 0)) return false;
+ // 滚动一个周期结束,只对垂直,或者水平步进形式有效
+.u-notice-bar-wrap {
+.u-direction-row {
+.u-notice-box {
+.u-notice-content {
+ animation: u-loop-animation 10s linear infinite both;
+ // 这一句很重要,为了能让滚动左右连接起来
+ padding-left: 100%;
+@keyframes u-loop-animation {
+ transform: translate3d(0, 0, 0);
+ transform: translate3d(-100%, 0, 0);
@@ -0,0 +1,348 @@
+ <view class="u-numberbox">
+ <view class="u-icon-minus" @touchstart.stop.prevent="btnTouchStart('minus')" @touchend.stop.prevent="clearTimer" :class="{ 'u-icon-disabled': disabled || inputVal <= min }"
+ height: inputHeight + 'rpx',
+ <u-icon name="minus" :size="size"></u-icon>
+ <input :disabled="disabledInput || disabled" :cursor-spacing="getCursorSpacing" :class="{ 'u-input-disabled': disabled }"
+ v-model="inputVal" class="u-number-input" @blur="onBlur"
+ type="number" :style="{
+ fontSize: size + 'rpx',
+ width: inputWidth + 'rpx'
+ }" />
+ <view class="u-icon-plus" @touchstart.stop.prevent="btnTouchStart('plus')" @touchend.stop.prevent="clearTimer" :class="{ 'u-icon-disabled': disabled || inputVal >= max }"
+ <u-icon name="plus" :size="size"></u-icon>
+ * numberBox 步进器
+ * @description 该组件一般用于商城购物选择物品数量的场景。注意:该输入框只能输入大于或等于0的整数,不支持小数输入
+ * @tutorial https://www.uviewui.com/components/numberBox.html
+ * @property {Number} value 输入框初始值(默认1)
+ * @property {String} bg-color 输入框和按钮的背景颜色(默认#F2F3F5)
+ * @property {Number} min 用户可输入的最小值(默认0)
+ * @property {Number} max 用户可输入的最大值(默认99999)
+ * @property {Number} step 步长,每次加或减的值(默认1)
+ * @property {Boolean} disabled 是否禁用操作,禁用后无法加减或手动修改输入框的值(默认false)
+ * @property {Boolean} disabled-input 是否禁止输入框手动输入值(默认false)
+ * @property {Boolean} positive-integer 是否只能输入正整数(默认true)
+ * @property {String | Number} size 输入框文字和按钮字体大小,单位rpx(默认26)
+ * @property {String} color 输入框文字和加减按钮图标的颜色(默认#323233)
+ * @property {String | Number} input-width 输入框宽度,单位rpx(默认80)
+ * @property {String | Number} input-height 输入框和按钮的高度,单位rpx(默认50)
+ * @property {String | Number} index 事件回调时用以区分当前发生变化的是哪个输入框
+ * @property {Boolean} long-press 是否开启长按连续递增或递减(默认true)
+ * @property {String | Number} press-time 开启长按触发后,每触发一次需要多久,单位ms(默认250)
+ * @property {String | Number} cursor-spacing 指定光标于键盘的距离,避免键盘遮挡输入框,单位rpx(默认200)
+ * @event {Function} change 输入框内容发生变化时触发,对象形式
+ * @event {Function} blur 输入框失去焦点时触发,对象形式
+ * @event {Function} minus 点击减少按钮时触发(按钮可点击情况下),对象形式
+ * @event {Function} plus 点击增加按钮时触发(按钮可点击情况下),对象形式
+ * @example <u-number-box :min="1" :max="100"></u-number-box>
+ name: "u-number-box",
+ // 预显示的数字
+ default: 1
+ default: '#F2F3F5'
+ // 最小值
+ min: {
+ // 最大值
+ default: 99999
+ // 步进值,每次加或减的值
+ step: {
+ // 是否禁用加减操作
+ // input的字体大小,单位rpx
+ // 加减图标的颜色
+ default: '#323233'
+ // input宽度,单位rpx
+ inputWidth: {
+ // input高度,单位rpx
+ inputHeight: {
+ // index索引,用于列表中使用,让用户知道是哪个numberbox发生了变化,一般使用for循环出来的index值即可
+ // 是否禁用输入框,与disabled作用于输入框时,为OR的关系,即想要禁用输入框,又可以加减的话
+ // 设置disabled为false,disabledInput为true即可
+ disabledInput: {
+ // 输入框于键盘之间的距离
+ // 是否开启长按连续递增或递减
+ longPress: {
+ // 开启长按触发后,每触发一次需要多久
+ pressTime: {
+ default: 250
+ // 是否只能输入大于或等于0的整数(正整数)
+ positiveInteger: {
+ value(v1, v2) {
+ // 只有value的改变是来自外部的时候,才去同步inputVal的值,否则会造成循环错误
+ if(!this.changeFromInner) {
+ this.inputVal = v1;
+ // 因为inputVal变化后,会触发this.handleChange(),在其中changeFromInner会再次被设置为true,
+ // 造成外面修改值,也导致被认为是内部修改的混乱,这里进行this.$nextTick延时,保证在运行周期的最后处
+ // 将changeFromInner设置为false
+ this.$nextTick(function(){
+ this.changeFromInner = false;
+ inputVal(v1, v2) {
+ // 为了让用户能够删除所有输入值,重新输入内容,删除所有值后,内容为空字符串
+ if (v1 == '') return;
+ let value = 0;
+ // 首先判断是否数值,并且在min和max之间,如果不是,使用原来值
+ let tmp = this.$u.test.number(v1);
+ if (tmp && v1 >= this.min && v1 <= this.max) value = v1;
+ else value = v2;
+ // 判断是否只能输入大于等于0的整数
+ if(this.positiveInteger) {
+ // 小于0,或者带有小数点,
+ if(v1 < 0 || String(v1).indexOf('.') !== -1) {
+ value = v2;
+ // 双向绑定input的值,必须要使用$nextTick修改显示的值
+ this.inputVal = v2;
+ this.handleChange(value, 'change');
+ inputVal: 1, // 输入框中的值,不能直接使用props中的value,因为应该改变props的状态
+ timer: null, // 用作长按的定时器
+ changeFromInner: false, // 值发生变化,是来自内部还是外部
+ this.inputVal = Number(this.value);
+ // 先将值转为px单位,再转为数值
+ return Number(uni.upx2px(this.cursorSpacing));
+ btnTouchStart(callback) {
+ // 先执行一遍方法,否则会造成松开手时,就执行了clearTimer,导致无法实现功能
+ this[callback]();
+ // 如果没开启长按功能,直接返回
+ if (!this.longPress) return;
+ // 执行加或减函数
+ }, this.pressTime);
+ minus() {
+ this.computeVal('minus');
+ plus() {
+ this.computeVal('plus');
+ // 为了保证小数相加减出现精度溢出的问题
+ calcPlus(num1, num2) {
+ let baseNum, baseNum1, baseNum2;
+ try {
+ baseNum1 = num1.toString().split('.')[1].length;
+ } catch (e) {
+ baseNum1 = 0;
+ baseNum2 = num2.toString().split('.')[1].length;
+ baseNum2 = 0;
+ baseNum = Math.pow(10, Math.max(baseNum1, baseNum2));
+ let precision = baseNum1 >= baseNum2 ? baseNum1 : baseNum2; //精度
+ return ((num1 * baseNum + num2 * baseNum) / baseNum).toFixed(precision);
+ calcMinus(num1, num2) {
+ let precision = baseNum1 >= baseNum2 ? baseNum1 : baseNum2;
+ return ((num1 * baseNum - num2 * baseNum) / baseNum).toFixed(precision);
+ computeVal(type) {
+ uni.hideKeyboard();
+ // 减
+ if (type === 'minus') {
+ value = this.calcMinus(this.inputVal, this.step);
+ } else if (type === 'plus') {
+ value = this.calcPlus(this.inputVal, this.step);
+ // 判断是否小于最小值和大于最大值
+ if (value < this.min || value > this.max) {
+ this.inputVal = value;
+ this.handleChange(value, type);
+ // 处理用户手动输入的情况
+ let val = 0;
+ // 如果为非0-9数字组成,或者其第一位数值为0,直接让其等于min值
+ // 这里不直接判断是否正整数,是因为用户传递的props min值可能为0
+ if (!/(^\d+$)/.test(value) || value[0] == 0) val = this.min;
+ val = +value;
+ if (val > this.max) {
+ val = this.max;
+ } else if (val < this.min) {
+ val = this.min;
+ this.inputVal = val;
+ this.handleChange(val, 'blur');
+ handleChange(value, type) {
+ // 发出input事件,修改通过v-model绑定的值,达到双向绑定的效果
+ this.changeFromInner = true;
+ this.$emit('input', Number(value));
+ this.$emit(type, {
+ // 转为Number类型
+ value: Number(value),
+ index: this.index
+ .u-numberbox {
+ .u-number-input {
+ margin: 0 6rpx;
+ .u-icon-plus,
+ .u-icon-minus {
+ width: 60rpx;
+ .u-icon-plus {
+ border-radius: 0 8rpx 8rpx 0;
+ border-radius: 8rpx 0 0 8rpx;
+ .u-icon-disabled {
+ background: #f7f8fa !important;
+ .u-input-disabled {
+ background-color: #f2f3f5 !important;
@@ -0,0 +1,158 @@
+ class="u-keyboard-grids-item"
+ :class="[btnBgGray(index) ? 'u-bg-gray' : '', index <= 2 ? 'u-border-top' : '', index < 9 ? 'u-border-bottom' : '', (index + 1) % 3 != 0 ? 'u-border-right' : '']"
+ v-for="(item, index) in numList"
+ :key="index"
+ :hover-class="hoverClass(index)"
+ :hover-stay-time="100"
+ @tap="keyboardClick(item)">
+ <view class="u-keyboard-grids-btn">{{ item }}</view>
+ <view class="u-keyboard-grids-item u-bg-gray" hover-class="u-hover-class" :hover-stay-time="100" @touchstart.stop="backspaceClick"
+ @touchend="clearTimer">
+ <view class="u-keyboard-back u-keyboard-grids-btn">
+ <u-icon name="backspace" :size="38" :bold="true"></u-icon>
+ // 键盘的类型,number-数字键盘,card-身份证键盘
+ backspace: 'backspace', // 退格键内容
+ dot: '.', // 点
+ timer: null, // 长按多次删除的事件监听
+ cardX: 'X' // 身份证的X符号
+ // 键盘需要显示的内容
+ numList() {
+ if (!this.dotEnabled && this.mode == 'number') {
+ if (!this.random) {
+ return [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
+ return this.$u.randomArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 0]);
+ } else if (this.dotEnabled && this.mode == 'number') {
+ return [1, 2, 3, 4, 5, 6, 7, 8, 9, this.dot, 0];
+ return this.$u.randomArray([1, 2, 3, 4, 5, 6, 7, 8, 9, this.dot, 0]);
+ } else if (this.mode == 'card') {
+ return [1, 2, 3, 4, 5, 6, 7, 8, 9, this.cardX, 0];
+ return this.$u.randomArray([1, 2, 3, 4, 5, 6, 7, 8, 9, this.cardX, 0]);
+ // 按键的样式,在非乱序&&数字键盘&&不显示点按钮时,index为9时,按键占位两个空间
+ return index => {
+ if (this.mode == 'number' && !this.dotEnabled && index == 9) style.flex = '0 0 66.6666666666%';
+ // 是否让按键显示灰色,只在非乱序&&数字键盘&&且允许点按键的时候
+ btnBgGray() {
+ if (!this.random && index == 9 && (this.mode != 'number' || (this.mode == 'number' && this.dotEnabled))) return true;
+ hoverClass() {
+ if (!this.random && index == 9 && (this.mode == 'number' && this.dotEnabled || this.mode == 'card')) return 'u-hover-class';
+ else return 'u-keyboard-hover';
+ // 获取键盘显示的内容
+ keyboardClick(val) {
+ // 允许键盘显示点模式和触发非点按键时,将内容转为数字类型
+ if (this.dotEnabled && val != this.dot && val != this.cardX) val = Number(val);
+ this.$emit('change', val);
+ flex: 0 0 33.3333333333%;
+ color: #333;
+ height: 110rpx;
+ .u-bg-gray {
+ background-color: $u-border-color;
+ .u-keyboard-hover {
+ background-color: #e7e6eb;
@@ -0,0 +1,97 @@
+const cfg = require('./config.js'),
+ isLetter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+function CssHandler(tagStyle) {
+ var styles = Object.assign(Object.create(null), cfg.userAgentStyles);
+ for (var item in tagStyle)
+ styles[item] = (styles[item] ? styles[item] + ';' : '') + tagStyle[item];
+ this.styles = styles;
+CssHandler.prototype.getStyle = function(data) {
+ this.styles = new parser(data, this.styles).parse();
+CssHandler.prototype.match = function(name, attrs) {
+ var tmp, matched = (tmp = this.styles[name]) ? tmp + ';' : '';
+ if (attrs.class) {
+ var items = attrs.class.split(' ');
+ for (var i = 0, item; item = items[i]; i++)
+ if (tmp = this.styles['.' + item])
+ matched += tmp + ';';
+ if (tmp = this.styles['#' + attrs.id])
+ return matched;
+module.exports = CssHandler;
+function parser(data, init) {
+ this.data = data;
+ this.floor = 0;
+ this.i = 0;
+ this.list = [];
+ this.res = init;
+ this.state = this.Space;
+parser.prototype.parse = function() {
+ for (var c; c = this.data[this.i]; this.i++)
+ this.state(c);
+ return this.res;
+parser.prototype.section = function() {
+ return this.data.substring(this.start, this.i);
+// 状态机
+parser.prototype.Space = function(c) {
+ if (c == '.' || c == '#' || isLetter(c)) {
+ this.start = this.i;
+ this.state = this.Name;
+ } else if (c == '/' && this.data[this.i + 1] == '*')
+ this.Comment();
+ else if (!cfg.blankChar[c] && c != ';')
+ this.state = this.Ignore;
+parser.prototype.Comment = function() {
+ this.i = this.data.indexOf('*/', this.i) + 1;
+ if (!this.i) this.i = this.data.length;
+parser.prototype.Ignore = function(c) {
+ if (c == '{') this.floor++;
+ else if (c == '}' && !--this.floor) this.state = this.Space;
+parser.prototype.Name = function(c) {
+ if (cfg.blankChar[c]) {
+ this.list.push(this.section());
+ this.state = this.NameSpace;
+ } else if (c == '{') {
+ this.Content();
+ } else if (c == ',') {
+ this.Comma();
+ } else if (!isLetter(c) && (c < '0' || c > '9') && c != '-' && c != '_')
+parser.prototype.NameSpace = function(c) {
+ if (c == '{') this.Content();
+ else if (c == ',') this.Comma();
+ else if (!cfg.blankChar[c]) this.state = this.Ignore;
+parser.prototype.Comma = function() {
+ while (cfg.blankChar[this.data[++this.i]]);
+ if (this.data[this.i] == '{') this.Content();
+ this.start = this.i--;
+parser.prototype.Content = function() {
+ this.start = ++this.i;
+ if ((this.i = this.data.indexOf('}', this.i)) == -1) this.i = this.data.length;
+ var content = this.section();
+ for (var i = 0, item; item = this.list[i++];)
+ if (this.res[item]) this.res[item] += ';' + content;
+ else this.res[item] = content;
@@ -0,0 +1,535 @@
+ * html 解析器
+ * @tutorial https://github.com/jin-yufeng/Parser
+ * @version 20200728
+ * @author JinYufeng
+ * @listens MIT
+ blankChar = cfg.blankChar,
+ CssHandler = require('./CssHandler.js'),
+ windowWidth = uni.getSystemInfoSync().windowWidth;
+var emoji;
+function MpHtmlParser(data, options = {}) {
+ this.attrs = {};
+ this.CssHandler = new CssHandler(options.tagStyle, windowWidth);
+ this.domain = options.domain;
+ this.DOM = [];
+ this.i = this.start = this.audioNum = this.imgNum = this.videoNum = 0;
+ options.prot = (this.domain || '').includes('://') ? this.domain.split('://')[0] : 'http';
+ this.options = options;
+ this.state = this.Text;
+ this.STACK = [];
+ // 工具函数
+ this.bubble = () => {
+ for (var i = this.STACK.length, item; item = this.STACK[--i];) {
+ if (cfg.richOnlyTags[item.name]) {
+ if (item.name == 'table' && !Object.hasOwnProperty.call(item, 'c')) item.c = 1;
+ item.c = 1;
+ return true;
+ this.decode = (val, amp) => {
+ var i = -1,
+ j, en;
+ while (1) {
+ if ((i = val.indexOf('&', i + 1)) == -1) break;
+ if ((j = val.indexOf(';', i + 2)) == -1) break;
+ if (val[i + 1] == '#') {
+ en = parseInt((val[i + 2] == 'x' ? '0' : '') + val.substring(i + 2, j));
+ if (!isNaN(en)) val = val.substr(0, i) + String.fromCharCode(en) + val.substr(j + 1);
+ en = val.substring(i + 1, j);
+ if (cfg.entities[en] || en == amp)
+ val = val.substr(0, i) + (cfg.entities[en] || '&') + val.substr(j + 1);
+ return val;
+ this.getUrl = url => {
+ if (url[0] == '/') {
+ if (url[1] == '/') url = this.options.prot + ':' + url;
+ else if (this.domain) url = this.domain + url;
+ } else if (this.domain && url.indexOf('data:') != 0 && !url.includes('://'))
+ url = this.domain + '/' + url;
+ return url;
+ this.isClose = () => this.data[this.i] == '>' || (this.data[this.i] == '/' && this.data[this.i + 1] == '>');
+ this.section = () => this.data.substring(this.start, this.i);
+ this.parent = () => this.STACK[this.STACK.length - 1];
+ this.siblings = () => this.STACK.length ? this.parent().children : this.DOM;
+MpHtmlParser.prototype.parse = function() {
+ if (emoji) this.data = emoji.parseEmoji(this.data);
+ if (this.state == this.Text) this.setText();
+ while (this.STACK.length) this.popNode(this.STACK.pop());
+ return this.DOM;
+// 设置属性
+MpHtmlParser.prototype.setAttr = function() {
+ var name = this.attrName.toLowerCase(),
+ val = this.attrVal;
+ if (cfg.boolAttrs[name]) this.attrs[name] = 'T';
+ else if (val) {
+ if (name == 'src' || (name == 'data-src' && !this.attrs.src)) this.attrs.src = this.getUrl(this.decode(val, 'amp'));
+ else if (name == 'href' || name == 'style') this.attrs[name] = this.decode(val, 'amp');
+ else if (name.substr(0, 5) != 'data-') this.attrs[name] = val;
+ this.attrVal = '';
+ while (blankChar[this.data[this.i]]) this.i++;
+ if (this.isClose()) this.setNode();
+ this.state = this.AttrName;
+// 设置文本节点
+MpHtmlParser.prototype.setText = function() {
+ var back, text = this.section();
+ if (!text) return;
+ text = (cfg.onText && cfg.onText(text, () => back = true)) || text;
+ if (back) {
+ this.data = this.data.substr(0, this.start) + text + this.data.substr(this.i);
+ let j = this.start + text.length;
+ for (this.i = this.start; this.i < j; this.i++) this.state(this.data[this.i]);
+ if (!this.pre) {
+ // 合并空白符
+ var flag, tmp = [];
+ for (let i = text.length, c; c = text[--i];)
+ if (!blankChar[c]) {
+ tmp.unshift(c);
+ if (!flag) flag = 1;
+ if (tmp[0] != ' ') tmp.unshift(' ');
+ if (c == '\n' && flag == void 0) flag = 0;
+ if (flag == 0) return;
+ text = tmp.join('');
+ this.siblings().push({
+ type: 'text',
+ text: this.decode(text)
+// 设置元素节点
+MpHtmlParser.prototype.setNode = function() {
+ var node = {
+ name: this.tagName.toLowerCase(),
+ attrs: this.attrs
+ close = cfg.selfClosingTags[node.name];
+ if (this.options.nodes.length) node.type = 'node';
+ if (!cfg.ignoreTags[node.name]) {
+ // 处理属性
+ var attrs = node.attrs,
+ style = this.CssHandler.match(node.name, attrs, node) + (attrs.style || ''),
+ styleObj = {};
+ if (attrs.id) {
+ if (this.options.compress & 1) attrs.id = void 0;
+ else if (this.options.useAnchor) this.bubble();
+ if ((this.options.compress & 2) && attrs.class) attrs.class = void 0;
+ switch (node.name) {
+ case 'a':
+ case 'ad': // #ifdef APP-PLUS
+ case 'iframe':
+ this.bubble();
+ case 'font':
+ if (attrs.color) {
+ styleObj['color'] = attrs.color;
+ attrs.color = void 0;
+ if (attrs.face) {
+ styleObj['font-family'] = attrs.face;
+ attrs.face = void 0;
+ if (attrs.size) {
+ var size = parseInt(attrs.size);
+ if (size < 1) size = 1;
+ else if (size > 7) size = 7;
+ var map = ['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'];
+ styleObj['font-size'] = map[size - 1];
+ attrs.size = void 0;
+ case 'embed':
+ // #ifndef APP-PLUS
+ var src = node.attrs.src || '',
+ type = node.attrs.type || '';
+ if (type.includes('video') || src.includes('.mp4') || src.includes('.3gp') || src.includes('.m3u8'))
+ node.name = 'video';
+ else if (type.includes('audio') || src.includes('.m4a') || src.includes('.wav') || src.includes('.mp3') || src.includes(
+ '.aac'))
+ node.name = 'audio';
+ else break;
+ if (node.attrs.autostart)
+ node.attrs.autoplay = 'T';
+ node.attrs.controls = 'T';
+ case 'video':
+ case 'audio':
+ if (!attrs.id) attrs.id = node.name + (++this[`${node.name}Num`]);
+ else this[`${node.name}Num`]++;
+ if (node.name == 'video') {
+ if (this.videoNum > 3)
+ node.lazyLoad = 1;
+ if (attrs.width) {
+ styleObj.width = parseFloat(attrs.width) + (attrs.width.includes('%') ? '%' : 'px');
+ attrs.width = void 0;
+ if (attrs.height) {
+ styleObj.height = parseFloat(attrs.height) + (attrs.height.includes('%') ? '%' : 'px');
+ attrs.height = void 0;
+ if (!attrs.controls && !attrs.autoplay) attrs.controls = 'T';
+ attrs.source = [];
+ if (attrs.src) {
+ attrs.source.push(attrs.src);
+ attrs.src = void 0;
+ case 'td':
+ case 'th':
+ if (attrs.colspan || attrs.rowspan)
+ for (var k = this.STACK.length, item; item = this.STACK[--k];)
+ if (item.name == 'table') {
+ item.c = void 0;
+ if (attrs.align) {
+ styleObj['text-align'] = attrs.align;
+ attrs.align = void 0;
+ // 压缩 style
+ var styles = style.split(';');
+ style = '';
+ for (var i = 0, len = styles.length; i < len; i++) {
+ var info = styles[i].split(':');
+ if (info.length < 2) continue;
+ let key = info[0].trim().toLowerCase(),
+ value = info.slice(1).join(':').trim();
+ if (value[0] == '-' || value.includes('safe'))
+ style += `;${key}:${value}`;
+ else if (!styleObj[key] || value.includes('import') || !styleObj[key].includes('import'))
+ styleObj[key] = value;
+ if (node.name == 'img') {
+ if (attrs.src && !attrs.ignore) {
+ if (this.bubble())
+ attrs.i = (this.imgNum++).toString();
+ else attrs.ignore = 'T';
+ if (attrs.ignore) {
+ style += ';-webkit-touch-callout:none';
+ styleObj['max-width'] = '100%';
+ var width;
+ if (styleObj.width) width = styleObj.width;
+ else if (attrs.width) width = attrs.width.includes('%') ? attrs.width : attrs.width + 'px';
+ if (width) {
+ styleObj.width = width;
+ attrs.width = '100%';
+ if (parseInt(width) > windowWidth) {
+ styleObj.height = '';
+ if (attrs.height) attrs.height = void 0;
+ if (styleObj.height) {
+ attrs.height = styleObj.height;
+ } else if (attrs.height && !attrs.height.includes('%'))
+ attrs.height += 'px';
+ for (var key in styleObj) {
+ var value = styleObj[key];
+ if (!value) continue;
+ if (key.includes('flex') || key == 'order' || key == 'self-align') node.c = 1;
+ // 填充链接
+ if (value.includes('url')) {
+ var j = value.indexOf('(');
+ if (j++ != -1) {
+ while (value[j] == '"' || value[j] == "'" || blankChar[value[j]]) j++;
+ value = value.substr(0, j) + this.getUrl(value.substr(j));
+ // 转换 rpx
+ else if (value.includes('rpx'))
+ value = value.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * windowWidth / 750 + 'px');
+ else if (key == 'white-space' && value.includes('pre') && !close)
+ this.pre = node.pre = true;
+ style = style.substr(1);
+ if (style) attrs.style = style;
+ if (!close) {
+ node.children = [];
+ if (node.name == 'pre' && cfg.highlight) {
+ this.remove(node);
+ this.siblings().push(node);
+ this.STACK.push(node);
+ } else if (!cfg.filter || cfg.filter(node, this) != false)
+ if (!close) this.remove(node);
+ else if (node.name == 'source') {
+ var parent = this.parent();
+ if (parent && (parent.name == 'video' || parent.name == 'audio') && node.attrs.src)
+ parent.attrs.source.push(node.attrs.src);
+ } else if (node.name == 'base' && !this.domain) this.domain = node.attrs.href;
+ if (this.data[this.i] == '/') this.i++;
+ this.start = this.i + 1;
+// 移除标签
+MpHtmlParser.prototype.remove = function(node) {
+ var name = node.name,
+ j = this.i;
+ // 处理 svg
+ var handleSvg = () => {
+ var src = this.data.substring(j, this.i + 1);
+ if (!node.attrs.xmlns) src = ' xmlns="http://www.w3.org/2000/svg"' + src;
+ var i = j;
+ while (this.data[j] != '<') j--;
+ src = this.data.substring(j, i).replace("viewbox", "viewBox") + src;
+ if (node.attrs.width == '100%' && parent && (parent.attrs.style || '').includes('inline'))
+ parent.attrs.style = 'width:300px;max-width:100%;' + parent.attrs.style;
+ name: 'img',
+ attrs: {
+ src: 'data:image/svg+xml;utf8,' + src.replace(/#/g, '%23'),
+ style: (/vertical[^;]+/.exec(node.attrs.style) || []).shift(),
+ ignore: 'T'
+ if (node.name == 'svg' && this.data[j] == '/') return handleSvg(this.i++);
+ if ((this.i = this.data.indexOf('</', this.i + 1)) == -1) {
+ if (name == 'pre' || name == 'svg') this.i = j;
+ else this.i = this.data.length;
+ this.start = (this.i += 2);
+ while (!blankChar[this.data[this.i]] && !this.isClose()) this.i++;
+ if (this.section().toLowerCase() == name) {
+ // 代码块高亮
+ if (name == 'pre') {
+ this.data = this.data.substr(0, j + 1) + cfg.highlight(this.data.substring(j + 1, this.i - 5), node.attrs) + this.data
+ .substr(this.i - 5);
+ return this.i = j;
+ } else if (name == 'style')
+ this.CssHandler.getStyle(this.data.substring(j + 1, this.i - 7));
+ else if (name == 'title')
+ this.DOM.title = this.data.substring(j + 1, this.i - 7);
+ if ((this.i = this.data.indexOf('>', this.i)) == -1) this.i = this.data.length;
+ if (name == 'svg') handleSvg();
+// 节点出栈处理
+MpHtmlParser.prototype.popNode = function(node) {
+ // 空白符处理
+ if (node.pre) {
+ node.pre = this.pre = void 0;
+ for (let i = this.STACK.length; i--;)
+ if (this.STACK[i].pre)
+ this.pre = true;
+ var siblings = this.siblings(),
+ len = siblings.length,
+ childs = node.children;
+ if (node.name == 'head' || (cfg.filter && cfg.filter(node, this) == false))
+ return siblings.pop();
+ var attrs = node.attrs;
+ // 替换一些标签名
+ if (cfg.blockTags[node.name]) node.name = 'div';
+ else if (!cfg.trustTags[node.name]) node.name = 'span';
+ // 处理列表
+ if (node.c && (node.name == 'ul' || node.name == 'ol')) {
+ if ((node.attrs.style || '').includes('list-style:none')) {
+ for (let i = 0, child; child = childs[i++];)
+ if (child.name == 'li')
+ child.name = 'div';
+ } else if (node.name == 'ul') {
+ var floor = 1;
+ if (this.STACK[i].name == 'ul') floor++;
+ if (floor != 1)
+ for (let i = childs.length; i--;)
+ childs[i].floor = floor;
+ for (let i = 0, num = 1, child; child = childs[i++];)
+ if (child.name == 'li') {
+ child.type = 'ol';
+ child.num = ((num, type) => {
+ if (type == 'a') return String.fromCharCode(97 + (num - 1) % 26);
+ if (type == 'A') return String.fromCharCode(65 + (num - 1) % 26);
+ if (type == 'i' || type == 'I') {
+ num = (num - 1) % 99 + 1;
+ var one = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
+ ten = ['X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
+ res = (ten[Math.floor(num / 10) - 1] || '') + (one[num % 10 - 1] || '');
+ if (type == 'i') return res.toLowerCase();
+ return res;
+ return num;
+ })(num++, attrs.type) + '.';
+ // 处理表格的边框
+ if (node.name == 'table') {
+ var padding = attrs.cellpadding,
+ spacing = attrs.cellspacing,
+ border = attrs.border;
+ if (node.c) {
+ attrs.style = (attrs.style || '') + ';display:table';
+ if (!padding) padding = 2;
+ if (!spacing) spacing = 2;
+ if (border) attrs.style = `border:${border}px solid gray;${attrs.style || ''}`;
+ if (spacing) attrs.style = `border-spacing:${spacing}px;${attrs.style || ''}`;
+ if (border || padding || node.c)
+ (function f(ns) {
+ for (var i = 0, n; n = ns[i]; i++) {
+ if (n.type == 'text') continue;
+ var style = n.attrs.style || '';
+ if (node.c && n.name[0] == 't') {
+ n.c = 1;
+ style += ';display:table-' + (n.name == 'th' || n.name == 'td' ? 'cell' : (n.name == 'tr' ? 'row' : 'row-group'));
+ if (n.name == 'th' || n.name == 'td') {
+ if (border) style = `border:${border}px solid gray;${style}`;
+ if (padding) style = `padding:${padding}px;${style}`;
+ } else f(n.children || []);
+ if (style) n.attrs.style = style;
+ })(childs)
+ if (this.options.autoscroll) {
+ var table = Object.assign({}, node);
+ node.name = 'div';
+ node.attrs = {
+ style: 'overflow:scroll'
+ node.children = [table];
+ this.CssHandler.pop && this.CssHandler.pop(node);
+ // 自动压缩
+ if (node.name == 'div' && !Object.keys(attrs).length && childs.length == 1 && childs[0].name == 'div')
+ siblings[len - 1] = childs[0];
+MpHtmlParser.prototype.Text = function(c) {
+ if (c == '<') {
+ var next = this.data[this.i + 1],
+ if (isLetter(next)) {
+ this.setText();
+ this.state = this.TagName;
+ } else if (next == '/') {
+ if (isLetter(this.data[++this.i + 1])) {
+ this.state = this.EndTag;
+ } else this.Comment();
+ } else if (next == '!' || next == '?') {
+MpHtmlParser.prototype.Comment = function() {
+ var key;
+ if (this.data.substring(this.i + 2, this.i + 4) == '--') key = '-->';
+ else if (this.data.substring(this.i + 2, this.i + 9) == '[CDATA[') key = ']]>';
+ else key = '>';
+ if ((this.i = this.data.indexOf(key, this.i + 2)) == -1) this.i = this.data.length;
+ else this.i += key.length - 1;
+MpHtmlParser.prototype.TagName = function(c) {
+ if (blankChar[c]) {
+ this.tagName = this.section();
+ } else if (this.isClose()) {
+ this.setNode();
+MpHtmlParser.prototype.AttrName = function(c) {
+ if (c == '=' || blankChar[c] || this.isClose()) {
+ this.attrName = this.section();
+ if (blankChar[c])
+ while (blankChar[this.data[++this.i]]);
+ if (this.data[this.i] == '=') {
+ this.state = this.AttrValue;
+ } else this.setAttr();
+MpHtmlParser.prototype.AttrValue = function(c) {
+ if (c == '"' || c == "'") {
+ this.start++;
+ if ((this.i = this.data.indexOf(c, this.i + 1)) == -1) return this.i = this.data.length;
+ this.attrVal = this.section();
+ this.i++;
+ for (; !blankChar[this.data[this.i]] && !this.isClose(); this.i++);
+ this.setAttr();
+MpHtmlParser.prototype.EndTag = function(c) {
+ if (blankChar[c] || c == '>' || c == '/') {
+ var name = this.section().toLowerCase();
+ for (var i = this.STACK.length; i--;)
+ if (this.STACK[i].name == name) break;
+ if (i != -1) {
+ var node;
+ while ((node = this.STACK.pop()).name != name) this.popNode(node);
+ this.popNode(node);
+ } else if (name == 'p' || name == 'br')
+ name,
+ attrs: {}
+ this.i = this.data.indexOf('>', this.i);
+ if (this.i == -1) this.i = this.data.length;
+ else this.state = this.Text;
+module.exports = MpHtmlParser;
@@ -0,0 +1,80 @@
+/* 配置文件 */
+var cfg = {
+ // 出错占位图
+ errorImg: null,
+ // 过滤器函数
+ filter: null,
+ // 代码高亮函数
+ highlight: null,
+ // 文本处理函数
+ onText: null,
+ // 实体编码列表
+ entities: {
+ quot: '"',
+ apos: "'",
+ semi: ';',
+ nbsp: '\xA0',
+ ensp: '\u2002',
+ emsp: '\u2003',
+ ndash: '–',
+ mdash: '—',
+ middot: '·',
+ lsquo: '‘',
+ rsquo: '’',
+ ldquo: '“',
+ rdquo: '”',
+ bull: '•',
+ hellip: '…'
+ blankChar: makeMap(' ,\xA0,\t,\r,\n,\f'),
+ boolAttrs: makeMap('allowfullscreen,autoplay,autostart,controls,ignore,loop,muted'),
+ // 块级标签,将被转为 div
+ blockTags: makeMap('address,article,aside,body,caption,center,cite,footer,header,html,nav,pre,section'),
+ // 将被移除的标签
+ ignoreTags: makeMap('area,base,canvas,frame,iframe,input,link,map,meta,param,script,source,style,svg,textarea,title,track,wbr'),
+ // 只能被 rich-text 显示的标签
+ richOnlyTags: makeMap('a,colgroup,fieldset,legend,table'),
+ // 自闭合的标签
+ selfClosingTags: makeMap('area,base,br,col,circle,ellipse,embed,frame,hr,img,input,line,link,meta,param,path,polygon,rect,source,track,use,wbr'),
+ // 信任的标签
+ trustTags: makeMap('a,abbr,ad,audio,b,blockquote,br,code,col,colgroup,dd,del,dl,dt,div,em,fieldset,h1,h2,h3,h4,h5,h6,hr,i,img,ins,label,legend,li,ol,p,q,source,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,title,ul,video'),
+ // 默认的标签样式
+ userAgentStyles: {
+ address: 'font-style:italic',
+ big: 'display:inline;font-size:1.2em',
+ blockquote: 'background-color:#f6f6f6;border-left:3px solid #dbdbdb;color:#6c6c6c;padding:5px 0 5px 10px',
+ caption: 'display:table-caption;text-align:center',
+ center: 'text-align:center',
+ cite: 'font-style:italic',
+ dd: 'margin-left:40px',
+ mark: 'background-color:yellow',
+ pre: 'font-family:monospace;white-space:pre;overflow:scroll',
+ s: 'text-decoration:line-through',
+ small: 'display:inline;font-size:0.8em',
+ u: 'text-decoration:underline'
+function makeMap(str) {
+ var map = Object.create(null),
+ list = str.split(',');
+ for (var i = list.length; i--;)
+ map[list[i]] = true;
+ return map;
+// #ifdef MP-WEIXIN
+if (wx.canIUse('editor')) {
+ cfg.blockTags.pre = void 0;
+ cfg.ignoreTags.rp = true;
+ Object.assign(cfg.richOnlyTags, makeMap('bdi,bdo,caption,rt,ruby'));
+ Object.assign(cfg.trustTags, makeMap('bdi,bdo,caption,pre,rt,ruby'));
+// #endif
+// #ifdef APP-PLUS
+cfg.ignoreTags.iframe = void 0;
+Object.assign(cfg.trustTags, makeMap('embed,iframe'));
+module.exports = cfg;
@@ -0,0 +1,22 @@
+var inline = {
+ abbr: 1,
+ b: 1,
+ big: 1,
+ code: 1,
+ del: 1,
+ em: 1,
+ i: 1,
+ ins: 1,
+ label: 1,
+ q: 1,
+ small: 1,
+ span: 1,
+ strong: 1,
+ sub: 1,
+ sup: 1
+module.exports = {
+ use: function(item) {
+ return !item.c && !inline[item.name] && (item.attrs.style || '').indexOf('display:inline') == -1
@@ -0,0 +1,501 @@
+ <view :class="'interlayer '+(c||'')" :style="s">
+ <block v-for="(n, i) in nodes" v-bind:key="i">
+ <!--图片-->
+ <view v-if="n.name=='img'" :class="'_img '+n.attrs.class" :style="n.attrs.style" :data-attrs="n.attrs" @tap="imgtap">
+ <rich-text v-if="ctrl[i]!=0" :nodes="[{attrs:{src:loading&&(ctrl[i]||0)<2?loading:(lazyLoad&&!ctrl[i]?placeholder:(ctrl[i]==3?errorImg:n.attrs.src||'')),alt:n.attrs.alt||'',width:n.attrs.width||'',style:'-webkit-touch-callout:none;max-width:100%;display:block'+(n.attrs.height?';height:'+n.attrs.height:'')},name:'img'}]" />
+ <image class="_image" :src="lazyLoad&&!ctrl[i]?placeholder:n.attrs.src" :lazy-load="lazyLoad"
+ :show-menu-by-longpress="!n.attrs.ignore" :data-i="i" :data-index="n.attrs.i" data-source="img" @load="loadImg"
+ @error="error" />
+ <!--文本-->
+ <text v-else-if="n.type=='text'" decode>{{n.text}}</text>
+ <!--#ifndef MP-BAIDU-->
+ <text v-else-if="n.name=='br'">\n</text>
+ <!--#endif-->
+ <!--视频-->
+ <view v-else-if="((n.lazyLoad&&!n.attrs.autoplay)||(n.name=='video'&&!loadVideo))&&ctrl[i]==undefined" :id="n.attrs.id" :class="'_video '+(n.attrs.class||'')"
+ :style="n.attrs.style" :data-i="i" @tap="_loadVideo" />
+ <video v-else-if="n.name=='video'" :id="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :autoplay="n.attrs.autoplay||ctrl[i]==0"
+ :controls="n.attrs.controls" :loop="n.attrs.loop" :muted="n.attrs.muted" :poster="n.attrs.poster" :src="n.attrs.source[ctrl[i]||0]"
+ :unit-id="n.attrs['unit-id']" :data-id="n.attrs.id" :data-i="i" data-source="video" @error="error" @play="play" />
+ <!--音频-->
+ <audio v-else-if="n.name=='audio'" :ref="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :author="n.attrs.author"
+ :autoplay="n.attrs.autoplay" :controls="n.attrs.controls" :loop="n.attrs.loop" :name="n.attrs.name" :poster="n.attrs.poster"
+ :src="n.attrs.source[ctrl[i]||0]" :data-i="i" :data-id="n.attrs.id" data-source="audio"
+ @error.native="error" @play.native="play" />
+ <!--链接-->
+ <view v-else-if="n.name=='a'" :id="n.attrs.id" :class="'_a '+(n.attrs.class||'')" hover-class="_hover" :style="n.attrs.style"
+ :data-attrs="n.attrs" @tap="linkpress">
+ <trees class="_span" c="_span" :nodes="n.children" />
+ <!--广告-->
+ <!--<ad v-else-if="n.name=='ad'" :class="n.attrs.class" :style="n.attrs.style" :unit-id="n.attrs['unit-id']" :appid="n.attrs.appid" :apid="n.attrs.apid" :type="n.attrs.type" :adpid="n.attrs.adpid" data-source="ad" @error="error" />-->
+ <!--列表-->
+ <view v-else-if="n.name=='li'" :id="n.attrs.id" :class="n.attrs.class" :style="(n.attrs.style||'')+';display:flex;flex-direction:row'">
+ <view v-if="n.type=='ol'" class="_ol-bef">{{n.num}}</view>
+ <view v-else class="_ul-bef">
+ <view v-if="n.floor%3==0" class="_ul-p1">█</view>
+ <view v-else-if="n.floor%3==2" class="_ul-p2" />
+ <view v-else class="_ul-p1" style="border-radius:50%">█</view>
+ <trees class="_li" c="_li" :nodes="n.children" :lazyLoad="lazyLoad" :loading="loading" />
+ <!--表格-->
+ <view v-else-if="n.name=='table'&&n.c" :id="n.attrs.id" :class="n.attrs.class" :style="(n.attrs.style||'')+';display:table'">
+ <view v-for="(tbody, o) in n.children" v-bind:key="o" :class="tbody.attrs.class" :style="(tbody.attrs.style||'')+(tbody.name[0]=='t'?';display:table-'+(tbody.name=='tr'?'row':'row-group'):'')">
+ <view v-for="(tr, p) in tbody.children" v-bind:key="p" :class="tr.attrs.class" :style="(tr.attrs.style||'')+(tr.name[0]=='t'?';display:table-'+(tr.name=='tr'?'row':'cell'):'')">
+ <trees v-if="tr.name=='td'" :nodes="tr.children" />
+ <trees v-else v-for="(td, q) in tr.children" v-bind:key="q" :class="td.attrs.class" :c="td.attrs.class" :style="(td.attrs.style||'')+(td.name[0]=='t'?';display:table-'+(td.name=='tr'?'row':'cell'):'')"
+ :s="(td.attrs.style||'')+(td.name[0]=='t'?';display:table-'+(td.name=='tr'?'row':'cell'):'')" :nodes="td.children" />
+ <!--#ifdef APP-PLUS-->
+ <iframe v-else-if="n.name=='iframe'" :style="n.attrs.style" :allowfullscreen="n.attrs.allowfullscreen" :frameborder="n.attrs.frameborder"
+ :width="n.attrs.width" :height="n.attrs.height" :src="n.attrs.src" />
+ <embed v-else-if="n.name=='embed'" :style="n.attrs.style" :width="n.attrs.width" :height="n.attrs.height" :src="n.attrs.src" />
+ <!--富文本-->
+ <!--#ifdef MP-WEIXIN || MP-QQ || APP-PLUS-->
+ <rich-text v-else-if="handler.use(n)" :id="n.attrs.id" :class="'_p __'+n.name" :nodes="[n]" />
+ <!--#ifndef MP-WEIXIN || MP-QQ || APP-PLUS-->
+ <rich-text v-else-if="!n.c" :id="n.attrs.id" :nodes="[n]" style="display:inline" />
+ <trees v-else :class="(n.attrs.id||'')+' _'+n.name+' '+(n.attrs.class||'')" :c="(n.attrs.id||'')+' _'+n.name+' '+(n.attrs.class||'')"
+ :style="n.attrs.style" :s="n.attrs.style" :nodes="n.children" :lazyLoad="lazyLoad" :loading="loading" />
+<script module="handler" lang="wxs" src="./handler.wxs"></script>
+ global.Parser = {};
+ import trees from './trees'
+ const errorImg = require('../libs/config.js').errorImg;
+ components: {
+ trees
+ name: 'trees',
+ ctrl: [],
+ placeholder: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="300" height="225"/>',
+ errorImg,
+ loadVideo: typeof plus == 'undefined',
+ // #ifndef MP-ALIPAY
+ c: '',
+ s: ''
+ nodes: Array,
+ lazyLoad: Boolean,
+ loading: String,
+ c: String,
+ s: String
+ for (this.top = this.$parent; this.top.$options.name != 'parser'; this.top = this.top.$parent);
+ this.observer && this.observer.disconnect();
+ for (var i = this.nodes.length, n; n = this.nodes[--i];) {
+ if (n.name == 'img') {
+ this.top.imgList.setItem(n.attrs.i, n.attrs.src);
+ if (this.lazyLoad && !this.observer) {
+ this.observer = uni.createIntersectionObserver(this).relativeToViewport({
+ top: 500,
+ bottom: 500
+ this.observer.observe('._img', res => {
+ if (res.intersectionRatio) {
+ for (var j = this.nodes.length; j--;)
+ if (this.nodes[j].name == 'img')
+ this.$set(this.ctrl, j, 1);
+ this.observer.disconnect();
+ }, 0)
+ } else if (n.name == 'video' || n.name == 'audio') {
+ var ctx;
+ if (n.name == 'video') {
+ ctx = uni.createVideoContext(n.attrs.id
+ // #ifndef MP-BAIDU
+ , this
+ } else if (this.$refs[n.attrs.id])
+ ctx = this.$refs[n.attrs.id][0];
+ if (ctx) {
+ ctx.id = n.attrs.id;
+ this.top.videoContexts.push(ctx);
+ // APP 上避免 video 错位需要延时渲染
+ this.loadVideo = true;
+ }, 1000)
+ play(e) {
+ var contexts = this.top.videoContexts;
+ if (contexts.length > 1 && this.top.autopause)
+ for (var i = contexts.length; i--;)
+ if (contexts[i].id != e.currentTarget.dataset.id)
+ contexts[i].pause();
+ imgtap(e) {
+ var attrs = e.currentTarget.dataset.attrs;
+ if (!attrs.ignore) {
+ var preview = true,
+ data = {
+ id: e.target.id,
+ src: attrs.src,
+ ignore: () => preview = false
+ global.Parser.onImgtap && global.Parser.onImgtap(data);
+ this.top.$emit('imgtap', data);
+ if (preview) {
+ var urls = this.top.imgList,
+ current = urls[attrs.i] ? parseInt(attrs.i) : (urls = [attrs.src], 0);
+ current,
+ urls
+ loadImg(e) {
+ var i = e.currentTarget.dataset.i;
+ if (this.lazyLoad && !this.ctrl[i]) {
+ // #ifdef QUICKAPP-WEBVIEW
+ this.$set(this.ctrl, i, 0);
+ this.$set(this.ctrl, i, 1);
+ } else if (this.loading && this.ctrl[i] != 2) {
+ this.$set(this.ctrl, i, 2);
+ linkpress(e) {
+ var jump = true,
+ attrs = e.currentTarget.dataset.attrs;
+ attrs.ignore = () => jump = false;
+ global.Parser.onLinkpress && global.Parser.onLinkpress(attrs);
+ this.top.$emit('linkpress', attrs);
+ if (jump) {
+ if (attrs['app-id']) {
+ return uni.navigateToMiniProgram({
+ appId: attrs['app-id'],
+ path: attrs.path
+ if (attrs.href) {
+ if (attrs.href[0] == '#') {
+ if (this.top.useAnchor)
+ this.top.navigateTo({
+ id: attrs.href.substring(1)
+ } else if (attrs.href.indexOf('http') == 0 || attrs.href.indexOf('//') == 0) {
+ plus.runtime.openWeb(attrs.href);
+ data: attrs.href,
+ success: () =>
+ title: '链接已复制'
+ } else
+ uni.navigateTo({
+ url: attrs.href,
+ fail() {
+ uni.switchTab({
+ error(e) {
+ var target = e.currentTarget,
+ source = target.dataset.source,
+ i = target.dataset.i;
+ if (source == 'video' || source == 'audio') {
+ // 加载其他 source
+ var index = this.ctrl[i] ? this.ctrl[i].i + 1 : 1;
+ if (index < this.nodes[i].attrs.source.length)
+ this.$set(this.ctrl, i, index);
+ if (e.detail.__args__)
+ e.detail = e.detail.__args__[0];
+ } else if (errorImg && source == 'img') {
+ this.top.imgList.setItem(target.dataset.index, errorImg);
+ this.$set(this.ctrl, i, 3);
+ this.top && this.top.$emit('error', {
+ source,
+ target,
+ errMsg: e.detail.errMsg
+ _loadVideo(e) {
+ this.$set(this.ctrl, e.target.dataset.i, 0);
+<style>
+ /* 在这里引入自定义样式 */
+ /* 链接和图片效果 */
+ ._a {
+ display: inline;
+ padding: 1.5px 0 1.5px 0;
+ color: #366092;
+ word-break: break-all;
+ ._hover {
+ text-decoration: underline;
+ opacity: 0.7;
+ ._img {
+ max-width: 100%;
+ /* #ifdef MP-WEIXIN */
+ :host {
+ /* #ifndef MP-ALIPAY || APP-PLUS */
+ .interlayer {
+ display: inherit;
+ flex-direction: inherit;
+ flex-wrap: inherit;
+ align-content: inherit;
+ align-items: inherit;
+ justify-content: inherit;
+ white-space: inherit;
+ ._b,
+ ._strong {
+ /* #ifndef MP-ALIPAY */
+ ._blockquote,
+ ._div,
+ ._p,
+ ._ol,
+ ._ul,
+ ._li {
+ display: block;
+ ._code {
+ font-family: monospace;
+ ._del {
+ text-decoration: line-through;
+ ._em,
+ ._i {
+ font-style: italic;
+ ._h1 {
+ font-size: 2em;
+ ._h2 {
+ font-size: 1.5em;
+ ._h3 {
+ font-size: 1.17em;
+ ._h5 {
+ font-size: 0.83em;
+ ._h6 {
+ font-size: 0.67em;
+ ._h1,
+ ._h2,
+ ._h3,
+ ._h4,
+ ._h5,
+ ._image {
+ height: 360px;
+ margin-top: -360px;
+ ._ins {
+ ._ol-bef {
+ width: 36px;
+ margin-right: 5px;
+ ._ul-bef {
+ margin: 0 12px 0 23px;
+ ._ol-bef,
+ ._ul-p1 {
+ width: 0.3em;
+ height: 0.3em;
+ line-height: 0.3em;
+ ._ul-p2 {
+ width: 0.23em;
+ height: 0.23em;
+ border: 0.05em solid black;
+ ._q::before {
+ content: '"';
+ ._q::after {
+ ._sub {
+ font-size: smaller;
+ vertical-align: sub;
+ ._sup {
+ vertical-align: super;
+ /* #ifdef MP-ALIPAY || APP-PLUS || QUICKAPP-WEBVIEW */
+ ._abbr,
+ ._code,
+ ._del,
+ ._i,
+ ._ins,
+ ._label,
+ ._q,
+ ._span,
+ ._strong,
+ ._sub,
+ .__bdo,
+ .__bdi,
+ .__ruby,
+ .__rt {
+ ._video {
+ width: 300px;
+ height: 225px;
+ background-color: black;
+ ._video::after {
+ margin: -15px 0 0 -15px;
+ content: '';
+ border-color: transparent transparent transparent white;
+ border-style: solid;
+ border-width: 15px 0 15px 30px;
@@ -0,0 +1,630 @@
+ <slot v-if="!nodes.length" />
+ <!--#ifdef APP-PLUS-NVUE-->
+ <web-view id="_top" ref="web" :style="'margin-top:-2px;height:'+height+'px'" @onPostMessage="_message" />
+ <!--#ifndef APP-PLUS-NVUE-->
+ <view id="_top" :style="showAm+(selectable?';user-select:text;-webkit-user-select:text':'')">
+ <!--#ifdef H5 || MP-360-->
+ <div :id="'rtf'+uid"></div>
+ <!--#ifndef H5 || MP-360-->
+ <trees :nodes="nodes" :lazyLoad="lazyLoad" :loading="loadingImg" />
+ // #ifndef H5 || APP-PLUS-NVUE || MP-360
+ import trees from './libs/trees';
+ var cache = {},
+ // #ifdef MP-WEIXIN || MP-TOUTIAO
+ fs = uni.getFileSystemManager ? uni.getFileSystemManager() : null,
+ Parser = require('./libs/MpHtmlParser.js');
+ var dom;
+ // 计算 cache 的 key
+ function hash(str) {
+ for (var i = str.length, val = 5381; i--;)
+ val += (val << 5) + str.charCodeAt(i);
+ // #ifdef H5 || APP-PLUS-NVUE || MP-360
+ var windowWidth = uni.getSystemInfoSync().windowWidth,
+ cfg = require('./libs/config.js');
+ // #ifdef APP-PLUS-NVUE
+ var weexDom = weex.requireModule('dom');
+ * Parser 富文本组件
+ * @property {String} html 富文本数据
+ * @property {Boolean} autopause 是否在播放一个视频时自动暂停其他视频
+ * @property {Boolean} autoscroll 是否自动给所有表格添加一个滚动层
+ * @property {Boolean} autosetTitle 是否自动将 title 标签中的内容设置到页面标题
+ * @property {Number} compress 压缩等级
+ * @property {String} domain 图片、视频等链接的主域名
+ * @property {Boolean} lazyLoad 是否开启图片懒加载
+ * @property {String} loadingImg 图片加载完成前的占位图
+ * @property {Boolean} selectable 是否开启长按复制
+ * @property {Object} tagStyle 标签的默认样式
+ * @property {Boolean} showWithAnimation 是否使用渐显动画
+ * @property {Boolean} useAnchor 是否使用锚点
+ * @property {Boolean} useCache 是否缓存解析结果
+ * @event {Function} parse 解析完成事件
+ * @event {Function} load dom 加载完成事件
+ * @event {Function} ready 所有图片加载完毕事件
+ * @event {Function} error 错误事件
+ * @event {Function} imgtap 图片点击事件
+ * @event {Function} linkpress 链接点击事件
+ name: 'parser',
+ // #ifdef H5 || MP-360
+ uid: this._uid,
+ height: 1,
+ // #ifndef APP-PLUS-NVUE
+ showAm: '',
+ nodes: []
+ html: String,
+ autopause: {
+ autoscroll: Boolean,
+ autosetTitle: {
+ compress: Number,
+ loadingImg: String,
+ useCache: Boolean,
+ domain: String,
+ selectable: Boolean,
+ tagStyle: Object,
+ showWithAnimation: Boolean,
+ useAnchor: Boolean
+ html(html) {
+ this.setContent(html);
+ // 图片数组
+ this.imgList = [];
+ this.imgList.each = function(f) {
+ for (var i = 0, len = this.length; i < len; i++)
+ this.setItem(i, f(this[i], i, this));
+ this.imgList.setItem = function(i, src) {
+ if (i == void 0 || !src) return;
+ // #ifndef MP-ALIPAY || APP-PLUS
+ // 去重
+ if (src.indexOf('http') == 0 && this.includes(src)) {
+ var newSrc = src.split('://')[0];
+ for (var j = newSrc.length, c; c = src[j]; j++) {
+ if (c == '/' && src[j - 1] != '/' && src[j + 1] != '/') break;
+ newSrc += Math.random() > 0.5 ? c.toUpperCase() : c;
+ newSrc += src.substr(j);
+ return this[i] = newSrc;
+ this[i] = src;
+ // 暂存 data src
+ if (src.includes('data:image')) {
+ var filePath, info = src.match(/data:image\/(\S+?);(\S+?),(.+)/);
+ if (!info) return;
+ filePath = `${wx.env.USER_DATA_PATH}/${Date.now()}.${info[1]}`;
+ fs && fs.writeFile({
+ filePath,
+ data: info[3],
+ encoding: info[2],
+ success: () => this[i] = filePath
+ filePath = `_doc/parser_tmp/${Date.now()}.${info[1]}`;
+ var bitmap = new plus.nativeObj.Bitmap();
+ bitmap.loadBase64Data(src, () => {
+ bitmap.save(filePath, {}, () => {
+ bitmap.clear()
+ this[i] = filePath;
+ this.document = document.getElementById('rtf' + this._uid);
+ if (dom) this.document = new dom(this);
+ this.document = this.$refs.web;
+ if (this.html) this.setContent(this.html);
+ }, 30)
+ if (this._observer) this._observer.disconnect();
+ this.imgList.each(src => {
+ if (src && src.includes('_doc')) {
+ plus.io.resolveLocalFileSystemURL(src, entry => {
+ entry.remove();
+ if (src && src.includes(uni.env.USER_DATA_PATH))
+ fs && fs.unlink({
+ filePath: src
+ clearInterval(this._timer);
+ // 设置富文本内容
+ setContent(html, append) {
+ if (!html)
+ return this.height = 1;
+ if (append)
+ this.$refs.web.evalJs("var b=document.createElement('div');b.innerHTML='" + html.replace(/'/g, "\\'") +
+ "';document.getElementById('parser').appendChild(b)");
+ html =
+ '<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"><style>html,body{width:100%;height:100%;overflow:hidden}body{margin:0}</style><base href="' +
+ this.domain + '"><div id="parser"' + (this.selectable ? '>' : ' style="user-select:none">') + this._handleHtml(html).replace(/\n/g, '\\n') +
+ '</div><script>"use strict";function e(e){if(window.__dcloud_weex_postMessage||window.__dcloud_weex_){var t={data:[e]};window.__dcloud_weex_postMessage?window.__dcloud_weex_postMessage(t):window.__dcloud_weex_.postMessage(JSON.stringify(t))}}document.body.onclick=function(){e({action:"click"})},' +
+ (this.showWithAnimation ? 'document.body.style.animation="_show .5s",' : '') +
+ 'setTimeout(function(){e({action:"load",text:document.body.innerText,height:document.getElementById("parser").scrollHeight})},50);\x3c/script>';
+ this.$refs.web.evalJs("document.write('" + html.replace(/'/g, "\\'") + "');document.close()");
+ this.$refs.web.evalJs(
+ 'var t=document.getElementsByTagName("title");t.length&&e({action:"getTitle",title:t[0].innerText});for(var o,n=document.getElementsByTagName("style"),r=1;o=n[r++];)o.innerHTML=o.innerHTML.replace(/body/g,"#parser");for(var a,c=document.getElementsByTagName("img"),s=[],i=0==c.length,d=0,l=0,g=0;a=c[l];l++)parseInt(a.style.width||a.getAttribute("width"))>' +
+ windowWidth + '&&(a.style.height="auto"),a.onload=function(){++d==c.length&&(i=!0)},a.onerror=function(){++d==c.length&&(i=!0),' + (cfg.errorImg ? 'this.src="' + cfg.errorImg + '",' : '') +
+ 'e({action:"error",source:"img",target:this})},a.hasAttribute("ignore")||"A"==a.parentElement.nodeName||(a.i=g++,s.push(a.src),a.onclick=function(){e({action:"preview",img:{i:this.i,src:this.src}})});e({action:"getImgList",imgList:s});for(var u,m=document.getElementsByTagName("a"),f=0;u=m[f];f++)u.onclick=function(){var t,o=this.getAttribute("href");if("#"==o[0]){var n=document.getElementById(o.substr(1));n&&(t=n.offsetTop)}return e({action:"linkpress",href:o,offset:t}),!1};for(var h,y=document.getElementsByTagName("video"),v=0;h=y[v];v++)h.style.maxWidth="100%",h.onerror=function(){e({action:"error",source:"video",target:this})}' +
+ (this.autopause ? ',h.onplay=function(){for(var e,t=0;e=y[t];t++)e!=this&&e.pause()}' : '') +
+ ';for(var _,p=document.getElementsByTagName("audio"),w=0;_=p[w];w++)_.onerror=function(){e({action:"error",source:"audio",target:this})};' +
+ (this.autoscroll ? 'for(var T,E=document.getElementsByTagName("table"),B=0;T=E[B];B++){var N=document.createElement("div");N.style.overflow="scroll",T.parentNode.replaceChild(N,T),N.appendChild(T)}' : '') +
+ 'var x=document.getElementById("parser");clearInterval(window.timer),window.timer=setInterval(function(){i&&clearInterval(window.timer),e({action:"ready",ready:i,height:x.scrollHeight})},350)'
+ )
+ this.nodes = [1];
+ if (!html) {
+ if (this.rtf && !append) this.rtf.parentNode.removeChild(this.rtf);
+ var div = document.createElement('div');
+ if (!append) {
+ if (this.rtf) this.rtf.parentNode.removeChild(this.rtf);
+ this.rtf = div;
+ if (!this.rtf) this.rtf = div;
+ else this.rtf.appendChild(div);
+ div.innerHTML = this._handleHtml(html, append);
+ for (var styles = this.rtf.getElementsByTagName('style'), i = 0, style; style = styles[i++];) {
+ style.innerHTML = style.innerHTML.replace(/body/g, '#rtf' + this._uid);
+ style.setAttribute('scoped', 'true');
+ // 懒加载
+ if (!this._observer && this.lazyLoad && IntersectionObserver) {
+ this._observer = new IntersectionObserver(changes => {
+ for (let item, i = 0; item = changes[i++];) {
+ if (item.isIntersecting) {
+ item.target.src = item.target.getAttribute('data-src');
+ item.target.removeAttribute('data-src');
+ this._observer.unobserve(item.target);
+ }, {
+ rootMargin: '500px 0px 500px 0px'
+ var _ts = this;
+ // 获取标题
+ var title = this.rtf.getElementsByTagName('title');
+ if (title.length && this.autosetTitle)
+ uni.setNavigationBarTitle({
+ title: title[0].innerText
+ // 图片处理
+ this.imgList.length = 0;
+ var imgs = this.rtf.getElementsByTagName('img');
+ for (let i = 0, j = 0, img; img = imgs[i]; i++) {
+ if (parseInt(img.style.width || img.getAttribute('width')) > windowWidth)
+ img.style.height = 'auto';
+ var src = img.getAttribute('src');
+ if (this.domain && src) {
+ if (src[0] == '/') {
+ if (src[1] == '/')
+ img.src = (this.domain.includes('://') ? this.domain.split('://')[0] : '') + ':' + src;
+ else img.src = this.domain + src;
+ } else if (!src.includes('://')) img.src = this.domain + '/' + src;
+ if (!img.hasAttribute('ignore') && img.parentElement.nodeName != 'A') {
+ img.i = j++;
+ _ts.imgList.push(img.src || img.getAttribute('data-src'));
+ img.onclick = function() {
+ var preview = true;
+ this.ignore = () => preview = false;
+ _ts.$emit('imgtap', this);
+ current: this.i,
+ urls: _ts.imgList
+ img.onerror = function() {
+ if (cfg.errorImg)
+ _ts.imgList[this.i] = this.src = cfg.errorImg;
+ _ts.$emit('error', {
+ source: 'img',
+ target: this
+ if (_ts.lazyLoad && this._observer && img.src && img.i != 0) {
+ img.setAttribute('data-src', img.src);
+ img.removeAttribute('src');
+ this._observer.observe(img);
+ // 链接处理
+ var links = this.rtf.getElementsByTagName('a');
+ for (var link of links) {
+ link.onclick = function() {
+ href = this.getAttribute('href');
+ _ts.$emit('linkpress', {
+ href,
+ ignore: () => jump = false
+ if (jump && href) {
+ if (href[0] == '#') {
+ if (_ts.useAnchor) {
+ _ts.navigateTo({
+ id: href.substr(1)
+ } else if (href.indexOf('http') == 0 || href.indexOf('//') == 0)
+ else
+ url: href
+ // 视频处理
+ var videos = this.rtf.getElementsByTagName('video');
+ _ts.videoContexts = videos;
+ for (let video, i = 0; video = videos[i++];) {
+ video.style.maxWidth = '100%';
+ video.onerror = function() {
+ source: 'video',
+ video.onplay = function() {
+ if (_ts.autopause)
+ for (let item, i = 0; item = _ts.videoContexts[i++];)
+ if (item != this) item.pause();
+ // 音频处理
+ var audios = this.rtf.getElementsByTagName('audio');
+ for (var audio of audios)
+ audio.onerror = function() {
+ source: 'audio',
+ // 表格处理
+ if (this.autoscroll) {
+ var tables = this.rtf.getElementsByTagName('table');
+ for (var table of tables) {
+ let div = document.createElement('div');
+ div.style.overflow = 'scroll';
+ table.parentNode.replaceChild(div, table);
+ div.appendChild(table);
+ if (!append) this.document.appendChild(this.rtf);
+ setTimeout(() => this.showAm = '', 500);
+ // #ifndef H5 || MP-360
+ var nodes;
+ if (!html) return this.nodes = [];
+ var parser = new Parser(html, this);
+ // 缓存读取
+ if (this.useCache) {
+ var hashVal = hash(html);
+ if (cache[hashVal])
+ nodes = cache[hashVal];
+ nodes = parser.parse();
+ cache[hashVal] = nodes;
+ } else nodes = parser.parse();
+ this.$emit('parse', nodes);
+ if (append) this.nodes = this.nodes.concat(nodes);
+ else this.nodes = nodes;
+ if (nodes.length && nodes.title && this.autosetTitle)
+ title: nodes.title
+ if (this.imgList) this.imgList.length = 0;
+ this.videoContexts = [];
+ (function f(cs) {
+ for (var i = cs.length; i--;) {
+ if (cs[i].top) {
+ cs[i].controls = [];
+ cs[i].init();
+ f(cs[i].$children);
+ })(this.$children)
+ var height;
+ this._timer = setInterval(() => {
+ this.rect = this.rtf.getBoundingClientRect();
+ uni.createSelectorQuery().in(this)
+ .select('#_top').boundingClientRect().exec(res => {
+ if (!res) return;
+ this.rect = res[0];
+ if (this.rect.height == height) {
+ this.$emit('ready', this.rect)
+ height = this.rect.height;
+ }, 350);
+ if (this.showWithAnimation && !append) this.showAm = 'animation:_show .5s';
+ // 获取文本内容
+ getText(ns = this.nodes) {
+ var txt = '';
+ txt = this._text;
+ txt = this.rtf.innerText;
+ for (var i = 0, n; n = ns[i++];) {
+ if (n.type == 'text') txt += n.text.replace(/ /g, '\u00A0').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/&/g, '&');
+ else if (n.type == 'br') txt += '\n';
+ // 块级标签前后加换行
+ var block = n.name == 'p' || n.name == 'div' || n.name == 'tr' || n.name == 'li' || (n.name[0] == 'h' && n.name[1] >
+ '0' && n.name[1] < '7');
+ if (block && txt && txt[txt.length - 1] != '\n') txt += '\n';
+ if (n.children) txt += this.getText(n.children);
+ if (block && txt[txt.length - 1] != '\n') txt += '\n';
+ else if (n.name == 'td' || n.name == 'th') txt += '\t';
+ return txt;
+ // 锚点跳转
+ in (obj) {
+ if (obj.page && obj.selector && obj.scrollTop) this._in = obj;
+ navigateTo(obj) {
+ if (!this.useAnchor) return obj.fail && obj.fail('Anchor is disabled');
+ if (!obj.id)
+ weexDom.scrollToElement(this.$refs.web);
+ this.$refs.web.evalJs('var pos=document.getElementById("' + obj.id +
+ '");if(pos)post({action:"linkpress",href:"#",offset:pos.offsetTop+' + (obj.offset || 0) + '})');
+ obj.success && obj.success();
+ var d = ' ';
+ // #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
+ d = '>>>';
+ var selector = uni.createSelectorQuery().in(this._in ? this._in.page : this).select((this._in ? this._in.selector :
+ '#_top') + (obj.id ? `${d}#${obj.id},${this._in?this._in.selector:'#_top'}${d}.${obj.id}` : '')).boundingClientRect();
+ if (this._in) selector.select(this._in.selector).scrollOffset().select(this._in.selector).boundingClientRect();
+ else selector.selectViewport().scrollOffset();
+ selector.exec(res => {
+ if (!res[0]) return obj.fail && obj.fail('Label not found')
+ var scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + (obj.offset || 0);
+ if (this._in) this._in.page[this._in.scrollTop] = scrollTop;
+ else uni.pageScrollTo({
+ duration: 300
+ // 获取视频对象
+ getVideoContext(id) {
+ if (!id) return this.videoContexts;
+ for (var i = this.videoContexts.length; i--;)
+ if (this.videoContexts[i].id == id) return this.videoContexts[i];
+ _handleHtml(html, append) {
+ // 处理 tag-style 和 userAgentStyles
+ var style = '<style>@keyframes _show{0%{opacity:0}100%{opacity:1}}img{max-width:100%}';
+ for (var item in cfg.userAgentStyles)
+ style += `${item}{${cfg.userAgentStyles[item]}}`;
+ for (item in this.tagStyle)
+ style += `${item}{${this.tagStyle[item]}}`;
+ style += '</style>';
+ html = style + html;
+ // 处理rpx
+ if (html.includes('rpx'))
+ html = html.replace(/[0-9.]+\s*rpx/g, $ => (parseFloat($) * windowWidth / 750) + 'px');
+ return html;
+ _message(e) {
+ // 接收 web-view 消息
+ var d = e.detail.data[0];
+ switch (d.action) {
+ case 'load':
+ this.height = d.height;
+ this._text = d.text;
+ case 'getTitle':
+ if (this.autosetTitle)
+ title: d.title
+ case 'getImgList':
+ for (var i = d.imgList.length; i--;)
+ this.imgList.setItem(i, d.imgList[i]);
+ case 'preview':
+ d.img.ignore = () => preview = false;
+ this.$emit('imgtap', d.img);
+ if (preview)
+ current: d.img.i,
+ urls: this.imgList
+ case 'linkpress':
+ href = d.href;
+ this.$emit('linkpress', {
+ if (this.useAnchor)
+ weexDom.scrollToElement(this.$refs.web, {
+ offset: d.offset
+ } else if (href.includes('://'))
+ plus.runtime.openWeb(href);
+ case 'error':
+ if (d.source == 'img' && cfg.errorImg)
+ this.imgList.setItem(d.target.i, cfg.errorImg);
+ this.$emit('error', {
+ source: d.source,
+ target: d.target
+ case 'ready':
+ if (d.ready) uni.createSelectorQuery().in(this).select('#_top').boundingClientRect().exec(res => {
+ this.$emit('ready', res[0]);
+ case 'click':
+ this.$emit('tap');
+ @keyframes _show {
+ overflow: scroll;
+ -webkit-overflow-scrolling: touch;
@@ -0,0 +1,676 @@
+ <u-popup :maskCloseAble="maskCloseAble" mode="bottom" :popup="false" v-model="value" length="auto" :safeAreaInsetBottom="safeAreaInsetBottom" @close="close" :z-index="uZIndex">
+ <view class="u-datetime-picker">
+ <view class="u-picker-header" @touchmove.stop.prevent="">
+ <view class="u-btn-picker u-btn-picker--tips"
+ :style="{ color: cancelColor }"
+ hover-class="u-opacity"
+ @tap="getResult('cancel')"
+ >{{cancelText}}</view>
+ <view class="u-picker__title">{{ title }}</view>
+ class="u-btn-picker u-btn-picker--primary"
+ :style="{ color: moving ? cancelColor : confirmColor }"
+ @touchmove.stop=""
+ @tap.stop="getResult('confirm')"
+ <view class="u-picker-body">
+ <picker-view v-if="mode == 'region'" :value="valueArr" @change="change" class="u-picker-view" @pickstart="pickstart" @pickend="pickend">
+ <picker-view-column v-if="!reset && params.province">
+ <view class="u-column-item" v-for="(item, index) in provinces" :key="index">
+ <view class="u-line-1">{{ item.label }}</view>
+ </picker-view-column>
+ <picker-view-column v-if="!reset && params.city">
+ <view class="u-column-item" v-for="(item, index) in citys" :key="index">
+ <picker-view-column v-if="!reset && params.area">
+ <view class="u-column-item" v-for="(item, index) in areas" :key="index">
+ </picker-view>
+ <picker-view v-else-if="mode == 'time'" :value="valueArr" @change="change" class="u-picker-view" @pickstart="pickstart" @pickend="pickend">
+ <picker-view-column v-if="!reset && params.year">
+ <view class="u-column-item" v-for="(item, index) in years" :key="index">
+ <text class="u-text" v-if="showTimeTag">年</text>
+ <picker-view-column v-if="!reset && params.month">
+ <view class="u-column-item" v-for="(item, index) in months" :key="index">
+ {{ formatNumber(item) }}
+ <text class="u-text" v-if="showTimeTag">月</text>
+ <picker-view-column v-if="!reset && params.day">
+ <view class="u-column-item" v-for="(item, index) in days" :key="index">
+ <text class="u-text" v-if="showTimeTag">日</text>
+ <picker-view-column v-if="!reset && params.hour">
+ <view class="u-column-item" v-for="(item, index) in hours" :key="index">
+ <text class="u-text" v-if="showTimeTag">时</text>
+ <picker-view-column v-if="!reset && params.minute">
+ <view class="u-column-item" v-for="(item, index) in minutes" :key="index">
+ <text class="u-text" v-if="showTimeTag">分</text>
+ <picker-view-column v-if="!reset && params.second">
+ <view class="u-column-item" v-for="(item, index) in seconds" :key="index">
+ <text class="u-text" v-if="showTimeTag">秒</text>
+ <picker-view v-else-if="mode == 'selector'" :value="valueArr" @change="change" class="u-picker-view" @pickstart="pickstart" @pickend="pickend">
+ <picker-view-column v-if="!reset">
+ <view class="u-column-item" v-for="(item, index) in range" :key="index">
+ <view class="u-line-1">{{ getItemValue(item, 'selector') }}</view>
+ <picker-view v-else-if="mode == 'multiSelector'" :value="valueArr" @change="change" class="u-picker-view" @pickstart="pickstart" @pickend="pickend">
+ <picker-view-column v-if="!reset" v-for="(item, index) in range" :key="index">
+ <view class="u-column-item" v-for="(item1, index1) in item" :key="index1">
+ <view class="u-line-1">{{ getItemValue(item1, 'multiSelector') }}</view>
+import provinces from '../../libs/util/province.js';
+import citys from '../../libs/util/city.js';
+import areas from '../../libs/util/area.js';
+ * picker picker弹出选择器
+ * @description 此选择器有两种弹出模式:一是时间模式,可以配置年,日,月,时,分,秒参数 二是地区模式,可以配置省,市,区参数
+ * @tutorial https://www.uviewui.com/components/picker.html
+ * @property {Object} params 需要显示的参数,见官网说明
+ * @property {String} mode 模式选择,region-地区类型,time-时间类型(默认time)
+ * @property {String Number} start-year 可选的开始年份,mode=time时有效(默认1950)
+ * @property {String Number} end-year 可选的结束年份,mode=time时有效(默认2050)
+ * @property {Boolean} show-time-tag 时间模式时,是否显示后面的年月日中文提示
+ * @property {String} cancel-color 取消按钮的颜色(默认#606266)
+ * @property {String} confirm-color 确认按钮的颜色(默认#2979ff)
+ * @property {String} default-time 默认选中的时间,mode=time时有效
+ * @property {String} default-region 默认选中的地区,中文形式,mode=region时有效
+ * @property {String} default-code 默认选中的地区,编号形式,mode=region时有效
+ * @property {Boolean} mask-close-able 是否允许通过点击遮罩关闭Picker(默认true)
+ * @property {String Number} z-index 弹出时的z-index值(默认1075)
+ * @property {Array} default-selector 数组形式,其中每一项表示选择了range对应项中的第几个
+ * @property {Array} range 自定义选择的数据,mode=selector或mode=multiSelector时有效
+ * @property {String} range-key 当range参数的元素为对象时,指定Object中的哪个key的值作为选择器显示内容
+ * @event {Function} confirm 点击确定按钮,返回当前选择的值
+ * @event {Function} cancel 点击取消按钮,返回当前选择的值
+ * @example <u-picker v-model="show" mode="time"></u-picker>
+ name: 'u-picker',
+ // picker中需要显示的参数
+ params: {
+ year: true,
+ month: true,
+ day: true,
+ hour: false,
+ minute: false,
+ second: false,
+ province: true,
+ city: true,
+ area: true,
+ timestamp: true,
+ // 当mode=selector或者mode=multiSelector时,提供的数组
+ range: {
+ // 当mode=selector或者mode=multiSelector时,提供的默认选中的下标
+ defaultSelector: {
+ return [0];
+ // 当 range 是一个 Array<Object> 时,通过 range-key 来指定 Object 中 key 的值作为选择器显示内容
+ rangeKey: {
+ // 模式选择,region-地区类型,time-时间类型,selector-单列模式,multiSelector-多列模式
+ default: 'time'
+ // 年份开始时间
+ startYear: {
+ // 年份结束时间
+ endYear: {
+ // "取消"按钮的颜色
+ // "确定"按钮的颜色
+ // 默认显示的时间,2025-07-02 || 2025-07-02 13:01:00 || 2025/07/02
+ defaultTime: {
+ // 默认显示的地区,可传类似["河北省", "秦皇岛市", "北戴河区"]
+ defaultRegion: {
+ // 时间模式时,是否显示后面的年月日中文提示
+ showTimeTag: {
+ // 默认显示地区的编码,defaultRegion和areaCode同时存在,areaCode优先,可传类似["13", "1303", "130304"]
+ areaCode: {
+ // 顶部标题
+ years: [],
+ months: [],
+ days: [],
+ hours: [],
+ minutes: [],
+ seconds: [],
+ year: 0,
+ hour: 0,
+ minute: 0,
+ second: 0,
+ reset: false,
+ valueArr: [],
+ provinces: provinces,
+ citys: citys[0],
+ areas: areas[0][0],
+ province: 0,
+ city: 0,
+ area: 0,
+ moving: false // 列是否还在滑动中,微信小程序如果在滑动中就点确定,结果可能不准确
+ // 引用这几个变量,是为了监听其变化
+ return `${this.mode}-${this.defaultTime}-${this.startYear}-${this.endYear}-${this.defaultRegion}-${this.areaCode}`;
+ regionChange() {
+ return `${this.province}-${this.city}`;
+ yearAndMonth() {
+ return `${this.year}-${this.month}`;
+ this.reset = true;
+ setTimeout(() => this.init(), 10);
+ // 如果地区发生变化,为了让picker联动起来,必须重置this.citys和this.areas
+ regionChange(val) {
+ this.citys = citys[this.province];
+ this.areas = areas[this.province][this.city];
+ // watch监听月份的变化,实时变更日的天数,因为不同月份,天数不一样
+ // 一个月可能有30,31天,甚至闰年2月的29天,平年2月28天
+ yearAndMonth(val) {
+ if (this.params.year) this.setDays();
+ // 微信和QQ小程序由于一些奇怪的原因(故同时对所有平台均初始化一遍),需要重新初始化才能显示正确的值
+ if (n) {
+ // 标识滑动开始,只有微信小程序才有这样的事件
+ pickstart() {
+ this.moving = true;
+ // 标识滑动结束
+ pickend() {
+ this.moving = false;
+ // 对单列和多列形式的判断是否有传入变量的情况
+ getItemValue(item, mode) {
+ // 目前(2020-05-25)uni-app对微信小程序编译有错误,导致v-if为false中的内容也执行,错误导致
+ // 单列模式或者多列模式中的getItemValue同时被执行,故在这里再加一层判断
+ if (this.mode == mode) {
+ return typeof item == 'object' ? item[this.rangeKey] : item;
+ // 小于10前面补0,用于月份,日期,时分秒等
+ return +num < 10 ? '0' + num : String(num);
+ // 生成递进的数组
+ // 转为数值格式,否则用户给end-year等传递字符串值时,下面的end+1会导致字符串拼接,而不是相加
+ start = Number(start);
+ end = Number(end);
+ end = end > start ? end : start;
+ // 生成数组,获取其中的索引,并剪出来
+ return [...Array(end + 1).keys()].slice(start);
+ getIndex: function(arr, val) {
+ let index = arr.indexOf(val);
+ // 如果index为-1(即找不到index值),~(-1)=-(-1)-1=0,导致条件不成立
+ return ~index ? index : 0;
+ //日期时间处理
+ initTimeValue() {
+ // 格式化时间,在IE浏览器(uni不存在此情况),无法识别日期间的"-"间隔符号
+ let fdate = this.defaultTime.replace(/\-/g, '/');
+ fdate = fdate && fdate.indexOf('/') == -1 ? `2020/01/01 ${fdate}` : fdate;
+ let time = null;
+ if (fdate) time = new Date(fdate);
+ else time = new Date();
+ // 获取年日月时分秒
+ this.year = time.getFullYear();
+ this.month = Number(time.getMonth()) + 1;
+ this.day = time.getDate();
+ this.hour = time.getHours();
+ this.minute = time.getMinutes();
+ this.second = time.getSeconds();
+ this.valueArr = [];
+ this.reset = false;
+ if (this.mode == 'time') {
+ this.initTimeValue();
+ if (this.params.year) {
+ this.valueArr.push(0);
+ this.setYears();
+ if (this.params.month) {
+ this.setMonths();
+ if (this.params.day) {
+ this.setDays();
+ if (this.params.hour) {
+ this.setHours();
+ if (this.params.minute) {
+ this.setMinutes();
+ if (this.params.second) {
+ this.setSeconds();
+ } else if (this.mode == 'region') {
+ if (this.params.province) {
+ this.setProvinces();
+ if (this.params.city) {
+ this.setCitys();
+ if (this.params.area) {
+ this.setAreas();
+ } else if (this.mode == 'selector') {
+ this.valueArr = this.defaultSelector;
+ } else if (this.mode == 'multiSelector') {
+ this.multiSelectorValue = this.defaultSelector;
+ // 设置picker的某一列值
+ setYears() {
+ // 获取年份集合
+ this.years = this.generateArray(this.startYear, this.endYear);
+ // 设置this.valueArr某一项的值,是为了让picker预选中某一个值
+ this.valueArr.splice(this.valueArr.length - 1, 1, this.getIndex(this.years, this.year));
+ setMonths() {
+ this.months = this.generateArray(1, 12);
+ this.valueArr.splice(this.valueArr.length - 1, 1, this.getIndex(this.months, this.month));
+ setDays() {
+ let totalDays = new Date(this.year, this.month, 0).getDate();
+ this.days = this.generateArray(1, totalDays);
+ let index = 0;
+ // 这里不能使用类似setMonths()中的this.valueArr.splice(this.valueArr.length - 1, xxx)做法
+ // 因为this.month和this.year变化时,会触发watch中的this.setDays(),导致this.valueArr.length计算有误
+ if (this.params.year && this.params.month) index = 2;
+ else if (this.params.month) index = 1;
+ else if (this.params.year) index = 1;
+ else index = 0;
+ // 当月份变化时,会导致日期的天数也会变化,如果原来选的天数大于变化后的天数,则重置为变化后的最大值
+ // 比如原来选中3月31日,调整为2月后,日期变为最大29,这时如果day值继续为31显然不合理,于是将其置为29(picker-column从1开始)
+ if(this.day > this.days.length) this.day = this.days.length;
+ this.valueArr.splice(index, 1, this.getIndex(this.days, this.day));
+ setHours() {
+ this.hours = this.generateArray(0, 23);
+ this.valueArr.splice(this.valueArr.length - 1, 1, this.getIndex(this.hours, this.hour));
+ setMinutes() {
+ this.minutes = this.generateArray(0, 59);
+ this.valueArr.splice(this.valueArr.length - 1, 1, this.getIndex(this.minutes, this.minute));
+ setSeconds() {
+ this.seconds = this.generateArray(0, 59);
+ this.valueArr.splice(this.valueArr.length - 1, 1, this.getIndex(this.seconds, this.second));
+ setProvinces() {
+ // 判断是否需要province参数
+ if (!this.params.province) return;
+ let tmp = '';
+ let useCode = false;
+ // 如果同时配置了defaultRegion和areaCode,优先使用areaCode参数
+ if (this.areaCode.length) {
+ tmp = this.areaCode[0];
+ useCode = true;
+ } else if (this.defaultRegion.length) tmp = this.defaultRegion[0];
+ else tmp = 0;
+ // 历遍省份数组匹配
+ provinces.map((v, k) => {
+ if (useCode ? v.value == tmp : v.label == tmp) {
+ tmp = k;
+ this.province = tmp;
+ this.provinces = provinces;
+ // 设置默认省份的值
+ this.valueArr.splice(0, 1, this.province);
+ setCitys() {
+ if (!this.params.city) return;
+ tmp = this.areaCode[1];
+ } else if (this.defaultRegion.length) tmp = this.defaultRegion[1];
+ citys[this.province].map((v, k) => {
+ this.city = tmp;
+ this.valueArr.splice(1, 1, this.city);
+ setAreas() {
+ if (!this.params.area) return;
+ tmp = this.areaCode[2];
+ } else if (this.defaultRegion.length) tmp = this.defaultRegion[2];
+ areas[this.province][this.city].map((v, k) => {
+ this.area = tmp;
+ this.valueArr.splice(2, 1, this.area);
+ // 用户更改picker的列选项
+ this.valueArr = e.detail.value;
+ let i = 0;
+ // 这里使用i++,是因为this.valueArr数组的长度是不确定长度的,它根据this.params的值来配置长度
+ // 进入if规则,i会加1,保证了能获取准确的值
+ if (this.params.year) this.year = this.years[this.valueArr[i++]];
+ if (this.params.month) this.month = this.months[this.valueArr[i++]];
+ if (this.params.day) this.day = this.days[this.valueArr[i++]];
+ if (this.params.hour) this.hour = this.hours[this.valueArr[i++]];
+ if (this.params.minute) this.minute = this.minutes[this.valueArr[i++]];
+ if (this.params.second) this.second = this.seconds[this.valueArr[i++]];
+ if (this.params.province) this.province = this.valueArr[i++];
+ if (this.params.city) this.city = this.valueArr[i++];
+ if (this.params.area) this.area = this.valueArr[i++];
+ let index = null;
+ // 对比前后两个数组,寻找变更的是哪一列,如果某一个元素不同,即可判定该列发生了变化
+ this.defaultSelector.map((val, idx) => {
+ if (val != e.detail.value[idx]) index = idx;
+ // 为了让用户对多列变化时,对动态设置其他列的变更
+ if (index != null) {
+ this.$emit('columnchange', {
+ column: index,
+ index: e.detail.value[index]
+ // 用户点击确定按钮
+ getResult(event = null) {
+ if (this.moving) return;
+ let result = {};
+ // 只返回用户在this.params中配置了为true的字段
+ if (this.params.year) result.year = this.formatNumber(this.year || 0);
+ if (this.params.month) result.month = this.formatNumber(this.month || 0);
+ if (this.params.day) result.day = this.formatNumber(this.day || 0);
+ if (this.params.hour) result.hour = this.formatNumber(this.hour || 0);
+ if (this.params.minute) result.minute = this.formatNumber(this.minute || 0);
+ if (this.params.second) result.second = this.formatNumber(this.second || 0);
+ if (this.params.timestamp) result.timestamp = this.getTimestamp();
+ if (this.params.province) result.province = provinces[this.province];
+ if (this.params.city) result.city = citys[this.province][this.city];
+ if (this.params.area) result.area = areas[this.province][this.city][this.area];
+ result = this.valueArr;
+ if (event) this.$emit(event, result);
+ // 获取时间戳
+ getTimestamp() {
+ // yyyy-mm-dd为安卓写法,不支持iOS,需要使用"/"分隔,才能二者兼容
+ let time = this.year + '/' + this.month + '/' + this.day + ' ' + this.hour + ':' + this.minute + ':' + this.second;
+ return new Date(time).getTime() / 1000;
+.u-datetime-picker {
+ z-index: 999;
+.u-picker-view {
+.u-picker-header {
+.u-picker-header::after {
+ border-bottom: 1rpx solid #eaeef1;
+.u-picker__title {
+.u-picker-body {
+ height: 500rpx;
+.u-column-item {
+ padding: 0 8rpx;
+.u-text {
+ padding-left: 8rpx;
+.u-btn-picker {
+ padding: 16rpx;
+.u-opacity {
+ opacity: 0.5;
+.u-btn-picker--primary {
+.u-btn-picker--tips {
@@ -0,0 +1,452 @@
+ <view v-if="visibleSync" :style="[customStyle, {
+ zIndex: uZindex - 1
+ }]" :class="{ 'u-drawer-visible': showDrawer }" class="u-drawer" hover-stop-propagation>
+ <u-mask :custom-style="maskCustomStyle" :maskClickAble="maskCloseAble" :z-index="uZindex - 2" :show="showDrawer && mask" @click="maskClick"></u-mask>
+ class="u-drawer-content"
+ @tap="modeCenterClose(mode)"
+ safeAreaInsetBottom ? 'safe-area-inset-bottom' : '',
+ 'u-drawer-' + mode,
+ showDrawer ? 'u-drawer-content-visible' : '',
+ zoom && mode == 'center' ? 'u-animation-zoom' : ''
+ @tap.stop.prevent
+ :style="[style]"
+ <view class="u-mode-center-box" @tap.stop.prevent @touchmove.stop.prevent v-if="mode == 'center'" :style="[centerStyle]">
+ @click="close"
+ v-if="closeable"
+ class="u-close"
+ :class="['u-close--' + closeIconPos]"
+ :name="closeIcon"
+ :color="closeIconColor"
+ :size="closeIconSize"
+ <scroll-view class="u-drawer__scroll-view" scroll-y="true">
+ </scroll-view>
+ <scroll-view class="u-drawer__scroll-view" scroll-y="true" v-else>
+ <view @tap="close" class="u-close" :class="['u-close--' + closeIconPos]">
+ v-if="mode != 'center' && closeable"
+ * popup 弹窗
+ * @description 弹出层容器,用于展示弹窗、信息提示等内容,支持上、下、左、右和中部弹出。组件只提供容器,内部内容由用户自定义
+ * @tutorial https://www.uviewui.com/components/popup.html
+ * @property {String} mode 弹出方向(默认left)
+ * @property {Stringr | Number} length mode=left | 见官网说明(默认auto)
+ * @property {Boolean} zoom 是否开启缩放动画,只在mode为center时有效(默认true)
+ * @property {Boolean} mask-close-able 点击遮罩是否可以关闭弹出层(默认true)
+ * @property {Object} custom-style 用户自定义样式
+ * @property {Stringr | Number} negative-top 中部弹出时,往上偏移的值
+ * @property {Numberr | String} border-radius 弹窗圆角值(默认0)
+ * @property {Numberr | String} z-index 弹出内容的z-index值(默认1075)
+ * @property {Boolean} closeable 是否显示关闭图标(默认false)
+ * @property {String} close-icon 关闭图标的名称,只能uView的内置图标
+ * @property {String} close-icon-pos 自定义关闭图标位置(默认top-right)
+ * @property {String} close-icon-color 关闭图标的颜色(默认#909399)
+ * @property {Number | String} close-icon-size 关闭图标的大小,单位rpx(默认30)
+ * @event {Function} open 弹出层打开
+ * @event {Function} close 弹出层收起
+ * @example <u-popup v-model="show"><view>出淤泥而不染,濯清涟而不妖</view></u-popup>
+ name: 'u-popup',
+ * 显示状态
+ * 弹出方向,left|right|top|bottom|center
+ * 是否显示遮罩
+ // 抽屉的宽度(mode=left|right),或者高度(mode=top|bottom),单位rpx,或者"auto"
+ // 或者百分比"50%",表示由内容撑开高度或者宽度
+ // 是否开启缩放动画,只在mode=center时有效
+ // 此为内部参数,不在文档对外使用,为了解决Picker和keyboard等融合了弹窗的组件
+ // 对v-model双向绑定多层调用造成报错不能修改props值的问题
+ popup: {
+ // 显示显示弹窗的圆角,单位rpx
+ // 是否显示关闭图标
+ // 关闭图标的名称,只能uView的内置图标
+ default: 'close'
+ // 自定义关闭图标位置,top-left为左上角,top-right为右上角,bottom-left为左下角,bottom-right为右下角
+ closeIconPos: {
+ default: 'top-right'
+ // 关闭图标的颜色
+ closeIconColor: {
+ // 关闭图标的大小,单位rpx
+ closeIconSize: {
+ // 宽度,只对左,右,中部弹出时起作用,单位rpx,或者"auto"
+ // 或者百分比"50%",表示由内容撑开高度或者宽度,优先级高于length参数
+ // 高度,只对上,下,中部弹出时起作用,单位rpx,或者"auto"
+ // 给一个负的margin-top,往上偏移,避免和键盘重合的情况,仅在mode=center时有效
+ // 遮罩的样式,一般用于修改遮罩的透明度
+ maskCustomStyle: {
+ visibleSync: false,
+ showDrawer: false,
+ timer: null,
+ closeFromInner: false, // value的值改变,是发生在内部还是外部
+ // 根据mode的位置,设定其弹窗的宽度(mode = left|right),或者高度(mode = top|bottom)
+ style() {
+ // 如果是左边或者上边弹出时,需要给translate设置为负值,用于隐藏
+ if (this.mode == 'left' || this.mode == 'right') {
+ width: this.width ? this.getUnitValue(this.width) : this.getUnitValue(this.length),
+ height: '100%',
+ transform: `translate3D(${this.mode == 'left' ? '-100%' : '100%'},0px,0px)`
+ } else if (this.mode == 'top' || this.mode == 'bottom') {
+ width: '100%',
+ height: this.height ? this.getUnitValue(this.height) : this.getUnitValue(this.length),
+ transform: `translate3D(0px,${this.mode == 'top' ? '-100%' : '100%'},0px)`
+ style.zIndex = this.uZindex;
+ // 如果用户设置了borderRadius值,添加弹窗的圆角
+ if (this.borderRadius) {
+ switch (this.mode) {
+ style.borderRadius = `0 ${this.borderRadius}rpx ${this.borderRadius}rpx 0`;
+ case 'top':
+ style.borderRadius = `0 0 ${this.borderRadius}rpx ${this.borderRadius}rpx`;
+ style.borderRadius = `${this.borderRadius}rpx 0 0 ${this.borderRadius}rpx`;
+ case 'bottom':
+ style.borderRadius = `${this.borderRadius}rpx ${this.borderRadius}rpx 0 0`;
+ default:
+ // 不加可能圆角无效
+ style.overflow = 'hidden';
+ // 中部弹窗的特有样式
+ centerStyle() {
+ style.width = this.width ? this.getUnitValue(this.width) : this.getUnitValue(this.length);
+ // 中部弹出的模式,如果没有设置高度,就用auto值,由内容撑开高度
+ style.height = this.height ? this.getUnitValue(this.height) : 'auto';
+ style.marginTop = `-${this.$u.addUnit(this.negativeTop)}`;
+ style.borderRadius = `${this.borderRadius}rpx`;
+ // 计算整理后的z-index值
+ uZindex() {
+ value(val) {
+ if (val) {
+ this.open();
+ } else if(!this.closeFromInner) {
+ this.closeFromInner = false;
+ // 组件渲染完成时,检查value是否为true,如果是,弹出popup
+ this.value && this.open();
+ // 判断传入的值,是否带有单位,如果没有,就默认用rpx单位
+ getUnitValue(val) {
+ if(/(%|px|rpx|auto)$/.test(val)) return val;
+ else return val + 'rpx'
+ // 遮罩被点击
+ // 标记关闭是内部发生的,否则修改了value值,导致watch中对value检测,导致再执行一遍close
+ // 造成@close事件触发两次
+ this.closeFromInner = true;
+ this.change('showDrawer', 'visibleSync', false);
+ // 中部弹出时,需要.u-drawer-content将居中内容,此元素会铺满屏幕,点击需要关闭弹窗
+ // 让其只在mode=center时起作用
+ modeCenterClose(mode) {
+ if (mode != 'center' || !this.maskCloseAble) return;
+ open() {
+ this.change('visibleSync', 'showDrawer', true);
+ // 此处的原理是,关闭时先通过动画隐藏弹窗和遮罩,再移除整个组件
+ // 打开时,先渲染组件,延时一定时间再让遮罩和弹窗的动画起作用
+ change(param1, param2, status) {
+ // 如果this.popup为false,意味着为picker,actionsheet等组件调用了popup组件
+ if (this.popup == true) {
+ this.$emit('input', status);
+ this[param1] = status;
+ if(status) {
+ // #ifdef H5 || MP
+ this[param2] = status;
+ this.$emit(status ? 'open' : 'close');
+ // #ifndef H5 || MP
+.u-drawer {
+ /* #ifndef APP-NVUE */
+.u-drawer-content {
+.u-drawer__scroll-view {
+.u-drawer-left {
+.u-drawer-right {
+.u-drawer-top {
+.u-drawer-bottom {
+.u-drawer-center {
+ z-index: 99999;
+.u-mode-center-box {
+ min-width: 100rpx;
+ min-height: 100rpx;
+.u-drawer-content-visible.u-drawer-center {
+ transform: scale(1);
+.u-animation-zoom {
+ transform: scale(1.15);
+.u-drawer-content-visible {
+ transform: translate3D(0px, 0px, 0px) !important;
+.u-close {
+ z-index: 3;
+.u-close--top-left {
+ top: 30rpx;
+ left: 30rpx;
+.u-close--top-right {
+ right: 30rpx;
+.u-close--bottom-left {
+ bottom: 30rpx;
+.u-close--bottom-right {
@@ -0,0 +1,105 @@
+ <view class="u-radio-group u-clearfix">
+ * radioRroup 单选框父组件
+ * @description 单选框用于有一个选择,用户只能选择其中一个的场景。搭配u-radio使用
+ * @tutorial https://www.uviewui.com/components/radio.html
+ * @property {Boolean} disabled 是否禁用所有radio(默认false)
+ * @property {String} active-color 选中时的颜色,应用到所有子Radio组件(默认#2979ff)
+ * @property {Boolean} wrap 是否每个radio都换行(默认false)
+ * @event {Function} change 任一个radio状态发生变化时触发
+ * @example <u-radio-group v-model="value"></u-radio-group>
+ name: "u-radio-group",
+ // 是否禁用所有单选框
+ // 匹配某一个radio组件,如果某个radio的name值等于此值,那么这个radio就被会选中
+ radioGroup: this
+ // 该方法有子组件radio调用,当一个radio被选中的时候,给父组件设置value值(props传递的value)
+ setValue(val) {
+ // 通过emit事件,设置父组件通过v-model双向绑定的值
+ this.$emit('input', val);
+ // 发出事件,用于在表单组件中嵌入radio的情况,进行验证
+ this.dispatch('u-form-item', 'on-form-change', val);
+ .u-radio-group {
@@ -0,0 +1,237 @@
+ <view class="u-radio" :style="[radioStyle]">
+ <view class="u-radio__icon-wrap" @tap="toggle" :class="[iconClass]" :style="[iconStyle]">
+ name="checkbox-mark"
+ :size="elIconSize"
+ :color="iconColor"/>
+ <view class="u-radio__label" @tap="onClickLabel" :style="{
+ * radio 单选框
+ * @description 单选框用于有一个选择,用户只能选择其中一个的场景。搭配u-radio-group使用
+ * @property {String Number} icon-size 图标大小,单位rpx(默认24)
+ * @property {String Number} name radio组件的标示符
+ * @property {String} shape 形状,见上方说明(默认circle)
+ * @property {Boolean} disabled 是否禁用(默认false)
+ * @property {Boolean} label-disabled 点击文本是否可以操作radio(默认true)
+ * @property {String} active-color 选中时的颜色,如设置parent的active-color将失效
+ * @event {Function} change 某个radio状态发生变化时触发(选中状态)
+ * @example <u-radio :label-disabled="false">门掩黄昏,无计留春住</u-radio>
+ name: "u-radio",
+ // radio的名称
+ // 选中状态下的颜色,如设置此值,将会覆盖parent的activeColor值
+ parentDisabled: false
+ // this.parentDisabled = this.parent.disabled;
+ this.parent = this.$u.$parent.call(this, 'u-radio-group');
+ // 是否禁用,如果父组件u-raios-group禁用的话,将会忽略子组件的配置
+ elDisabled() {
+ elLabelDisabled() {
+ elSize() {
+ elIconSize() {
+ return this.shape ? this.shape : (this.parent ? this.parent.shape : 'circle');
+ // 设置radio的状态,要求radio的name等于parent的value时才为选中状态
+ if (this.elActiveColor && this.name == this.parent.value && !this.elDisabled) {
+ style.width = this.$u.addUnit(this.elSize);
+ style.height = this.$u.addUnit(this.elSize);
+ return this.name == this.parent.value ? '#ffffff' : 'transparent';
+ classes.push('u-radio__icon-wrap--' + this.elShape);
+ if (this.name == this.parent.value) classes.push('u-radio__icon-wrap--checked');
+ if (this.elDisabled) classes.push('u-radio__icon-wrap--disabled');
+ if (this.name == this.parent.value && this.elDisabled) classes.push(
+ 'u-radio__icon-wrap--disabled--checked');
+ radioStyle() {
+ if (this.parent.width) {
+ if (this.parent.wrap) {
+ if (!this.elLabelDisabled && !this.elDisabled) {
+ this.parent.setValue(this.name);
+ if (!this.elDisabled) {
+ // u-radio的name不等于父组件的v-model的值时(意味着未选中),才发出事件,避免多次点击触发事件
+ if(this.parent.value != this.name) this.$emit('change', this.name);
+ .u-radio {
+ border-radius: 3px;
+ background-color: #2979ff;
+ border-color: #2979ff;
@@ -0,0 +1,214 @@
+ <view class="u-rate" :id="elId" @touchmove.stop.prevent="touchMove">
+ <view class="u-star-wrap" v-for="(item, index) in count" :key="index" :class="[elClass]">
+ :name="activeIndex > index ? activeIcon : inactiveIcon"
+ @click="click(index + 1, $event)"
+ :color="activeIndex > index ? activeColor : inactiveColor"
+ :custom-style="{
+ padding: `0 ${gutter / 2 + 'rpx'}`
+ * rate 评分
+ * @description 该组件一般用于满意度调查,星型评分的场景
+ * @tutorial https://www.uviewui.com/components/rate.html
+ * @property {String Number} count 最多可选的星星数量(默认5)
+ * @property {String Number} current 默认选中的星星数量(默认0)
+ * @property {Boolean} disabled 是否禁止用户操作(默认false)
+ * @property {String Number} size 星星的大小,单位rpx(默认32)
+ * @property {String} inactive-color 未选中星星的颜色(默认#b2b2b2)
+ * @property {String} active-color 选中的星星颜色(默认#FA3534)
+ * @property {String} active-icon 选中时的图标名,只能为uView的内置图标(默认star-fill)
+ * @property {String} inactive-icon 未选中时的图标名,只能为uView的内置图标(默认star)
+ * @property {String} gutter 星星之间的距离(默认10)
+ * @property {String Number} min-count 最少选中星星的个数(默认0)
+ * @property {Boolean} allow-half 是否允许半星选择(默认false)
+ * @event {Function} change 选中的星星发生变化时触发
+ * @example <u-rate :count="count" :current="2"></u-rate>
+ name: 'u-rate',
+ // 用于v-model双向绑定选中的星星数量
+ // 1.4.5版新增
+ // 要显示的星星数量
+ default: 5
+ // 当前需要默认选中的星星(选中的个数)
+ // 1.4.5后通过value双向绑定,不再建议使用此参数
+ current: {
+ // 是否不可选中
+ // 星星的大小,单位rpx
+ // 未选中时的颜色
+ default: '#b2b2b2'
+ // 选中的颜色
+ default: '#FA3534'
+ // 星星之间的间距,单位rpx
+ gutter: {
+ default: 10
+ // 最少能选择的星星个数
+ minCount: {
+ // 是否允许半星(功能尚未实现)
+ allowHalf: {
+ // 选中时的图标(星星)
+ activeIcon: {
+ default: 'star-fill'
+ // 未选中时的图标(星星)
+ inactiveIcon: {
+ default: 'star'
+ // 生成一个唯一id,否则一个页面多个评分组件,会造成冲突
+ elClass: this.$u.guid(),
+ starBoxLeft: 0, // 评分盒子左边到屏幕左边的距离,用于滑动选择时计算距离
+ // 当前激活的星星的index,如果存在value,优先使用value,因为它可以双向绑定(1.4.5新增)
+ activeIndex: this.value != -1 ? this.value : this.current,
+ starWidth: 0, // 每个星星的宽度
+ starWidthArr: [] //每个星星最右边到组件盒子最左边的距离
+ current(val) {
+ this.activeIndex = val;
+ // 获取评分组件盒子的布局信息
+ getElRectById() {
+ // uView封装的获取节点的方法,详见文档
+ this.$u.getRect('#' + this.elId).then(res => {
+ this.starBoxLeft = res.left;
+ // 获取单个星星的尺寸
+ getElRectByClass() {
+ this.$u.getRect('.' + this.elClass).then(res => {
+ this.starWidth = res.width;
+ // 把每个星星右边到组件盒子左边的距离放入数组中
+ for (let i = 0; i < this.count; i++) {
+ this.starWidthArr[i] = (i + 1) * this.starWidth;
+ // 手指滑动
+ if (this.disabled) {
+ if (!e.changedTouches[0]) {
+ const movePageX = e.changedTouches[0].pageX;
+ // 滑动点相对于评分盒子左边的距离
+ const distance = movePageX - this.starBoxLeft;
+ // 如果滑动到了评分盒子的左边界,就设置为0星
+ if (distance <= 0) {
+ this.activeIndex = 0;
+ // 滑动的距离,相当于多少颗星星
+ let index = Math.ceil(distance / this.starWidth);
+ this.activeIndex = index > this.count ? this.count : index;
+ // 对最少颗星星的限制
+ if (this.activeIndex < this.minCount) this.activeIndex = this.minCount;
+ // 通过点击,直接选中
+ click(index, e) {
+ // 半星选择,尚未实现
+ if (this.allowHalf) {
+ // 对第一个星星特殊处理,只有一个的时候,点击可以取消,否则无法作0星评价
+ if (index == 1) {
+ if (this.activeIndex == 1) this.activeIndex = 0;
+ else this.activeIndex = 1;
+ this.activeIndex = index;
+ // 发出事件
+ this.$emit('change', this.activeIndex);
+ // 同时修改双向绑定的value的值
+ if(this.value != -1) {
+ this.$emit('input', this.activeIndex);
+ this.getElRectById();
+ this.getElRectByClass();
+.u-rate {
+ display: -webkit-inline-flex;
@@ -0,0 +1,179 @@
+ <view class="u-content" :class="[elId]" :style="{
+ height: isLongContent && !showMore ? showHeight + 'rpx' : 'auto',
+ textIndent: this.textIndent
+ <view @tap="toggleReadMore" v-if="isLongContent" class="u-content__showmore-wrap"
+ :class="{ 'u-content__show-more': showMore }"
+ :style="[innerShadowStyle]">
+ <text class="u-content__showmore-wrap__readmore-btn" :style="{
+ {{ showMore ? openText : closeText }}
+ <view class="u-content__showmore-wrap__readmore-btn__icon u-flex">
+ <u-icon :color="color" :size="fontSize" :name="showMore ? 'arrow-up' : 'arrow-down'"></u-icon>
+ * readMore 阅读更多
+ * @description 该组件一般用于内容较长,预先收起一部分,点击展开全部内容的场景。
+ * @tutorial https://www.uviewui.com/components/readMore.html
+ * @property {String Number} show-height 内容超出此高度才会显示展开全文按钮,单位rpx(默认400)
+ * @property {Boolean} toggle 展开后是否显示收起按钮(默认false)
+ * @property {String} close-text 关闭时的提示文字(默认“展开阅读全文”)
+ * @property {String} text-indent 段落首行缩进的字符个数(默认2em)
+ * @property {String} open-text 展开时的提示文字(默认“收起”)
+ * @property {String} color 提示文字的颜色(默认#2979ff)
+ * @example <u-read-more><rich-text :nodes="content"></rich-text></u-read-more>
+ name: "u-read-more",
+ // 默认的显示占位高度,单位为rpx
+ showHeight: {
+ // 展开后是否显示"收起"按钮
+ toggle: {
+ // 关闭时的提示文字
+ default: '展开阅读全文'
+ // 展开时的提示文字
+ openText: {
+ default: '收起'
+ // 提示的文字颜色
+ // 提示文字的大小
+ // 是否显示阴影
+ shadowStyle: {
+ backgroundImage: "linear-gradient(-180deg, rgba(255, 255, 255, 0) 0%, #fff 80%)",
+ paddingTop: "300rpx",
+ marginTop: "-300rpx"
+ // 段落首行缩进的字符个数
+ textIndent: {
+ default: '2em'
+ // open和close事件时,将此参数返回在回调参数中
+ paramsChange(val) {
+ paramsChange() {
+ return `${this.toggle}-${this.showHeight}`;
+ // 展开后无需阴影,收起时才需要阴影样式
+ innerShadowStyle() {
+ if (this.showMore) return {};
+ else return this.shadowStyle
+ isLongContent: false, // 是否需要隐藏一部分内容
+ showMore: false, // 当前隐藏与显示的状态,true-显示,false-收起
+ elId: this.$u.guid(), // 生成唯一class
+ this.$uGetRect('.' + this.elId).then(res => {
+ // 判断高度,如果真实内容高度大于占位高度,则显示收起与展开的控制按钮
+ if (res.height > uni.upx2px(this.showHeight)) {
+ this.isLongContent = true;
+ this.showMore = false;
+ // 展开或者收起
+ toggleReadMore() {
+ this.showMore = !this.showMore;
+ // 如果toggle为false,隐藏"收起"部分的内容
+ if (this.toggle == false) this.isLongContent = false;
+ // 发出打开或者收齐的事件
+ this.$emit(this.showMore ? 'open' : 'close', this.index);
+ .u-content {
+ &__show-more {
+ padding-top: 0;
+ &__showmore-wrap {
+ padding-bottom: 26rpx;
+ &__readmore-btn {
@@ -0,0 +1,267 @@
+ v-if="show"
+ <view class="u-direction-row">
+ <view class="u-notice-box" id="u-notice-box">
+ class="u-notice-content"
+ id="u-notice-content"
+ animationDuration: animationDuration,
+ animationPlayState: animationPlayState,
+ <text class="u-notice-text" @tap="click" :style="[textStyle]"
+ :class="['u-type-' + type]">{{showText}}</text>
+ // 显示的主题,success|error|primary|info|warning|none
+ // none主题默认为透明背景,黑色(contentColor)字体
+ textWidth: 0, // 滚动的文字宽度
+ boxWidth: 0, // 供文字滚动的父盒子的宽度,和前者一起为了计算滚动速度
+ animationDuration: '10s', // 动画执行时间
+ animationPlayState: 'paused', // 动画的开始和结束执行
+ showText: '' // 显示的文本
+ this.showText = val.join(',');
+ this.initSize();
+ playState(val) {
+ if(val == 'play') this.animationPlayState = 'running';
+ else this.animationPlayState = 'paused';
+ speed(val) {
+ initSize() {
+ let query = [],
+ boxWidth = 0,
+ textWidth = 0;
+ let textQuery = new Promise((resolve, reject) => {
+ uni.createSelectorQuery()
+ .in(this)
+ .select(`#u-notice-content`)
+ .boundingClientRect()
+ .exec(ret => {
+ this.textWidth = ret[0].width;
+ resolve();
+ query.push(textQuery);
+ Promise.all(query).then(() => {
+ // 根据t=s/v(时间=路程/速度),这里为何不需要加上#u-notice-box的宽度,因为中设置了.u-notice-content样式中设置了padding-left: 100%
+ // 恰巧计算出来的结果中已经包含了#u-notice-box的宽度
+ this.animationDuration = `${this.textWidth / uni.upx2px(this.speed)}s`;
+ // 这里必须这样开始动画,否则在APP上动画速度不会改变(HX版本2.4.6,IOS13)
+ this.animationPlayState = 'paused';
+ if(this.playState == 'play' && this.autoplay) this.animationPlayState = 'running';
+ }, 10);
+.u-notice-text {
+ word-break: keep-all;
+ white-space: nowrap
+ <view class="u-row" :style="{
+ justifyContent: uJustify
+ @tap.stop.prevent="click"
+ * row 行布局
+ * @description 通过基础的 12 分栏,迅速简便地创建布局。
+ * @tutorial https://www.uviewui.com/components/layout.html#row-props
+ * @property {String Number} gutter 栅格间隔,左右各为此值的一半,单位rpx(默认0)
+ * @property {String} justify 水平排列方式(微信小程序暂不支持)默认(start(或flex-start))
+ * @property {String} align 垂直排列方式(默认center)
+ * @example <u-row gutter="16"></u-row>
+ name: "u-row",
+ // 给col添加间距,左右边距各占一半
+ gutter: this.gutter
+ .u-row {
+ // 由于微信小程序编译后奇怪的页面结构,只能使用float布局实现,flex无法实现
+ /* #ifndef MP-WEIXIN || MP-QQ */
+ .u-row:after {
+ display: table;
+ clear: both;
+ content: "";
@@ -0,0 +1,342 @@
+ <view class="u-search" @tap="clickHandler" :style="{
+ class="u-content"
+ borderRadius: shape == 'round' ? '100rpx' : '10rpx',
+ border: borderStyle,
+ height: height + 'rpx'
+ <u-icon class="u-clear-icon" :size="30" :name="searchIcon" :color="searchIconColor ? searchIconColor : color"></u-icon>
+ confirm-type="search"
+ @blur="blur"
+ @confirm="search"
+ @input="inputChange"
+ @focus="getFocus"
+ :maxlength="getMaxlength"
+ placeholder-class="u-placeholder-class"
+ :placeholder-style="`color: ${placeholderColor}`"
+ type="text"
+ :style="[{
+ textAlign: inputAlign,
+ }, inputStyle]"
+ <view class="u-close-wrap" v-if="keyword && clearabled && focused" @tap="clear">
+ <u-icon class="u-clear-icon" name="close-circle-fill" size="34" color="#c0c4cc"></u-icon>
+ <view :style="[actionStyle]" class="u-action"
+ :class="[showActionBtn || show ? 'u-action-active' : '']"
+ @tap.stop.prevent="custom"
+ >{{ actionText }}</view>
+ * search 搜索框
+ * @description 搜索组件,集成了常见搜索框所需功能,用户可以一键引入,开箱即用。
+ * @tutorial https://www.uviewui.com/components/search.html
+ * @property {String} shape 搜索框形状,round-圆形,square-方形(默认round)
+ * @property {String} bg-color 搜索框背景颜色(默认#f2f2f2)
+ * @property {String} border-color 边框颜色,配置了颜色,才会有边框
+ * @property {String} placeholder 占位文字内容(默认“请输入关键字”)
+ * @property {Boolean} clearabled 是否启用清除控件(默认true)
+ * @property {Boolean} show-action 是否显示右侧控件(默认true)
+ * @property {String} action-text 右侧控件文字(默认“搜索”)
+ * @property {Object} action-style 右侧控件的样式,对象形式
+ * @property {String} input-align 输入框内容水平对齐方式(默认left)
+ * @property {Object} input-style 自定义输入框样式,对象形式
+ * @property {Boolean} disabled 是否启用输入框(默认false)
+ * @property {String} search-icon-color 搜索图标的颜色,默认同输入框字体颜色
+ * @property {String} color 输入框字体颜色(默认#606266)
+ * @property {String} placeholder-color placeholder的颜色(默认#909399)
+ * @property {String} search-icon 输入框左边的图标,可以为uView图标名称或图片路径
+ * @property {String} margin 组件与其他上下左右元素之间的距离,带单位的字符串形式,如"30rpx"
+ * @property {Boolean} animation 是否开启动画,见上方说明(默认false)
+ * @property {String} value 输入框初始值
+ * @property {String | Number} maxlength 输入框最大能输入的长度,-1为不限制长度
+ * @property {Boolean} input-style input输入框的样式,可以定义文字颜色,大小等,对象形式
+ * @property {String | Number} height 输入框高度,单位rpx(默认64)
+ * @event {Function} change 输入框内容发生变化时触发
+ * @event {Function} search 用户确定搜索时触发,用户按回车键,或者手机键盘右下角的"搜索"键时触发
+ * @event {Function} custom 用户点击右侧控件时触发
+ * @event {Function} clear 用户点击清除按钮时触发
+ * @example <u-search placeholder="日照香炉生紫烟" v-model="keyword"></u-search>
+ name: "u-search",
+ // 搜索框形状,round-圆形,square-方形
+ default: 'round'
+ // 搜索框背景色,默认值#f2f2f2
+ default: '#f2f2f2'
+ // 占位提示文字
+ default: '请输入关键字'
+ // 是否启用清除控件
+ clearabled: {
+ // 是否自动聚焦
+ // 是否在搜索框右侧显示取消按钮
+ showAction: {
+ // 右边控件的样式
+ actionStyle: {
+ // 取消按钮文字
+ actionText: {
+ default: '搜索'
+ // 输入框内容对齐方式,可选值为 left|center|right
+ // 是否启用输入框
+ // 开启showAction时,是否在input获取焦点时才显示
+ animation: {
+ // 边框颜色,只要配置了颜色,才会有边框
+ // 输入框的初始化内容
+ // 搜索框高度,单位rpx
+ default: 64
+ // input输入框的样式,可以定义文字颜色,大小等,对象形式
+ inputStyle: {
+ // 输入框最大能输入的长度,-1为不限制长度(来自uniapp文档)
+ // 搜索图标的颜色,默认同输入框字体颜色
+ searchIconColor: {
+ // 输入框字体颜色
+ // placeholder的颜色
+ placeholderColor: {
+ // 组件与其他上下左右元素之间的距离,带单位的字符串形式,如"30rpx"、"30rpx 20rpx"等写法
+ // 左边输入框的图标,可以为uView图标名称或图片路径
+ searchIcon: {
+ default: 'search'
+ keyword: '',
+ showClear: false, // 是否显示右边的清除图标
+ // 标记input当前状态是否处于聚焦中,如果是,才会显示右侧的清除控件
+ focused: this.focus
+ // 绑定输入框的值
+ // inputValue: this.value
+ keyword(nVal) {
+ // 双向绑定值,让v-model绑定的值双向变化
+ this.$emit('input', nVal);
+ // 触发change事件,事件效果和v-model双向绑定的效果一样,让用户多一个选择
+ this.$emit('change', nVal);
+ handler(nVal) {
+ this.keyword = nVal;
+ showActionBtn() {
+ if (!this.animation && this.showAction) return true;
+ // 样式,根据用户传入的颜色值生成,如果不传入,默认为none
+ borderStyle() {
+ if (this.borderColor) return `1px solid ${this.borderColor}`;
+ else return 'none';
+ // 将maxlength转为数值
+ getMaxlength() {
+ // 目前HX2.6.9 v-model双向绑定无效,故监听input事件获取输入框内容的变化
+ inputChange(e) {
+ this.keyword = e.detail.value;
+ // 清空输入
+ // 也可以作为用户通过this.$refs形式调用清空输入框内容
+ clear() {
+ this.keyword = '';
+ // 延后发出事件,避免在父组件监听clear事件时,value为更新前的值(不为空)
+ this.$emit('clear');
+ // 确定搜索
+ search(e) {
+ this.$emit('search', e.detail.value);
+ // 收起键盘
+ // 点击右边自定义按钮的事件
+ custom() {
+ this.$emit('custom', this.keyword);
+ // 获取焦点
+ getFocus() {
+ // 开启右侧搜索按钮展开的动画效果
+ if (this.animation && this.showAction) this.show = true;
+ this.$emit('focus', this.keyword);
+ // 失去焦点
+ blur() {
+ this.show = false;
+ this.$emit('blur', this.keyword);
+ // 点击搜索框,只有disabled=true时才发出事件,因为禁止了输入,意味着是想跳转真正的搜索页
+ clickHandler() {
+ if(this.disabled) this.$emit('click');
+.u-search {
+.u-content {
+ padding: 0 18rpx;
+ margin: 0 10rpx;
+.u-close-wrap {
+ width: 40rpx;
+.u-placeholder-class {
+.u-action {
+.u-action-active {
@@ -0,0 +1,154 @@
+ <view class="u-section">
+ <view class="u-section__title" :style="{
+ paddingLeft: showLine ? (fontSize * 0.7) + 'rpx' : 0
+ 'u-section--line': showLine
+ <view class="u-section__title__icon-wrap u-flex" :style="[lineStyle]" v-if="showLine">
+ <u-icon top="0" name="column-line" :size="fontSize * 1.25" bold :color="lineColor ? lineColor : color"></u-icon>
+ <text class="u-flex u-section__title__text">{{title}}</text>
+ <view class="u-section__right-info" v-if="right || $slots.right" :style="{
+ color: subColor
+ }" @tap="rightClick">
+ <slot name="right" v-if="$slots.right" />
+ {{subTitle}}
+ <view class="u-section__right-info__icon-arrow u-flex" v-if="arrow">
+ <u-icon name="arrow-right" size="24" :color="subColor"></u-icon>
+ * section 查看更多
+ * @description 该组件一般用于分类信息有很多,但是限于篇幅只能列出一部分,让用户通过"查看更多"获得更多信息的场景,实际效果见演示。
+ * @tutorial https://www.uviewui.com/components/section.html
+ * @property {String} title 左边主标题
+ * @property {String} sub-title 右边副标题(默认更多)
+ * @property {Boolean} right 是否显示右边的内容(默认true)
+ * @property {Boolean} showLine 是否显示左边的竖条(默认true)
+ * @property {Boolean} arrow 是否显示右边箭头(默认true)
+ * @property {String Number} font-size 主标题的字体大小(默认28)
+ * @property {Boolean} bold 主标题是否加粗(默认true)
+ * @property {String} color 主标题颜色(默认#303133)
+ * @event {Function} click 组件右侧的内容被点击时触发,用于跳转"更多"
+ * @example <u-section title="今日热门" :right="false"></u-section>
+ name: "u-section",
+ // 标题信息
+ // 右边副标题内容
+ default: '更多'
+ // 是否显示右边的内容
+ // 主标题是否加粗
+ // 主标题的颜色
+ // 右边副标题的颜色
+ subColor: {
+ // 是否显示左边的竖条
+ showLine: {
+ // 左边竖线的颜色
+ // 是否显示右边箭头
+ // 左边竖条的样式
+ // 由于安卓和iOS的,需要稍微调整绝对定位的top值,才能让左边的竖线和右边的文字垂直居中
+ // 由于竖线为字体图标,具有比实际线宽更宽的宽度,所以也需要根据字体打下动态调整
+ left: -(Number(this.fontSize) * 0.9) + 'rpx',
+ top: -(Number(this.fontSize) * (this.$u.os() == 'ios' ? 0.14 : 0.15)) + 'rpx',
+ rightClick() {
+ .u-section {
+ padding-left: 20rpx;
+ &__right-info {
+ &__icon-arrow {
@@ -0,0 +1,417 @@
+ <view class="u-select">
+ <!-- <view class="u-select__action" :class="{
+ 'u-select--border': border
+ }" @tap.stop="selectHandler">
+ <view class="u-select__action__icon" :class="{
+ 'u-select__action__icon--reverse': value == true
+ </view> -->
+ <view class="u-select__header" @touchmove.stop.prevent="">
+ class="u-select__header__cancel u-select__header__btn"
+ hover-class="u-hover-class"
+ <view class="u-select__header__title">
+ class="u-select__header__confirm u-select__header__btn"
+ <view class="u-select__body">
+ <picker-view @change="columnChange" class="u-select__body__picker-view" :value="defaultSelector" @pickstart="pickstart" @pickend="pickend">
+ <picker-view-column v-for="(item, index) in columnData" :key="index">
+ <view class="u-select__body__picker-view__item" v-for="(item1, index1) in item" :key="index1">
+ <view class="u-line-1">{{ item1[labelName] }}</view>
+ * select 列选择器
+ * @description 此选择器用于单列,多列,多列联动的选择场景。(从1.3.0版本起,不建议使用Picker组件的单列和多列模式,Select组件是专门为列选择而构造的组件,更简单易用。)
+ * @tutorial http://uviewui.com/components/select.html
+ * @property {String} mode 模式选择,"single-column"-单列模式,"mutil-column"-多列模式,"mutil-column-auto"-多列联动模式
+ * @property {Array} list 列数据,数组形式,见官网说明
+ * @property {Boolean} v-model 布尔值变量,用于控制选择器的弹出与收起
+ * @property {String} confirm-color 确认按钮的颜色(默认#2979ff)
+ * @property {String} default-value 提供的默认选中的下标,见官网说明
+ * @property {Boolean} mask-close-able 是否允许通过点击遮罩关闭Picker(默认true)
+ * @property {String} value-name 自定义list数据的value属性名 1.3.6
+ * @property {String} label-name 自定义list数据的label属性名 1.3.6
+ * @property {String} child-name 自定义list数据的children属性名,只对多列联动模式有效 1.3.7
+ * @example <u-select v-model="show" :list="list"></u-select>
+ // 列数据
+ // 提供的默认选中的下标
+ defaultValue: {
+ // 模式选择,single-column-单列,mutil-column-多列,mutil-column-auto-多列联动
+ default: 'single-column'
+ // 自定义value属性名
+ valueName: {
+ default: 'value'
+ // 自定义label属性名
+ labelName: {
+ default: 'label'
+ // 自定义多列联动模式的children属性名
+ childName: {
+ default: 'children'
+ // 用于列改变时,保存当前的索引,下一次变化时比较得出是哪一列发生了变化
+ defaultSelector: [0],
+ // picker-view的数据
+ columnData: [],
+ // 每次队列发生变化时,保存选择的结果
+ selectValue: [],
+ // 上一次列变化时的index
+ lastSelectIndex: [],
+ // 列数
+ columnNum: 0,
+ // 列是否还在滑动中,微信小程序如果在滑动中就点确定,结果可能不准确
+ moving: false
+ // 在select弹起的时候,重新初始化所有数据
+ if(val) setTimeout(() => this.init(), 10);
+ this.setColumnNum();
+ this.setDefaultSelector();
+ this.setColumnData();
+ this.setSelectValue();
+ // 获取默认选中列下标
+ setDefaultSelector() {
+ // 如果没有传入默认选中的值,生成长度为columnNum,用0填充的数组
+ this.defaultSelector = this.defaultValue.length == this.columnNum ? this.defaultValue : Array(this.columnNum).fill(0);
+ this.lastSelectIndex = this.$u.deepClone(this.defaultSelector);
+ // 计算列数
+ setColumnNum() {
+ // 单列的列数为1
+ if(this.mode == 'single-column') this.columnNum = 1;
+ // 多列时,this.list数组长度就是列数
+ else if(this.mode == 'mutil-column') this.columnNum = this.list.length;
+ // 多列联动时,通过历遍this.list的第一个元素,得出有多少列
+ else if(this.mode == 'mutil-column-auto') {
+ let num = 1;
+ let column = this.list;
+ // 只要有元素并且第一个元素有children属性,继续历遍
+ while(column[0][this.childName]) {
+ column = column[0] ? column[0][this.childName] : {};
+ num ++;
+ this.columnNum = num;
+ // 获取需要展示在picker中的列数据
+ setColumnData() {
+ let data = [];
+ this.selectValue = [];
+ if(this.mode == 'mutil-column-auto') {
+ // 获得所有数据中的第一个元素
+ let column = this.list[this.defaultSelector.length ? this.defaultSelector[0] : 0];
+ // 通过循环所有的列数,再根据设定列的数组,得出当前需要渲染的整个列数组
+ for (let i = 0; i < this.columnNum; i++) {
+ // 第一列默认为整个list数组
+ if (i == 0) {
+ data[i] = this.list;
+ column = column[this.childName];
+ // 大于第一列时,判断是否有默认选中的,如果没有就用该列的第一项
+ data[i] = column;
+ column = column[this.defaultSelector[i]][this.childName];
+ } else if(this.mode == 'single-column') {
+ data[0] = this.list;
+ data = this.list;
+ this.columnData = data;
+ // 获取默认选中的值,如果没有设置defaultValue,就默认选中每列的第一个
+ setSelectValue() {
+ let tmp = null;
+ for(let i = 0; i < this.columnNum; i++) {
+ tmp = this.columnData[i][this.defaultSelector[i]];
+ let data = {
+ value: tmp ? tmp[this.valueName] : null,
+ label: tmp ? tmp[this.labelName] : null
+ // 判断是否存在额外的参数,如果存在,就返回
+ if(tmp && tmp.extra) data.extra = tmp.extra;
+ this.selectValue.push(data)
+ // 列选项
+ columnChange(e) {
+ let columnIndex = e.detail.value;
+ // 由于后面是需要push进数组的,所以需要先清空数组
+ this.lastSelectIndex.map((val, idx) => {
+ if (val != columnIndex[idx]) index = idx;
+ this.defaultSelector = columnIndex;
+ for (let i = index + 1; i < this.columnNum; i++) {
+ // 当前变化列的下一列的数据,需要获取上一列的数据,同时需要指定是上一列的第几个的children,再往后的
+ // 默认是队列的第一个为默认选项
+ this.columnData[i] = this.columnData[i - 1][i - 1 == index ? columnIndex[index] : 0][this.childName];
+ // 改变的列之后的所有列,默认选中第一个
+ this.defaultSelector[i] = 0;
+ // 在历遍的过程中,可能由于上一步修改this.columnData,导致产生连锁反应,程序触发columnChange,会有多次调用
+ // 只有在最后一次数据稳定后的结果是正确的,此前的历遍中,可能会产生undefined,故需要判断
+ columnIndex.map((item, index) => {
+ let data = this.columnData[index][columnIndex[index]];
+ let tmp = {
+ value: data ? data[this.valueName] : null,
+ label: data ? data[this.labelName] : null,
+ // 判断是否有需要额外携带的参数
+ if(data && data.extra !== undefined) tmp.extra = data.extra;
+ this.selectValue.push(tmp);
+ // 保存这一次的结果,用于下次列发生变化时作比较
+ this.lastSelectIndex = columnIndex;
+ let data = this.columnData[0][columnIndex[0]];
+ // 初始默认选中值
+ } else if(this.mode == 'mutil-column') {
+ // 点击确定或者取消
+ if (event) this.$emit(event, this.selectValue);
+ selectHandler() {
+.u-select {
+ height: $u-form-item-height;
+ transform: rotate(-180deg) translateY(50%);
+ &__hader {
+ &__picker-view {
+ <view v-if="loading" :style="{
+ width: windowWinth + 'px',
+ height: windowHeight + 'px',
+ position: 'absolute',
+ left: left + 'px',
+ top: top + 'px',
+ zIndex: 9998,
+ overflow: 'hidden'
+ @touchmove.stop.prevent>
+ <view v-for="(item, index) in RectNodes" :key="$u.guid()" :class="[animation ? 'skeleton-fade' : '']" :style="{
+ width: item.width + 'px',
+ height: item.height + 'px',
+ backgroundColor: elColor,
+ left: (item.left - left) + 'px',
+ top: (item.top - top) + 'px'
+ }"></view>
+ <view v-for="(item, index) in circleNodes" :key="$u.guid()" :class="animation ? 'skeleton-fade' : ''" :style="{
+ borderRadius: item.width/2 + 'px',
+ <view v-for="(item, index) in filletNodes" :key="$u.guid()" :class="animation ? 'skeleton-fade' : ''" :style="{
+ * skeleton 骨架屏
+ * @description 骨架屏一般用于页面在请求远程数据尚未完成时,页面用灰色块预显示本来的页面结构,给用户更好的体验。
+ * @tutorial https://www.uviewui.com/components/skeleton.html
+ * @property {String} el-color 骨架块状元素的背景颜色(默认#e5e5e5)
+ * @property {String} bg-color 骨架组件背景颜色(默认#ffffff)
+ * @property {Boolean} animation 骨架块是否显示动画效果(默认false)
+ * @property {String Number} border-radius u-skeleton-fillet类名元素,对应的骨架块的圆角大小,单位rpx(默认10)
+ * @property {Boolean} loading 是否显示骨架组件,请求完成后,将此值设置为false(默认true)
+ * @example <u-skeleton :loading="true" :animation="true"></u-skeleton>
+ name: "u-skeleton",
+ // 需要渲染的元素背景颜色,十六进制或者rgb等都可以
+ elColor: {
+ default: '#e5e5e5'
+ // 整个骨架屏页面的背景颜色
+ // 是否显示加载动画
+ // 圆角值,只对类名为u-skeleton-fillet的元素生效,为数值,不带单位
+ default: "10"
+ // 是否显示骨架,true-显示,false-隐藏
+ windowWinth: 750, // 骨架屏宽度
+ windowHeight: 1500, // 骨架屏高度
+ filletNodes: [], // 圆角元素
+ circleNodes: [], // 圆形元素
+ RectNodes: [], // 矩形元素
+ top: 0,
+ left: 0,
+ // 查询各节点的信息
+ selecterQueryInfo() {
+ // 获取整个父组件容器的高度,当做骨架屏的高度
+ // 在微信小程序中,如果把骨架屏放入组件中使用的话,需要调in(this)上下文为父组件才有效
+ let query = '';
+ query = uni.createSelectorQuery().in(this.$parent);
+ query = uni.createSelectorQuery()
+ query.selectAll('.u-skeleton').boundingClientRect().exec((res) => {
+ this.windowHeight = res[0][0].height;
+ this.windowWinth = res[0][0].width;
+ this.top = res[0][0].bottom - res[0][0].height;
+ this.left = res[0][0].left;
+ // 矩形骨架元素
+ this.getRectEls();
+ // 圆形骨架元素
+ this.getCircleEls();
+ // 圆角骨架元素
+ this.getFilletEls();
+ // 矩形元素列表
+ getRectEls() {
+ query.selectAll('.u-skeleton-rect').boundingClientRect().exec((res) => {
+ this.RectNodes = res[0];
+ // 圆角元素列表
+ getFilletEls() {
+ query.selectAll('.u-skeleton-fillet').boundingClientRect().exec((res) => {
+ this.filletNodes = res[0];
+ // 圆形元素列表
+ getCircleEls() {
+ query.selectAll('.u-skeleton-circle').boundingClientRect().exec((res) => {
+ this.circleNodes = res[0];
+ // 组件被挂载
+ // 获取系统信息
+ this.windowHeight = systemInfo.windowHeight;
+ this.windowWinth = systemInfo.windowWidth;
+ this.selecterQueryInfo();
+ .skeleton-fade {
+ background: rgb(194, 207, 214);
+ animation-duration: 1.5s;
+ animation-name: blink;
+ @keyframes blink {
+ opacity: 0.4;
@@ -0,0 +1,257 @@
+ <view class="u-slider" @tap="onClick" :class="[disabled ? 'u-slider--disabled' : '']" :style="{
+ class="u-slider__gap"
+ barStyle,
+ backgroundColor: activeColor
+ <view class="u-slider__button-wrap" @touchstart="onTouchStart"
+ @touchmove="onTouchMove" @touchend="onTouchEnd"
+ @touchcancel="onTouchEnd">
+ <slot v-if="$slots.default"/>
+ <view v-else class="u-slider__button" :style="[blockStyle, {
+ height: blockWidth + 'rpx',
+ width: blockWidth + 'rpx',
+ backgroundColor: blockColor
+ }]"></view>
+ * slider 滑块选择器
+ * @tutorial https://uviewui.com/components/slider.html
+ * @property {Number | String} value 滑块默认值(默认0)
+ * @property {Number | String} min 最小值(默认0)
+ * @property {Number | String} max 最大值(默认100)
+ * @property {Number | String} step 步长(默认1)
+ * @property {Number | String} blockWidth 滑块宽度,高等于宽(30)
+ * @property {Number | String} height 滑块条高度,单位rpx(默认6)
+ * @property {String} inactiveColor 底部条背景颜色(默认#c0c4cc)
+ * @property {String} activeColor 底部选择部分的背景颜色(默认#2979ff)
+ * @property {String} blockColor 滑块颜色(默认#ffffff)
+ * @property {Object} blockStyle 给滑块自定义样式,对象形式
+ * @property {Boolean} disabled 是否禁用滑块(默认为false)
+ * @event {Function} start 滑动触发
+ * @event {Function} moving 正在滑动中
+ * @event {Function} end 滑动结束
+ * @example <u-slider v-model="value" />
+ name: 'u-slider',
+ // 当前进度百分比值,范围0-100
+ // 是否禁用滑块
+ // 滑块宽度,高等于宽,单位rpx
+ blockWidth: {
+ // 步进值
+ // 滑块条高度,单位rpx
+ default: 6
+ // 进度条的激活部分颜色
+ // 进度条的背景颜色
+ // 滑块的背景颜色
+ blockColor: {
+ // 用户对滑块的自定义颜色
+ blockStyle: {
+ startX: 0,
+ status: 'end',
+ newValue: 0,
+ distanceX: 0,
+ startValue: 0,
+ barStyle: {},
+ sliderRect: {
+ width: 0
+ // 只有在非滑动状态时,才可以通过value更新滑块值,这里监听,是为了让用户触发
+ if(this.status == 'end') this.updateValue(this.value, false);
+ this.updateValue(this.value, false);
+ // 获取滑块条的尺寸信息
+ this.$uGetRect('.u-slider').then(rect => {
+ this.sliderRect = rect;
+ onTouchStart(event) {
+ this.startX = 0;
+ // 触摸点集
+ let touches = event.touches[0];
+ // 触摸点到屏幕左边的距离
+ this.startX = touches.clientX;
+ // 此处的this.value虽为props值,但是通过$emit('input')进行了修改
+ this.startValue = this.format(this.value);
+ // 标示当前的状态为开始触摸滑动
+ this.status = 'start';
+ // 连续触摸的过程会一直触发本方法,但只有手指触发且移动了才被认为是拖动了,才发出事件
+ // 触摸后第一次移动已经将status设置为moving状态,故触摸第二次移动不会触发本事件
+ if (this.status == 'start') this.$emit('start');
+ // 滑块的左边不一定跟屏幕左边接壤,所以需要减去最外层父元素的左边值
+ this.distanceX = touches.clientX - this.sliderRect.left;
+ // 获得移动距离对整个滑块的百分比值,此为带有多位小数的值,不能用此更新视图
+ // 否则造成通信阻塞,需要每改变一个step值时修改一次视图
+ this.newValue = (this.distanceX / this.sliderRect.width) * 100;
+ this.status = 'moving';
+ // 发出moving事件
+ this.$emit('moving');
+ this.updateValue(this.newValue, true);
+ onTouchEnd() {
+ if (this.status === 'moving') {
+ this.updateValue(this.newValue, false);
+ this.status = 'end';
+ updateValue(value, drag) {
+ // 去掉小数部分,同时也是对step步进的处理
+ const width = this.format(value);
+ // 不允许滑动的值超过max最大值,百分比也不能超过100
+ if(width > this.max || width > 100) return;
+ // 设置移动的百分比值
+ let barStyle = {
+ width: width + '%'
+ // 移动期间无需过渡动画
+ if (drag == true) {
+ barStyle.transition = 'none';
+ // 非移动期间,删掉对过渡为空的声明,让css中的声明起效
+ delete barStyle.transition;
+ // 修改value值
+ this.$emit('input', width);
+ this.barStyle = barStyle;
+ format(value) {
+ // 将小数变成整数,为了减少对视图的更新,造成视图层与逻辑层的阻塞
+ return Math.round(Math.max(this.min, Math.min(value, this.max)) / this.step) * this.step;
+ onClick(event) {
+ // 直接点击滑块的情况,计算方式与onTouchMove方法相同
+ const value = ((event.detail.x - this.sliderRect.left) / this.sliderRect.width) * 100;
+ this.updateValue(value, false);
+.u-slider {
+ border-radius: 999px;
+.u-slider:before {
+ top: -8px;
+ bottom: -8px;
+ z-index: -1;
+.u-slider__gap {
+ border-radius: inherit;
+ transition: width 0.2s;
+ background-color: #1989fa;
+.u-slider__button {
+ width: 24px;
+ height: 24px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
+.u-slider__button-wrap {
+ transform: translate3d(50%, -50%, 0);
+.u-slider--disabled {
@@ -0,0 +1,200 @@
+ class="u-steps"
+ flexDirection: direction
+ <view class="u-steps__item"
+ :class="['u-steps__item--' + direction]"
+ v-for="(item, index) in list" :key="index"
+ class="u-steps__item__num"
+ v-if="mode == 'number'"
+ backgroundColor: current < index ? 'transparent' : activeColor,
+ borderColor: current < index ? unActiveColor : activeColor
+ <text v-if="current < index" :style="{
+ color: current < index ? unActiveColor : activeColor,
+ {{ index + 1 }}
+ <u-icon v-else size="22" color="#ffffff" :name="icon"></u-icon>
+ <view class="u-steps__item__dot" v-if="mode == 'dot'" :style="{
+ backgroundColor: index <= current ? activeColor : unActiveColor
+ <text class="u-line-1" :style="{
+ color: index <= current ? activeColor : unActiveColor,
+ }" :class="['u-steps__item__text--' + direction]">
+ {{ item.name }}
+ <view class="u-steps__item__line" :class="['u-steps__item__line--' + mode]" v-if="index < list.length - 1">
+ <u-line :direction="direction" length="100%" :hair-line="false" :color="unActiveColor"></u-line>
+ * steps 步骤条
+ * @description 该组件一般用于完成一个任务要分几个步骤,标识目前处于第几步的场景。
+ * @tutorial https://www.uviewui.com/components/steps.html
+ * @property {String} mode 设置模式(默认dot)
+ * @property {Array} list 数轴条数据,数组。具体见上方示例
+ * @property {String} type type主题(默认primary)
+ * @property {String} direction row-横向,column-竖向(默认row)
+ * @property {Number String} current 设置当前处于第几步
+ * @property {String} active-color 已完成步骤的激活颜色,如设置,type值会失效
+ * @property {String} un-active-color 未激活的颜色,用于表示未完成步骤的颜色(默认#606266)
+ * @example <u-steps :list="numList" active-color="#fa3534"></u-steps>
+ name: 'u-steps',
+ // 步骤条的类型,dot|number
+ default: 'dot'
+ // 步骤条的数据
+ // 主题类型, primary|success|info|warning|error
+ // 当前哪一步是激活的
+ // 激活步骤的颜色
+ // 未激活的颜色
+ unActiveColor: {
+ default: 'checkmark'
+ // step的排列方向,row-横向,column-竖向
+$u-steps-item-number-width: 44rpx;
+$u-steps-item-dot-width: 20rpx;
+.u-steps {
+ .u-steps__item {
+ color: #8799a3;
+ &--row {
+ .u-steps__item__line {
+ left: 75%;
+ &--dot {
+ top: calc(#{$u-steps-item-dot-width} / 2);
+ &--number {
+ top: calc(#{$u-steps-item-number-width} / 2);
+ &--column {
+ justify-content: flex-start;
+ min-height: 120rpx;
+ height: 50%;
+ top: 75%;
+ left: calc(#{$u-steps-item-dot-width} / 2);
+ left: calc(#{$u-steps-item-number-width} / 2);
+ &__num {
+ width: $u-steps-item-number-width;
+ height: $u-steps-item-number-width;
+ border: 1px solid #8799a3;
+ &__dot {
+ width: $u-steps-item-dot-width;
+ height: $u-steps-item-dot-width;
+ &__text--row {
+ margin-top: 14rpx;
+ &__text--column {
@@ -0,0 +1,157 @@
+ <view class="u-sticky-wrap" :class="[elClass]" :style="{
+ height: fixed ? height + 'px' : 'auto',
+ <view class="u-sticky" :style="{
+ position: fixed ? 'fixed' : 'static',
+ top: stickyTop + 'px',
+ width: width == 'auto' ? 'auto' : width + 'px',
+ zIndex: uZIndex
+ * sticky 吸顶
+ * @description 该组件与CSS中position: sticky属性实现的效果一致,当组件达到预设的到顶部距离时, 就会固定在指定位置,组件位置大于预设的顶部距离时,会重新按照正常的布局排列。
+ * @tutorial https://www.uviewui.com/components/sticky.html
+ * @property {String Number} offset-top 吸顶时与顶部的距离,单位rpx(默认0)
+ * @property {String Number} index 自定义标识,用于区分是哪一个组件
+ * @property {Boolean} enable 是否开启吸顶功能(默认true)
+ * @property {String} bg-color 组件背景颜色(默认#ffffff)
+ * @property {String Number} z-index 吸顶时的z-index值(默认970)
+ * @property {String Number} h5-nav-height 导航栏高度,自定义导航栏时(无导航栏时需设置为0),需要传入此值,单位px(默认44)
+ * @event {Function} fixed 组件吸顶时触发
+ * @event {Function} unfixed 组件取消吸顶时触发
+ * @example <u-sticky offset-top="200"><view>塞下秋来风景异,衡阳雁去无留意</view></u-sticky>
+ name: "u-sticky",
+ // 吸顶容器到顶部某个距离的时候,进行吸顶,在H5平台,NavigationBar为44px
+ //列表中的索引值
+ // 是否开启吸顶功能
+ enable: {
+ // h5顶部导航栏的高度
+ h5NavHeight: {
+ default: 44
+ // 吸顶区域的背景颜色
+ fixed: false,
+ height: 'auto',
+ stickyTop: 0,
+ width: 'auto',
+ offsetTop(val) {
+ this.initObserver();
+ enable(val) {
+ if (val == false) {
+ this.fixed = false;
+ this.disconnectObserver('contentObserver');
+ return this.zIndex ? this.zIndex : this.$u.zIndex.sticky;
+ initObserver() {
+ if (!this.enable) return;
+ this.stickyTop = this.offsetTop != 0 ? uni.upx2px(this.offsetTop) + this.h5NavHeight : this.h5NavHeight;
+ this.stickyTop = this.offsetTop != 0 ? uni.upx2px(this.offsetTop) : 0;
+ this.$uGetRect('.' + this.elClass).then((res) => {
+ this.left = res.left;
+ this.width = res.width;
+ this.observeContent();
+ observeContent() {
+ const contentObserver = this.createIntersectionObserver({
+ thresholds: [0.95, 0.98, 1]
+ contentObserver.relativeToViewport({
+ top: -this.stickyTop
+ contentObserver.observe('.' + this.elClass, res => {
+ this.setFixed(res.boundingClientRect.top);
+ this.contentObserver = contentObserver;
+ setFixed(top) {
+ const fixed = top < this.stickyTop;
+ if (fixed) this.$emit('fixed', this.index);
+ else if(this.fixed) this.$emit('unfixed', this.index);
+ this.fixed = fixed;
+ disconnectObserver(observerName) {
+ const observer = this[observerName];
+ observer && observer.disconnect();
+ .u-sticky {
+ z-index: 9999999999;
@@ -0,0 +1,356 @@
+ <view class="u-subsection" :style="[subsectionStyle]">
+ <view class="u-item u-line-1" :style="[itemStyle(index)]" @tap="click(index)" :class="[noBorderRight(index), 'u-item-' + index]"
+ v-for="(item, index) in listInfo" :key="index">
+ <view :style="[textStyle(index)]" class="u-item-text u-line-1">{{ item.name }}</view>
+ <view class="u-item-bg" :style="[itemBarStyle]"></view>
+ * subsection 分段器
+ * @description 该分段器一般用于用户从几个选项中选择某一个的场景
+ * @tutorial https://www.uviewui.com/components/subsection.html
+ * @property {Array} list 选项的数组,形式见上方"基本使用"
+ * @property {String Number} current 初始化时默认选中的选项索引值(默认0)
+ * @property {String} active-color 激活时的颜色,mode为subsection时固定为白色(默认#303133)
+ * @property {String} inactive-color 未激活时字体的颜色,mode为subsection时无效(默认#606266)
+ * @property {String} mode 模式选择,见官网"模式选择"说明(默认button)
+ * @property {String Number} height 组件高度,单位rpx(默认70)
+ * @property {Boolean} animation 是否开启动画效果,见上方说明(默认true)
+ * @property {Boolean} bold 激活选项的字体是否加粗(默认true)
+ * @property {String} bg-color 组件背景颜色,mode为button时有效(默认#eeeeef)
+ * @property {String} button-color 按钮背景颜色,mode为button时有效(默认#ffffff)
+ * @event {Function} change 分段器选项发生改变时触发
+ * @example <u-subsection active-color="#ff9900"></u-subsection>
+ name: "u-subsection",
+ // tab的数据
+ // 当前活动的tab的index
+ // 激活的颜色
+ // 模式选择,mode=button为按钮形式,mode=subsection时为分段模式
+ default: 'button'
+ // 是否开启动画效果
+ // 组件的高度,单位rpx
+ default: 70
+ // 激活tab的字体是否加粗
+ // mode=button时,组件背景颜色
+ default: '#eeeeef'
+ // mode = button时,滑块背景颜色
+ buttonColor: {
+ // 在切换分段器的时候,是否让设备震一下
+ vibrateShort: {
+ listInfo: [],
+ itemBgStyle: {
+ backgroundColor: '#ffffff',
+ transition: ''
+ currentIndex: this.current,
+ buttonPadding: 3, // mode = button 时,组件的内边距
+ borderRadius: 5, // 圆角值
+ firstTimeVibrateShort: true // 组件初始化时,会触发current变化,此时不应震动
+ this.currentIndex = nVal;
+ this.changeSectionStatus(nVal);
+ // 将list的数据,传入listInfo数组,因为不能修改props传递的list值
+ // 可以接受直接数组形式,或者数组元素为对象的形式,如:['简介', '评论'],或者[{name: '简介'}, {name: '评论'}]
+ this.listInfo = this.list.map((val, index) => {
+ if (typeof val != 'object') {
+ let obj = {
+ name: val
+ return obj;
+ val.width = 0;
+ // 设置mode=subsection时,滑块特有的样式
+ noBorderRight() {
+ if (this.mode != 'subsection') return;
+ let classs = '';
+ // 不显示右边的边框
+ if (index < this.list.length - 1) classs += ' u-none-border-right';
+ // 显示整个组件的左右边圆角
+ if (index == 0) classs += ' u-item-first';
+ if (index == this.list.length - 1) classs += ' u-item-last';
+ return classs;
+ // 文字的样式
+ // 设置字体颜色
+ if (this.mode == 'subsection') {
+ if (index == this.currentIndex) {
+ style.color = '#ffffff';
+ style.color = this.activeColor;
+ style.color = this.inactiveColor;
+ // 字体加粗
+ if (index == this.currentIndex && this.bold) style.fontWeight = 'bold';
+ // 文字大小
+ // 每个分段器item的样式
+ // 设置border的样式
+ style.borderColor = this.activeColor;
+ // mode=button时,外层view的样式
+ subsectionStyle() {
+ style.height = uni.upx2px(this.height) + 'px';
+ if (this.mode == 'button') {
+ style.padding = `${this.buttonPadding}px`;
+ style.borderRadius = `${this.borderRadius}px`;
+ // 滑块的样式
+ itemBarStyle() {
+ style.backgroundColor = this.activeColor;
+ style.zIndex = 1;
+ style.backgroundColor = this.buttonColor;
+ style.bottom = `${this.buttonPadding}px`;
+ style.height = uni.upx2px(this.height) - this.buttonPadding * 2 + 'px';
+ style.zIndex = 0;
+ return Object.assign(this.itemBgStyle, style);
+ this.getTabsInfo();
+ // 改变滑块的样式
+ changeSectionStatus(nVal) {
+ // 根据滑块在最左边和最右边时,显示左边和右边的圆角
+ if (nVal == this.list.length - 1) {
+ this.itemBgStyle.borderRadius = `0 ${this.buttonPadding}px ${this.buttonPadding}px 0`;
+ if (nVal == 0) {
+ this.itemBgStyle.borderRadius = `${this.buttonPadding}px 0 0 ${this.buttonPadding}px`;
+ if (nVal > 0 && nVal < this.list.length - 1) {
+ this.itemBgStyle.borderRadius = '0';
+ // 更新滑块的位置
+ this.itemBgLeft();
+ if (this.vibrateShort && !this.firstTimeVibrateShort) {
+ // 使手机产生短促震动,微信小程序有效,APP(HX 2.6.8)和H5无效
+ uni.vibrateShort();
+ // 第一次过后,设置firstTimeVibrateShort为false,让其下一次可以震动(如果允许震动的话)
+ this.firstTimeVibrateShort = false;
+ // 不允许点击当前激活选项
+ if (index == this.currentIndex) return;
+ this.currentIndex = index;
+ this.changeSectionStatus(index);
+ this.$emit('change', Number(index));
+ // 获取各个tab的节点信息
+ getTabsInfo() {
+ let view = uni.createSelectorQuery().in(this);
+ for (let i = 0; i < this.list.length; i++) {
+ view.select('.u-item-' + i).boundingClientRect();
+ view.exec(res => {
+ if (!res.length) {
+ // 将分段器每个item的宽度,放入listInfo数组
+ res.map((val, index) => {
+ this.listInfo[index].width = val.width;
+ // 初始化滑块的宽度
+ this.itemBgStyle.width = this.listInfo[0].width + 'px';
+ } else if (this.mode == 'button') {
+ // 初始化滑块的位置
+ itemBgLeft() {
+ // 根据是否开启动画效果,
+ if (this.animation) {
+ this.itemBgStyle.transition = 'all 0.35s';
+ this.itemBgStyle.transition = 'all 0s';
+ let left = 0;
+ // 计算当前活跃item到组件左边的距离
+ this.listInfo.map((val, index) => {
+ if (index < this.currentIndex) left += val.width;
+ // 根据mode不同模式,计算滑块需要移动的距离
+ this.itemBgStyle.left = left + 'px';
+ this.itemBgStyle.left = left + this.buttonPadding + 'px';
+ .u-subsection {
+ .u-item {
+ padding: 0 6rpx;
+ .u-item-bg {
+ .u-none-border-right {
+ border-right: none !important;
+ .u-item-first {
+ .u-item-last {
+ .u-item-text {
+ transition: all 0.35s;
@@ -0,0 +1,255 @@
+ <movable-area class="u-swipe-action" :style="{ backgroundColor: bgColor }">
+ <movable-view
+ class="u-swipe-view"
+ @change="change"
+ @touchend="touchend"
+ @touchstart="touchstart"
+ direction="horizontal"
+ :x="moveX"
+ width: movableViewWidth ? movableViewWidth : '100%'
+ class="u-swipe-content"
+ @tap.stop="contentClick"
+ <view class="u-swipe-del" v-if="showBtn" @tap.stop="btnClick(index)" :style="[btnStyle(item.style)]" v-for="(item, index) in options" :key="index">
+ <view class="u-btn-text">{{ item.text }}</view>
+ </movable-view>
+ </movable-area>
+ * swipeAction 左滑单元格
+ * @description 该组件一般用于左滑唤出操作菜单的场景,用的最多的是左滑删除操作。
+ * @tutorial https://www.uviewui.com/components/swipeAction.html
+ * @property {String} bg-color 整个组件背景颜色(默认#ffffff)
+ * @property {Array} options 数组形式,可以配置背景颜色和文字
+ * @property {String Number} index 标识符,点击时候用于区分点击了哪一个,用v-for循环时的index即可
+ * @property {String Number} btn-width 按钮宽度,单位rpx(默认180)
+ * @property {Boolean} disabled 是否禁止某个swipeAction滑动(默认false)
+ * @property {Boolean} show 打开或者关闭某个组件(默认false)
+ * @event {Function} close 组件触发关闭状态时
+ * @event {Function} content-click 点击内容时触发
+ * @event {Function} open 组件触发打开状态时
+ * @example <u-swipe-action btn-text="收藏">...</u-swipe-action>
+ name: 'u-swipe-action',
+ // index值,用于得知点击删除的是哪个按钮
+ // 滑动按钮的宽度,单位为rpx
+ btnWidth: {
+ default: 180
+ // 是否禁止某个action滑动
+ // 打开或者关闭组件
+ // 组件背景颜色
+ // 是否使手机发生短促震动,目前只在iOS的微信小程序有效(2020-05-06)
+ // 按钮操作参数
+ handler(nVal, oVal) {
+ if (nVal) {
+ moveX: 0, // movable-view元素在x轴上需要移动的目标移动距离,用于展开或收起滑动的按钮
+ scrollX: 0, // movable-view移动过程中产生的change事件中的x轴移动值
+ status: false, // 滑动的状态,表示当前是展开还是关闭按钮的状态
+ movableAreaWidth: 0, // 滑动区域
+ elId: this.$u.guid(), // id,用于通知另外组件关闭时的识别
+ showBtn: false, // 刚开始渲染视图时不显示右边的按钮,避免视图闪动
+ movableViewWidth() {
+ return this.movableAreaWidth + this.allBtnWidth + 'px';
+ innerBtnWidth() {
+ return uni.upx2px(this.btnWidth);
+ allBtnWidth() {
+ return uni.upx2px(this.btnWidth) * this.options.length;
+ btnStyle() {
+ return style => {
+ let css = {};
+ style.width = this.btnWidth + 'rpx';
+ this.getActionRect();
+ // 点击按钮
+ btnClick(index) {
+ this.status = false;
+ // this.index为点击的几个组件,index为点击某个组件的第几个按钮(options数组的索引)
+ this.$emit('click', this.index, index);
+ // movable-view元素移动事件
+ this.scrollX = e.detail.x;
+ // 关闭按钮状态
+ this.moveX = 0;
+ // 打开按钮的状态
+ this.moveX = -this.allBtnWidth;
+ this.status = true;
+ // 用户手指离开movable-view元素,停止触摸
+ touchend() {
+ this.moveX = this.scrollX;
+ // 停止触摸时候,判断当前是展开还是关闭状态
+ // 关闭状态
+ // 这一步很重要,需要先给this.moveX一个变化的随机值,否则因为前后设置的为同一个值
+ // props单向数据流的原因,导致movable-view元素不会发生变化,切记,详见文档:
+ // https://uniapp.dcloud.io/use?id=%e5%b8%b8%e8%a7%81%e9%97%ae%e9%a2%98
+ if (this.status == false) {
+ // 关闭状态左滑,产生的x轴位移为负值,也就是说滑动的距离大于按钮的四分之一宽度,自动展开按钮
+ if (this.scrollX <= -this.allBtnWidth / 4) {
+ this.moveX = -this.allBtnWidth; // 按钮宽度的负值,即为展开状态movable-view元素左滑的距离
+ this.status = true; // 标志当前为展开状态
+ this.emitOpenEvent();
+ // 产生震动效果
+ if (this.vibrateShort) uni.vibrateShort();
+ this.moveX = 0; // 如果距离没有按钮宽度的四分之一,自动收起
+ this.emitCloseEvent();
+ // 如果在打开的状态下,右滑动的距离X轴偏移超过按钮的四分之一(负值反过来的四分之三),自动收起按钮
+ if (this.scrollX > (-this.allBtnWidth * 3) / 4) {
+ this.moveX = 101;
+ emitOpenEvent() {
+ this.$emit('open', this.index);
+ emitCloseEvent() {
+ this.$emit('close', this.index);
+ // 开始触摸
+ touchstart() {},
+ getActionRect() {
+ this.$uGetRect('.u-swipe-action').then(res => {
+ this.movableAreaWidth = res.width;
+ // 等视图更新完后,再显示右边的可滑动按钮,防止这些按钮会"闪一下"
+ this.showBtn = true;
+ // 点击内容触发事件
+ contentClick() {
+ // 点击内容时,如果当前为打开状态,收起组件
+ if (this.status == true) {
+ this.status = 'close';
+ this.$emit('content-click', this.index);
+.u-swipe-action {
+ height: initial;
+.u-swipe-view {
+ /* 这一句很关键,覆盖默认的绝对定位 */
+.u-swipe-content {
+.u-swipe-del {
+.u-btn-text {
@@ -0,0 +1,338 @@
+ <view class="u-swiper-wrap" :style="{
+ borderRadius: `${borderRadius}rpx`
+ <swiper :current="elCurrent" @change="change" @animationfinish="animationfinish" :interval="interval" :circular="circular" :duration="duration" :autoplay="autoplay"
+ :previous-margin="effect3d ? effect3dPreviousMargin + 'rpx' : '0'" :next-margin="effect3d ? effect3dPreviousMargin + 'rpx' : '0'"
+ <swiper-item class="u-swiper-item" v-for="(item, index) in list" :key="index">
+ <view class="u-list-image-wrap" @tap.stop.prevent="listClick(index)" :class="[uCurrent != index ? 'u-list-scale' : '']" :style="{
+ borderRadius: `${borderRadius}rpx`,
+ transform: effect3d && uCurrent != index ? 'scaleY(0.9)' : 'scaleY(1)',
+ margin: effect3d && uCurrent != index ? '0 20rpx' : 0,
+ <image class="u-swiper-image" :src="item[name]" :mode="imgMode"></image>
+ <view v-if="title" class="u-swiper-title u-line-1" :style="[{
+ 'padding-bottom': titlePaddingBottom
+ }, titleStyle]">
+ {{ item.title }}
+ <view class="u-swiper-indicator" :style="{
+ top: indicatorPos == 'topLeft' || indicatorPos == 'topCenter' || indicatorPos == 'topRight' ? '12rpx' : 'auto',
+ bottom: indicatorPos == 'bottomLeft' || indicatorPos == 'bottomCenter' || indicatorPos == 'bottomRight' ? '12rpx' : 'auto',
+ padding: `0 ${effect3d ? '74rpx' : '24rpx'}`
+ <block v-if="mode == 'rect'">
+ <view class="u-indicator-item-rect" :class="{ 'u-indicator-item-rect-active': index == uCurrent }" v-for="(item, index) in list"
+ :key="index"></view>
+ <block v-if="mode == 'dot'">
+ <view class="u-indicator-item-dot" :class="{ 'u-indicator-item-dot-active': index == uCurrent }" v-for="(item, index) in list"
+ <block v-if="mode == 'round'">
+ <view class="u-indicator-item-round" :class="{ 'u-indicator-item-round-active': index == uCurrent }" v-for="(item, index) in list"
+ <block v-if="mode == 'number'">
+ <view class="u-indicator-item-number">{{ uCurrent + 1 }}/{{ list.length }}</view>
+ * swiper 轮播图
+ * @description 该组件一般用于导航轮播,广告展示等场景,可开箱即用
+ * @tutorial https://www.uviewui.com/components/swiper.html
+ * @property {Array} list 轮播图数据,见官网"基本使用"说明
+ * @property {Boolean} title 是否显示标题文字,需要配合list参数,见官网说明(默认false)
+ * @property {String} mode 指示器模式,见官网说明(默认round)
+ * @property {String Number} height 轮播图组件高度,单位rpx(默认250)
+ * @property {String} indicator-pos 指示器的位置(默认bottomCenter)
+ * @property {Boolean} effect3d 是否开启3D效果(默认false)
+ * @property {String Number} interval 自动轮播时间间隔,单位ms(默认2500)
+ * @property {Boolean} circular 是否衔接播放,见官网说明(默认true)
+ * @property {String Number} border-radius 轮播图圆角值,单位rpx(默认8)
+ * @property {Object} title-style 自定义标题样式
+ * @property {String Number} effect3d-previous-margin mode = true模式的情况下,激活项与前后项之间的距离,单位rpx(默认50)
+ * @property {String} img-mode 图片的裁剪模式,详见image组件裁剪模式(默认aspectFill)
+ * @event {Function} click 点击轮播图时触发
+ * @example <u-swiper :list="list" mode="dot" indicator-pos="bottomRight"></u-swiper>
+ name: "u-swiper",
+ // 轮播图的数据,格式如:[{image: 'xxxx', title: 'xxxx'},{image: 'yyyy', title: 'yyyy'}],其中title字段可选
+ // 是否显示title标题
+ // 用户自定义的指示器的样式
+ indicator: {
+ default: 8
+ // 隔多久自动切换
+ interval: {
+ default: 3000
+ // 指示器的模式,rect|dot|number|round
+ // list的高度,单位rpx
+ // 指示器的位置,topLeft|topCenter|topRight|bottomLeft|bottomCenter|bottomRight
+ indicatorPos: {
+ default: 'bottomCenter'
+ effect3d: {
+ // 3D模式的情况下,激活item与前后item之间的距离,单位rpx
+ effect3dPreviousMargin: {
+ // 自动轮播时间间隔,单位ms
+ // 是否衔接滑动,即到最后一张时接着滑动,是否自动切换到第一张
+ circular: {
+ // 图片的裁剪模式
+ // 从list数组中读取的图片的属性名
+ default: 'image'
+ // 初始化时,默认显示第几项
+ // 标题的样式,对象形式
+ // 如果外部的list发生变化,判断长度是否被修改,如果前后长度不一致,重置uCurrent值,避免溢出
+ list(nVal, oVal) {
+ if(nVal.length !== oVal.length) this.uCurrent = 0;
+ // 监听外部current的变化,实时修改内部依赖于此测uCurrent值,如果更新了current,而不是更新uCurrent,
+ // 就会错乱,因为指示器是依赖于uCurrent的
+ current(n) {
+ this.uCurrent = n;
+ uCurrent: this.current // 当前活跃的swiper-item的index
+ if (this.indicatorPos == 'topLeft' || this.indicatorPos == 'bottomLeft') return 'flex-start';
+ if (this.indicatorPos == 'topCenter' || this.indicatorPos == 'bottomCenter') return 'center';
+ if (this.indicatorPos == 'topRight' || this.indicatorPos == 'bottomRight') return 'flex-end';
+ titlePaddingBottom() {
+ let tmp = 0;
+ if (this.mode == 'none') return '12rpx';
+ if (['bottomLeft', 'bottomCenter', 'bottomRight'].indexOf(this.indicatorPos) >= 0 && this.mode == 'number') {
+ tmp = '60rpx';
+ } else if (['bottomLeft', 'bottomCenter', 'bottomRight'].indexOf(this.indicatorPos) >= 0 && this.mode != 'number') {
+ tmp = '40rpx';
+ tmp = '12rpx';
+ // 因为uni的swiper组件的current参数只接受Number类型,这里做一个转换
+ elCurrent() {
+ return Number(this.current);
+ listClick(index) {
+ let current = e.detail.current;
+ this.uCurrent = current;
+ // 发出change事件,表示当前自动切换的index,从0开始
+ this.$emit('change', current);
+ // 头条小程序不支持animationfinish事件,改由change事件
+ // 暂不监听此事件,因为不再给swiper绑定uCurrent属性
+ animationfinish(e) {
+ // #ifndef MP-TOUTIAO
+ // this.uCurrent = e.detail.current;
+ .u-swiper-wrap {
+ transform: translateY(0);
+ .u-swiper-image {
+ /* #ifdef H5 */
+ .u-swiper-indicator {
+ padding: 0 24rpx;
+ .u-indicator-item-rect {
+ width: 26rpx;
+ height: 8rpx;
+ transition: all 0.5s;
+ background-color: rgba(0, 0, 0, 0.3);
+ .u-indicator-item-rect-active {
+ background-color: rgba(255, 255, 255, 0.8);
+ .u-indicator-item-dot {
+ width: 14rpx;
+ height: 14rpx;
+ border-radius: 20rpx;
+ .u-indicator-item-dot-active {
+ .u-indicator-item-round {
+ .u-indicator-item-round-active {
+ width: 34rpx;
+ .u-indicator-item-number {
+ padding: 6rpx 16rpx;
+ color: rgba(255, 255, 255, 0.8);
+ .u-list-scale {
+ .u-list-image-wrap {
+ box-sizing: content-box;
+ .u-swiper-title {
+ padding: 12rpx 24rpx;
+ color: rgba(255, 255, 255, 0.9);
+ .u-swiper-item {
@@ -0,0 +1,161 @@
+ <view class="u-switch" :class="[value == true ? 'u-switch--on' : '', disabled ? 'u-switch--disabled' : '']" @tap="onClick"
+ :style="[switchStyle]">
+ <view class="u-switch__node node-class" :style="{
+ width: $u.addUnit(this.size),
+ height: $u.addUnit(this.size)
+ <u-loading :show="loading" class="u-switch__loading" :size="size * 0.6" :color="loadingColor" />
+ * switch 开关选择器
+ * @description 选择开关一般用于只有两个选择,且只能选其一的场景。
+ * @tutorial https://www.uviewui.com/components/switch.html
+ * @property {Boolean} loading 是否处于加载中(默认false)
+ * @property {String Number} size 开关尺寸,单位rpx(默认50)
+ * @property {String} active-color 打开时的背景色(默认#2979ff)
+ * @property {Boolean} inactive-color 关闭时的背景色(默认#ffffff)
+ * @property {Boolean | Number | String} active-value 打开选择器时通过change事件发出的值(默认true)
+ * @property {Boolean | Number | String} inactive-value 关闭选择器时通过change事件发出的值(默认false)
+ * @event {Function} change 在switch打开或关闭时触发
+ * @example <u-switch v-model="checked" active-color="red" inactive-color="#eee"></u-switch>
+ name: "u-switch",
+ // 是否为加载中状态
+ // 是否为禁用装填
+ // 开关尺寸,单位rpx
+ // 打开时的背景颜色
+ // 关闭时的背景颜色
+ // 通过v-model双向绑定的值
+ // 打开选择器时的值
+ activeValue: {
+ type: [Number, String, Boolean],
+ // 关闭选择器时的值
+ inactiveValue: {
+ switchStyle() {
+ style.fontSize = this.size + 'rpx';
+ style.backgroundColor = this.value ? this.activeColor : this.inactiveColor;
+ loadingColor() {
+ return this.value ? this.activeColor : null;
+ if (!this.disabled && !this.loading) {
+ if(this.vibrateShort) uni.vibrateShort();
+ // 放到下一个生命周期,因为双向绑定的value修改父组件状态需要时间,且是异步的
+ this.$emit('change', this.value ? this.activeValue : this.inactiveValue);
+ .u-switch {
+ box-sizing: initial;
+ width: 2em;
+ height: 1em;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 1em;
+ transition: background-color 0.3s;
+ .u-switch__node {
+ box-shadow: 0 3px 1px 0 rgba(0, 0, 0, 0.05), 0 2px 2px 0 rgba(0, 0, 0, 0.1), 0 3px 3px 0 rgba(0, 0, 0, 0.05);
+ transition: transform 0.3s cubic-bezier(0.3, 1.05, 0.4, 1.05);
+ transition: transform 0.3s cubic-bezier(0.3, 1.05, 0.4, 1.05), -webkit-transform 0.3s cubic-bezier(0.3, 1.05, 0.4, 1.05);
+ transition: transform cubic-bezier(0.3, 1.05, 0.4, 1.05);
+ transition: transform 0.3s cubic-bezier(0.3, 1.05, 0.4, 1.05)
+ .u-switch__loading {
+ .u-switch--on {
+ .u-switch--on .u-switch__node {
+ transform: translateX(100%);
+ .u-switch--disabled {
+ <view v-if="show" class="u-tabbar" @touchmove.stop.prevent>
+ <view class="u-tabbar__content safe-area-inset-bottom" :style="{
+ height: $u.addUnit(height),
+ 'u-border-top': borderTop
+ <view class="u-tabbar__content__item" v-for="(item, index) in list" :key="index" :class="{
+ 'u-tabbar__content__circle': midButton &&item.midButton
+ }" @tap.stop="clickHandler(index)" :style="{
+ midButton && item.midButton ? 'u-tabbar__content__circle__button' : 'u-tabbar__content__item__button'
+ ]">
+ :size="midButton && item.midButton ? midButtonSize : iconSize"
+ :name="elIconPath(index)"
+ img-mode="scaleToFill"
+ :color="elColor(index)"
+ :custom-prefix="item.customIcon ? 'custom-icon' : 'uicon'"
+ <u-badge :count="item.count" :is-dot="item.isDot"
+ v-if="item.count"
+ :offset="[-2, getOffsetRight(item.count, item.isDot)]"
+ ></u-badge>
+ <view class="u-tabbar__content__item__text" :style="{
+ color: elColor(index)
+ <text class="u-line-1">{{item.text}}</text>
+ <view v-if="midButton" class="u-tabbar__content__circle__border" :class="{
+ 'u-border': borderTop,
+ }" :style="{
+ left: midButtonLeft
+ <!-- 这里加上一个48rpx的高度,是为了增高有凸起按钮时的防塌陷高度(也即按钮凸出来部分的高度) -->
+ <view class="u-fixed-placeholder safe-area-inset-bottom" :style="{
+ height: `calc(${$u.addUnit(height)} + ${midButton ? 48 : 0}rpx)`,
+ // 显示与否
+ // 通过v-model绑定current值
+ // 整个tabbar的背景颜色
+ // tabbar的高度,默认50px,单位任意,如果为数值,则为rpx单位
+ default: '50px'
+ // 非凸起图标的大小,单位任意,数值默认rpx
+ // 凸起的图标的大小,单位任意,数值默认rpx
+ midButtonSize: {
+ // 激活时的演示,包括字体图标,提示文字等的演示
+ // 未激活时的颜色
+ // 是否显示中部的凸起按钮
+ midButton: {
+ // 配置参数
+ // 切换前的回调
+ beforeSwitch: {
+ // 是否显示顶部的横线
+ // 是否隐藏原生tabbar
+ hideTabBar: {
+ // 由于安卓太菜了,通过css居中凸起按钮的外层元素有误差,故通过js计算将其其中
+ midButtonLeft: '50%',
+ pageUrl: '', // 当前
+ if(this.hideTabBar) uni.hideTabBar();
+ // 获取引入了u-tabbar页面的路由地址,该地址没有路径前面的"/"
+ let pages = getCurrentPages();
+ // 页面栈中的最后一个即为项为当前页面,route属性为页面路径
+ this.pageUrl = pages[pages.length - 1].route;
+ elIconPath() {
+ // 历遍u-tabbar的每一项item时,判断是否传入了pagePath参数,如果传入了
+ // 和data中的pageUrl参数对比,如果相等,即可判断当前的item对应当前的tabbar页面,设置高亮图标
+ // 采用这个方法,可以无需使用v-model绑定的value值
+ let pagePath = this.list[index].pagePath;
+ // 如果定义了pagePath属性,意味着使用系统自带tabbar方案,否则使用一个页面用几个组件模拟tabbar页面的方案
+ // 这两个方案对处理tabbar item的激活与否方式不一样
+ if(pagePath) {
+ if(pagePath == this.pageUrl || pagePath == '/' + this.pageUrl) {
+ return this.list[index].selectedIconPath;
+ return this.list[index].iconPath;
+ // 普通方案中,索引等于v-model值时,即为激活项
+ return index == this.value ? this.list[index].selectedIconPath : this.list[index].iconPath
+ elColor() {
+ // 判断方法同理于elIconPath
+ if(pagePath == this.pageUrl || pagePath == '/' + this.pageUrl) return this.activeColor;
+ else return this.inactiveColor;
+ return index == this.value ? this.activeColor : this.inactiveColor;
+ this.midButton && this.getMidButtonLeft();
+ async clickHandler(index) {
+ if(this.beforeSwitch && typeof(this.beforeSwitch) === 'function') {
+ // 执行回调,同时传入索引当作参数
+ let beforeSwitch = this.beforeSwitch.bind(this.$u.$parent.call(this))(index);
+ // 判断是否返回了promise
+ if (!!beforeSwitch && typeof beforeSwitch.then === 'function') {
+ await beforeSwitch.then(res => {
+ // promise返回成功,
+ this.switchTab(index);
+ }).catch(err => {
+ } else if(beforeSwitch === true) {
+ // 如果返回true
+ // 切换tab
+ switchTab(index) {
+ // 发出事件和修改v-model绑定的值
+ this.$emit('change', index);
+ // 如果有配置pagePath属性,使用uni.switchTab进行跳转
+ if(this.list[index].pagePath) {
+ url: this.list[index].pagePath
+ // 如果配置了papgePath属性,将不会双向绑定v-model传入的value值
+ // 因为这个模式下,不再需要v-model绑定的value值了,而是通过getCurrentPages()适配
+ this.$emit('input', index);
+ // 计算角标的right值
+ getOffsetRight(count, isDot) {
+ // 点类型,count大于9(两位数),分别设置不同的right值,避免位置太挤
+ if(isDot) {
+ return -20;
+ } else if(count > 9) {
+ return -40;
+ return -30;
+ // 获取凸起按钮外层元素的left值,让其水平居中
+ getMidButtonLeft() {
+ let windowWidth = this.$u.sys().windowWidth;
+ // 由于安卓中css计算left: 50%的结果不准确,故用js计算
+ this.midButtonLeft = (windowWidth / 2) + 'px';
+ .u-fixed-placeholder {
+ .u-tabbar {
+ z-index: 998;
+ &__circle__border {
+ width: 110rpx;
+ top: -48rpx;
+ z-index: 4;
+ // 由于安卓的无能,导致只有3个tabbar item时,此css计算方式有误差
+ // 故使用js计算的形式来定位,此处不注释,是因为js计算有延后,避免出现位置闪动
+ transform: translateX(-50%);
+ &:after {
+ border-radius: 100px;
+ padding: 12rpx 0;
+ top: 10rpx;
+ line-height: 28rpx;
+ bottom: 12rpx;
+ &__circle {
+ z-index: 10;
+ height: calc(100% - 1px);
+ top: -40rpx;
+ z-index: 6;
+ <view class="u-table" :style="[tableStyle]">
+ * table 表格
+ * @description 表格组件一般用于展示大量结构化数据的场景
+ * @tutorial https://www.uviewui.com/components/table.html
+ * @property {String} border-color 表格边框的颜色(默认#e4e7ed)
+ * @property {String} bg-color 表格的背景颜色(默认#ffffff)
+ * @property {String} align 单元格的内容对齐方式,作用类似css的text-align(默认center)
+ * @property {String} padding 单元格的内边距,同css的padding写法(默认10rpx 0)
+ * @property {String Number} font-size 单元格字体大小,单位rpx(默认28)
+ * @property {String} color 单元格字体颜色(默认#606266)
+ * @property {Object} th-style th单元格的样式,对象形式(将th所需参数放在table组件,是为了避免每一个th组件要写一遍)
+ * @example <u-table></u-table>
+ name: "u-table",
+ // td的内边距
+ default: '10rpx 6rpx'
+ // th的自定义样式
+ thStyle: {
+ // table的背景颜色
+ uTable: this,
+ tableStyle() {
+ style.borderLeft = `solid 1px ${this.borderColor}`;
+ style.borderTop = `solid 1px ${this.borderColor}`;
+ style.backgroundColor = this.bgColor;;
+ .u-table {
@@ -0,0 +1,473 @@
+ <view class="u-tabs" :style="{
+ zIndex: zIndex,
+ background: bgColor
+ <scroll-view scroll-x class="u-scroll-view" :scroll-left="scrollLeft" scroll-with-animation :style="{ zIndex: zIndex + 1 }">
+ <view class="u-tabs-scroll-box" :class="{'u-tabs-scorll-flex': !isScroll}">
+ <view class="u-tabs-item" :style="[tabItemStyle(index)]"
+ v-for="(item, index) in getTabs" :key="index" :class="[preId + index]" @tap="emit(index)">
+ {{ item[name] || item['name']}}
+ <view v-if="showBar" class="u-scroll-bar" :style="[tabBarStyle]"></view>
+ import colorGradient from '../../libs/function/colorGradient';
+ let color = colorGradient;
+ windowWidth
+ } = uni.getSystemInfoSync();
+ const preId = 'UEl_';
+ * tabsSwiper 全屏选项卡
+ * @description 该组件内部实现主要依托于uniapp的scroll-view和swiper组件,主要特色是切换过程中,tabsSwiper文字的颜色可以渐变,底部滑块可以 跟随式滑动,活动tab滚动居中等。应用场景可以用于需要左右切换页面,比如商城的订单中心(待收货-待付款-待评价-已退货)等应用场景。
+ * @tutorial https://www.uviewui.com/components/tabsSwiper.html
+ * @property {Boolean} is-scroll tabs是否可以左右拖动(默认true)
+ * @property {Array} list 标签数组,元素为对象,如[{name: '推荐'}]
+ * @property {String Number} current 指定哪个tab为激活状态(默认0)
+ * @property {String Number} height 导航栏的高度,单位rpx(默认80)
+ * @property {String Number} font-size tab文字大小,单位rpx(默认30)
+ * @property {String Number} swiper-width tabs组件外部swiper的宽度,默认为屏幕宽度,单位rpx(默认750)
+ * @property {String} active-color 滑块和激活tab文字的颜色(默认#2979ff)
+ * @property {String} inactive-color tabs文字颜色(默认#303133)
+ * @property {String Number} bar-width 滑块宽度,单位rpx(默认40)
+ * @property {String Number} bar-height 滑块高度,单位rpx(默认6)
+ * @property {Object} bar-style 底部滑块的样式,对象形式
+ * @property {Object} active-item-style 活动tabs item的样式,对象形式
+ * @property {Boolean} show-bar 是否显示底部的滑块(默认true)
+ * @property {String Number} gutter 单个tab标签的左右内边距之和,单位rpx(默认40)
+ * @property {String} bg-color tabs导航栏的背景颜色(默认#ffffff)
+ * @property {String} name 组件内部读取的list参数中的属性名,见官网说明(默认name)
+ * @event {Function} change 点击标签时触发
+ * @example <u-tabs-swiper ref="tabs" :list="list" :is-scroll="false"></u-tabs-swiper>
+ name: "u-tabs-swiper",
+ // 导航菜单是否需要滚动,如只有2或者3个的时候,就不需要滚动了,此时使用flex平分tab的宽度
+ isScroll: {
+ //需循环的标签列表
+ // 当前活动tab的索引
+ // 导航栏的高度和行高,单位rpx
+ // 过渡动画时长, 单位s
+ // duration: {
+ // default: 0.5
+ swiperWidth: {
+ //line3生效, 外部swiper的宽度, 单位rpx
+ default: 750
+ // 选中项的主题颜色
+ // 未选中项的颜色
+ // 菜单底部移动的bar的宽度,单位rpx
+ barWidth: {
+ // 移动bar的高度
+ barHeight: {
+ // 单个tab的左或右内边距(各占一半),单位rpx
+ // 如果是绝对定位,添加z-index值
+ // 导航栏的背景颜色
+ //滚动至中心目标类型
+ autoCenterMode: {
+ default: 'window'
+ // 读取传入的数组对象的属性
+ default: 'name'
+ // 活动tab字体是否加粗
+ // 当前活动tab item的样式
+ activeItemStyle: {
+ // 是否显示底部的滑块
+ showBar: {
+ // 底部滑块的自定义样式
+ barStyle: {
+ scrollLeft: 0, // 滚动scroll-view的左边滚动距离
+ tabQueryInfo: [], // 存放对tab菜单查询后的节点信息
+ windowWidth: 0, // 屏幕宽度,单位为px
+ //scrollBarLeft: 0, // 移动bar需要通过translateX()移动的距离
+ animationFinishCurrent: this.current,
+ componentsWidth: 0,
+ line3AddDx: 0,
+ line3Dx: 0,
+ preId,
+ sW: 0,
+ tabsInfo: [],
+ colorGradientArr: [],
+ colorStep: 100 // 两个颜色之间的渐变等分
+ // 获取当前活跃的current值
+ getCurrent() {
+ const current = Number(this.current);
+ // 判断是否超出边界
+ if (current > this.getTabs.length - 1) {
+ return this.getTabs.length - 1;
+ if (current < 0) return 0;
+ return current;
+ getTabs() {
+ return this.list;
+ // 滑块需要移动的距离
+ scrollBarLeft() {
+ return Number(this.line3Dx) + Number(this.line3AddDx);
+ // 滑块的宽度转为px单位
+ barWidthPx() {
+ return uni.upx2px(this.barWidth);
+ // tab的样式
+ tabItemStyle() {
+ let style = {
+ lineHeight: this.height + 'rpx',
+ padding: `0 ${this.gutter / 2}rpx`,
+ color: this.tabsInfo.length > 0 ? (this.tabsInfo[index] ? this.tabsInfo[index].color : this.activeColor) : this.inactiveColor,
+ zIndex: this.zIndex + 2,
+ fontWeight: (index == this.getCurrent && this.bold) ? 'bold' : 'normal'
+ if(index == this.getCurrent) {
+ // 给选中的tab item添加外部自定义的样式
+ style = Object.assign(style, this.activeItemStyle);
+ // 底部滑块的样式
+ tabBarStyle() {
+ width: this.barWidthPx + 'px',
+ height: this.barHeight + 'rpx',
+ borderRadius: '100px',
+ backgroundColor: this.activeColor,
+ left: this.scrollBarLeft + 'px'
+ return Object.assign(style, this.barStyle);
+ current(n, o) {
+ this.change(n);
+ this.setFinishCurrent(n);
+ list() {
+ async init() {
+ this.countPx();
+ await this.getTabsInfo();
+ this.countLine3Dx();
+ this.getQuery(() => {
+ this.setScrollViewToCenter();
+ // 颜色渐变过程数组
+ this.colorGradientArr = color.colorGradient(this.inactiveColor, this.activeColor, this.colorStep);
+ return new Promise((resolve, reject) => {
+ view.select('.' + preId + i).boundingClientRect();
+ const arr = [];
+ for (let i = 0; i < res.length; i++) {
+ // 给每个tab添加其文字颜色属性
+ res[i].color = this.inactiveColor;
+ // 当前tab直接赋予activeColor
+ if (i == this.getCurrent) res[i].color = this.activeColor;
+ arr.push(res[i]);
+ this.tabsInfo = arr;
+ // 当swiper滑动结束,计算滑块最终要停留的位置
+ countLine3Dx() {
+ const tab = this.tabsInfo[this.animationFinishCurrent];
+ // 让滑块中心点和当前tab中心重合
+ if (tab) this.line3Dx = tab.left + tab.width / 2 - this.barWidthPx / 2 - this.tabsInfo[0].left;
+ countPx() {
+ // swiper宽度由rpx转为px单位,因为dx等,都是px单位
+ this.sW = uni.upx2px(Number(this.swiperWidth));
+ emit(index) {
+ change() {
+ getQuery(cb) {
+ let view = uni.createSelectorQuery().in(this).select('.u-tabs');
+ view.fields({
+ size: true
+ data => {
+ if (data) {
+ this.componentsWidth = data.width;
+ if (cb && typeof cb === 'function') cb(data);
+ this.getQuery(cb);
+ ).exec();
+ this.componentsWidth = windowWidth;
+ // 把活动tab移动到屏幕中心点
+ setScrollViewToCenter() {
+ let tab;
+ tab = this.tabsInfo[this.animationFinishCurrent];
+ if (tab) {
+ let tabCenter = tab.left + tab.width / 2;
+ let fatherWidth;
+ // 活动tab移动到中心时,以屏幕还是tab组件为宽度为基准
+ if (this.autoCenterMode === 'window') {
+ fatherWidth = windowWidth;
+ fatherWidth = this.componentsWidth;
+ this.scrollLeft = tabCenter - fatherWidth / 2;
+ setDx(dx) {
+ let nextTabIndex = dx > 0 ? this.animationFinishCurrent + 1 : this.animationFinishCurrent - 1;
+ // 判断索引是否超出边界
+ nextTabIndex = nextTabIndex <= 0 ? 0 : nextTabIndex;
+ nextTabIndex = nextTabIndex >= this.list.length ? this.list.length - 1 : nextTabIndex;
+ const tab = this.tabsInfo[nextTabIndex];
+ // 当前tab中心点x轴坐标
+ let nowTab = this.tabsInfo[this.animationFinishCurrent];
+ let nowTabX = nowTab.left + nowTab.width / 2;
+ // 下一个tab
+ let nextTab = this.tabsInfo[nextTabIndex];
+ let nextTabX = nextTab.left + nextTab.width / 2;
+ // 两个tab之间的距离,因为下一个tab可能在当前tab的左边或者右边,取绝对值即可
+ let distanceX = Math.abs(nextTabX - nowTabX);
+ this.line3AddDx = (dx / this.sW) * distanceX;
+ this.setTabColor(this.animationFinishCurrent, nextTabIndex, dx);
+ // 设置tab的颜色
+ setTabColor(nowTabIndex, nextTabIndex, dx) {
+ let colorIndex = Math.abs(Math.ceil((dx / this.sW) * 100));
+ let colorLength = this.colorGradientArr.length;
+ // 处理超出索引边界的情况
+ colorIndex = colorIndex >= colorLength ? colorLength - 1 : colorIndex <= 0 ? 0 : colorIndex;
+ // 设置下一个tab的颜色
+ this.tabsInfo[nextTabIndex].color = this.colorGradientArr[colorIndex];
+ // 设置当前tab的颜色
+ this.tabsInfo[nowTabIndex].color = this.colorGradientArr[colorLength - 1 - colorIndex];
+ // swiper结束滑动
+ setFinishCurrent(current) {
+ // 如果滑动的索引不一致,修改tab颜色变化,因为可能会有直接点击tab的情况
+ this.tabsInfo.map((val, index) => {
+ if (current == index) val.color = this.activeColor;
+ else val.color = this.inactiveColor;
+ this.line3AddDx = 0;
+ this.animationFinishCurrent = current;
+ view,
+ scroll-view {
+ .u-tabs {
+ transition-property: background-color, color;
+ ::-webkit-scrollbar,
+ ::-webkit-scrollbar {
+ width: 0 !important;
+ height: 0 !important;
+ -webkit-appearance: none;
+ background: transparent;
+ // 通过样式穿透,隐藏H5下,scroll-view下的滚动条
+ scroll-view ::v-deep ::-webkit-scrollbar {
+ .u-scroll-view {
+ .u-tabs-scroll-box {
+ .u-tabs-scorll-flex {
+ .u-tabs-scorll-flex .u-tabs-item {
+ .u-tabs-item {
+ transition-property: background-color, color, font-weight;
+ .content {
+ text-overflow: ellipsis;
+ .boxStyle {
+ transition-property: all;
+ .boxStyle2 {
+ transform: translateY(-100%);
+ .itemBackgroundBox {
+ transition-property: left, background-color;
+ .itemBackground {
+ .u-scroll-bar {
+ bottom: 4rpx;
@@ -0,0 +1,347 @@
+ <!-- $u.getRect()对组件根节点无效,因为写了.in(this),故这里获取内层接点尺寸 -->
+ <view :id="id">
+ <scroll-view scroll-x class="u-scroll-view" :scroll-left="scrollLeft" scroll-with-animation>
+ <view class="u-scroll-box" :class="{'u-tabs-scorll-flex': !isScroll}">
+ <view class="u-tab-item u-line-1" :id="'u-tab-item-' + index" v-for="(item, index) in list" :key="index" @tap="clickTab(index)"
+ :style="[tabItemStyle(index)]">
+ <view v-if="showBar" class="u-tab-bar" :style="[tabBarStyle]"></view>
+ * tabs 标签
+ * @description 该组件,是一个tabs标签组件,在标签多的时候,可以配置为左右滑动,标签少的时候,可以禁止滑动。 该组件的一个特点是配置为滚动模式时,激活的tab会自动移动到组件的中间位置。
+ * @tutorial https://www.uviewui.com/components/tabs.html
+ * @property {String Number} duration 滑块移动一次所需的时间,单位秒(默认0.5)
+ * @property {String Number} item-width 标签的宽度(默认auto)
+ * @example <u-tabs ref="tabs" :list="list" :is-scroll="false"></u-tabs>
+ name: "u-tabs",
+ // 导航栏的高度和行高
+ // 过渡动画时长, 单位ms
+ default: 0.5
+ // 单个tab的左或有内边距(左右相同)
+ // 标签的宽度
+ itemWidth: {
+ componentWidth: 0, // 屏幕宽度,单位为px
+ scrollBarLeft: 0, // 移动bar需要通过translateX()移动的距离
+ parentLeft: 0, // 父元素(tabs组件)到屏幕左边的距离
+ id: this.$u.guid(), // id值
+ barFirstTimeMove: true, // 滑块第一次移动时(页面刚生成时),无需动画,否则给人怪异的感觉
+ // 监听tab的变化,重新计算tab菜单的布局信息,因为实际使用中菜单可能是通过
+ // 后台获取的(如新闻app顶部的菜单),获取返回需要一定时间,所以list变化时,重新获取布局信息
+ list(n, o) {
+ // list变动时,重制内部索引,否则可能导致超出数组边界的情况
+ if(n.length !== o.length) this.currentIndex = 0;
+ // 用$nextTick等待视图更新完毕后再计算tab的局部信息,否则可能因为tab还没生成就获取,就会有问题
+ // 视图更新后再执行移动操作
+ this.scrollByIndex();
+ // 移动bar的样式
+ width: this.barWidth + 'rpx',
+ transform: `translate(${this.scrollBarLeft}px, -100%)`,
+ // 滑块在页面渲染后第一次滑动时,无需动画效果
+ 'transition-duration': `${this.barFirstTimeMove ? 0 : this.duration }s`,
+ 'background-color': this.activeColor,
+ // 设置一个很大的值,它会自动取能用的最大值,不用高度的一半,是因为高度可能是单数,会有小数出现
+ 'border-radius': `${this.barHeight / 2}px`
+ Object.assign(style, this.barStyle);
+ 'line-height': this.height + 'rpx',
+ 'font-size': this.fontSize + 'rpx',
+ 'transition-duration': `${this.duration}s`,
+ padding: this.isScroll ? `0 ${this.gutter}rpx` : '',
+ flex: this.isScroll ? 'auto' : '1',
+ width: this.$u.addUnit(this.itemWidth)
+ // 设置一个init方法,方便多处调用
+ // 获取tabs组件的尺寸信息
+ let tabRect = await this.$uGetRect('#' + this.id);
+ // tabs组件距离屏幕左边的宽度
+ this.parentLeft = tabRect.left;
+ // tabs组件的宽度
+ this.componentWidth = tabRect.width;
+ this.getTabRect();
+ // 点击某一个tab菜单
+ clickTab(index) {
+ // 点击当前活动tab,不触发事件
+ if(index == this.currentIndex) return ;
+ // 发送事件给父组件
+ // 查询tab的布局信息
+ getTabRect() {
+ // 创建节点查询
+ let query = uni.createSelectorQuery().in(this);
+ // 历遍所有tab,这里是执行了查询,最终使用exec()会一次性返回查询的数组结果
+ // 只要size和rect两个参数
+ query.select(`#u-tab-item-${i}`).fields({
+ size: true,
+ rect: true
+ // 执行查询,一次性获取多个结果
+ query.exec(
+ function(res) {
+ this.tabQueryInfo = res;
+ // 初始化滚动条和移动bar的位置
+ }.bind(this)
+ // 滚动scroll-view,让活动的tab处于屏幕的中间位置
+ scrollByIndex() {
+ // 当前活动tab的布局信息,有tab菜单的width和left(为元素左边界到父元素左边界的距离)等信息
+ let tabInfo = this.tabQueryInfo[this.currentIndex];
+ if (!tabInfo) return;
+ // 活动tab的宽度
+ let tabWidth = tabInfo.width;
+ // 活动item的左边到tabs组件左边的距离,用item的left减去tabs的left
+ let offsetLeft = tabInfo.left - this.parentLeft;
+ // 将活动的tabs-item移动到屏幕正中间,实际上是对scroll-view的移动
+ let scrollLeft = offsetLeft - (this.componentWidth - tabWidth) / 2;
+ this.scrollLeft = scrollLeft < 0 ? 0 : scrollLeft;
+ // 当前活动item的中点点到左边的距离减去滑块宽度的一半,即可得到滑块所需的移动距离
+ let left = tabInfo.left + tabInfo.width / 2 - this.parentLeft;
+ this.scrollBarLeft = left - uni.upx2px(this.barWidth) / 2;
+ // 第一次移动滑块的时候,barFirstTimeMove为true,放到延时中将其设置false
+ // 延时是因为scrollBarLeft作用于computed计算时,需要一个过程需,否则导致出错
+ if(this.barFirstTimeMove == true) {
+ this.barFirstTimeMove = false;
+ .u-scroll-box {
+ .u-tab-item {
+ .u-tab-bar {
@@ -0,0 +1,292 @@
+ <view v-if="show" :class="[
+ disabled ? 'u-disabled' : '',
+ 'u-shape-' + shape,
+ 'u-mode-' + mode + '-' + type
+ class="u-tag" :style="[customStyle]" @tap="clickTag">
+ {{text}}
+ <view class="u-icon-wrap" @tap.stop>
+ <u-icon @click="close" size="22" v-if="closeable" :color="closeIconColor"
+ name="close" class="u-close-icon" :style="[iconStyle]"></u-icon>
+ * tag 提示
+ * @description 该组件一般用于标记和选择
+ * @tutorial https://www.uviewui.com/components/tag.html
+ * @property {String} type 主题类型(默认primary)
+ * @property {String} size 标签大小(默认default)
+ * @property {String} shape 标签形状(默认square)
+ * @property {String} text 标签的文字内容
+ * @property {String} bg-color 自定义标签的背景颜色
+ * @property {String} border-color 标签的边框颜色
+ * @property {String} close-color 关闭按钮的颜色
+ * @property {String Number} index 点击标签时,会通过click事件返回该值
+ * @property {String} mode 模式选择,见官网说明(默认light)
+ * @property {Boolean} closeable 是否可关闭,设置为true,文字右边会出现一个关闭图标(默认false)
+ * @property {Boolean} show 标签显示与否(默认true)
+ * @event {Function} click 点击标签触发
+ * @event {Function} close closeable为true时,点击标签关闭按钮触发
+ * @example <u-tag text="雪月夜" type="success" />
+ name: 'u-tag',
+ // 是否禁用这个标签,禁用的话,会屏蔽点击事件
+ // 标签类型info、primary、success、warning、error
+ // 标签的大小,分为default(默认),mini(较小)
+ // tag的形状,circle(两边半圆形), square(方形,带圆角),circleLeft(左边是半圆),circleRight(右边是半圆)
+ // 标签文字
+ // 背景颜色,默认为空字符串,即不处理
+ // 标签字体颜色,默认为空字符串,即不处理
+ // 镂空形式标签的边框颜色
+ // 关闭按钮图标的颜色
+ closeColor: {
+ // 点击时返回的索引值,用于区分例遍的数组哪个元素被点击了
+ // 模式选择,dark|light|plain
+ default: 'light'
+ customStyle() {
+ // 文字颜色(如果有此值,会覆盖type值的颜色)
+ if(this.color) style.color = this.color+"!important";
+ // tag的背景颜色(如果有此值,会覆盖type值的颜色)
+ if(this.bgColor) style.backgroundColor = this.bgColor+"!important";
+ // 如果是镂空型tag,没有传递边框颜色(borderColor)的话,使用文字的颜色(color属性)
+ if(this.mode == 'plain' && this.color && !this.borderColor) style.borderColor = this.color;
+ else style.borderColor = this.borderColor;
+ if(!this.closeable) return ;
+ if(this.size == 'mini') style.fontSize = '20rpx';
+ else style.fontSize = '22rpx';
+ if(this.mode == 'plain' || this.mode == 'light') style.color = this.type;
+ else if(this.mode == 'dark') style.color = "#ffffff";
+ if(this.closeColor) style.color = this.closeColor;
+ closeIconColor() {
+ // 如果定义了关闭图标的颜色,就用此值,否则用字体颜色的值
+ // 如果上面的二者都没有,如果是dark深色模式,图标就为白色
+ // 最后如果上面的三者都不合适,就返回type值给图标获取颜色
+ let color = '';
+ if(this.closeColor) return this.closeColor;
+ else if(this.color) return this.color;
+ else if(this.mode == 'dark') return '#ffffff';
+ // 标签被点击
+ clickTag() {
+ // 如果是disabled状态,不发送点击事件
+ if(this.disabled) return ;
+ // 点击标签关闭按钮
+ .u-tag {
+ .u-size-default {
+ padding: 12rpx 22rpx;
+ .u-size-mini {
+ padding: 6rpx 12rpx;
+ .u-mode-light-primary {
+ border: 1px solid $u-type-primary-disabled;
+ .u-mode-light-success {
+ border: 1px solid $u-type-success-disabled;
+ .u-mode-light-error {
+ border: 1px solid $u-type-error-disabled;
+ .u-mode-light-warning {
+ border: 1px solid $u-type-warning-disabled;
+ .u-mode-light-info {
+ border: 1px solid $u-type-info-disabled;
+ .u-mode-dark-primary {
+ color: #FFFFFF;
+ .u-mode-dark-success {
+ .u-mode-dark-error {
+ .u-mode-dark-warning {
+ .u-mode-dark-info {
+ .u-mode-plain-primary {
+ border: 1px solid $u-type-primary;
+ .u-mode-plain-success {
+ border: 1px solid $u-type-success;
+ .u-mode-plain-error {
+ border: 1px solid $u-type-error;
+ .u-mode-plain-warning {
+ border: 1px solid $u-type-warning;
+ .u-mode-plain-info {
+ border: 1px solid $u-type-info;
+ .u-disabled {
+ opacity: 0.55;
+ .u-shape-circle {
+ .u-shape-circleRight {
+ border-radius: 0 100rpx 100rpx 0;
+ .u-shape-circleLeft {
+ border-radius: 100rpx 0 0 100rpx;
+ .u-icon-wrap {
+ transform: scale(0.86);
+ <view class="u-td" :style="[tdStyle]">
+ * td td单元格
+ * @description 表格组件一般用于展示大量结构化数据的场景(搭配u-table使用)
+ * @tutorial https://www.uviewui.com/components/table.html#td-props
+ * @property {String Number} width 单元格宽度百分比或者具体带单位的值,如30%, 200rpx等,一般使用百分比,单元格宽度默认为均分tr的长度(默认auto)
+ * @example <u-td>二年级</u-td>
+ name: "u-td",
+ // 宽度,百分比或者具体带单位的值,如30%, 200rpx等,一般使用百分比
+ inject: ['uTable'],
+ tdStyle() {
+ if (this.width != "auto") style.flex = `0 0 ${this.width}`;
+ style.textAlign = this.uTable.align;
+ style.fontSize = this.uTable.fontSize + 'rpx';
+ style.padding = this.uTable.padding;
+ style.borderBottom = `solid 1px ${this.uTable.borderColor}`;
+ style.borderRight = `solid 1px ${this.uTable.borderColor}`;
+ style.color = this.uTable.color;
+ .u-td {
+ align-self: stretch;
+ flex: 0 0 calc(100%/12);
+ flex: 0 0 calc(100%/12 * 2);
+ flex: 0 0 calc(100%/12 * 3);
+ flex: 0 0 calc(100%/12 * 4);
+ flex: 0 0 calc(100%/12 * 5);
+ flex: 0 0 calc(100%/12 * 6);
+ flex: 0 0 calc(100%/12 * 7);
+ flex: 0 0 calc(100%/12 * 8);
+ flex: 0 0 calc(100%/12 * 9);
+ flex: 0 0 calc(100%/12 * 10);
+ flex: 0 0 calc(100%/12 * 11);
+ flex: 0 0 calc(100%/12 * 12);
@@ -0,0 +1,53 @@
+ <view class="u-th" :style="[thStyle]">
+ * th th单元格
+ * @property {String Number} width 标题单元格宽度百分比或者具体带单位的值,如30%,200rpx等,一般使用百分比,单元格宽度默认为均分tr的长度
+ * @example 暂无示例
+ name: "u-th",
+ thStyle() {
+ if (this.width) style.flex = `0 0 ${this.width}`;
+ Object.assign(style, this.uTable.thStyle);
+ .u-th {
+ background-color: rgb(245, 246, 248);
@@ -0,0 +1,83 @@
+ <view class="u-time-axis-item">
+ <slot name="content" />
+ <view class="u-time-axis-node" :style="[nodeStyle]">
+ <slot name="node">
+ <view class="u-dot">
+ </slot>
+ * timeLineItem 时间轴Item
+ * @description 时间轴组件一般用于物流信息展示,各种跟时间相关的记录等场景。(搭配u-time-line使用)
+ * @tutorial https://www.uviewui.com/components/timeLine.html
+ * @property {String} bg-color 左边节点的背景颜色,一般通过slot内容自定义背景颜色即可(默认#ffffff)
+ * @property {String Number} node-top 节点左边图标绝对定位的top值,单位rpx
+ * @example <u-time-line-item node-top="2">...</u-time-line-item>
+ name: "u-time-line-item",
+ // 节点的背景颜色
+ default: "#ffffff"
+ // 节点左边图标绝对定位的top值
+ nodeTop: {
+ default: ""
+ nodeStyle() {
+ if (this.nodeTop != "") style.top = this.nodeTop + 'rpx';
+ .u-time-axis-item {
+ margin-bottom: 32rpx;
+ .u-time-axis-node {
+ top: 12rpx;
+ left: -40rpx;
+ transform-origin: 0;
+ background: #ddd;
@@ -0,0 +1,43 @@
+ <view class="u-time-axis">
+ * timeLine 时间轴
+ * @description 时间轴组件一般用于物流信息展示,各种跟时间相关的记录等场景。
+ * @example <u-time-line></u-time-line>
+ name: "u-time-line",
+ .u-time-axis {
+ .u-time-axis::before {
+ content: " ";
+ width: 1px;
+ border-left: 1px solid #ddd;
+ transform: scaleX(0.5);
@@ -0,0 +1,222 @@
+ <view class="u-toast" :class="[isShow ? 'u-show' : '', 'u-type-' + config.type, 'u-position-' + config.position]" :style="{
+ <u-icon v-if="config.icon" class="u-icon" :name="iconName" :size="30" :color="config.type"></u-icon>
+ <text class="u-text">{{config.title}}</text>
+ * toast 消息提示
+ * @description 此组件表现形式类似uni的uni.showToastAPI,但也有不同的地方。
+ * @tutorial https://www.uviewui.com/components/toast.html
+ * @property {String} z-index toast展示时的z-index值
+ * @event {Function} show 显示toast,如需一进入页面就显示toast,请在onReady生命周期调用
+ * @example <u-toast ref="uToast" />
+ name: "u-toast",
+ timer: null, // 定时器
+ config: {
+ params: {}, // URL跳转的参数,对象
+ title: '', // 显示文本
+ type: '', // 主题类型,primary,success,error,warning,black
+ duration: 2000, // 显示的时间,毫秒
+ isTab: false, // 是否跳转tab页面
+ url: '', // toast消失后是否跳转页面,有则跳转,优先级高于back参数
+ icon: true, // 显示的图标
+ position: 'center', // toast出现的位置
+ callback: null, // 执行完后的回调函数
+ back: false, // 结束toast是否自动返回上一页
+ iconName() {
+ // 只有不为none,并且type为error|warning|succes|info时候,才显示图标
+ if (['error', 'warning', 'success', 'info'].indexOf(this.config.type) >= 0 && this.config.icon) {
+ let icon = this.$u.type2icon(this.config.type);
+ return icon;
+ // 显示toast时候,如果用户有传递z-index值,有限使用
+ return this.isShow ? (this.zIndex ? this.zIndex : this.$u.zIndex.toast) : '999999';
+ // 显示toast组件,由父组件通过this.$refs.xxx.show(options)形式调用
+ show(options) {
+ this.config = this.$u.deepMerge(this.config, options);
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.isShow = true;
+ // 倒计时结束,清除定时器,隐藏toast组件
+ this.isShow = false;
+ // 判断是否存在callback方法,如果存在就执行
+ typeof(this.config.callback) === 'function' && this.config.callback();
+ this.timeEnd();
+ }, this.config.duration);
+ // 隐藏toast组件,由父组件通过this.$refs.xxx.hide()形式调用
+ hide() {
+ // 倒计时结束之后,进行的一些操作
+ timeEnd() {
+ // 如果带有url值,根据isTab为true或者false进行跳转
+ if (this.config.url) {
+ // 如果url没有"/"开头,添加上,因为uni的路由跳转需要"/"开头
+ if (this.config.url[0] != '/') this.config.url = '/' + this.config.url;
+ // 判断是否有传递显式的参数
+ if (Object.keys(this.config.params).length) {
+ // 判断用户传递的url中,是否带有参数
+ // 使用正则匹配,主要依据是判断是否有"/","?","="等,如“/page/index/index?name=mary"
+ // 如果有params参数,转换后无需带上"?"
+ if (/.*\/.*\?.*=.*/.test(this.config.url)) {
+ // object对象转为get类型的参数
+ query = this.$u.queryParams(this.config.params, false);
+ this.config.url = this.config.url + "&" + query;
+ query = this.$u.queryParams(this.config.params);
+ this.config.url += query;
+ // 如果是跳转tab页面,就使用uni.switchTab
+ if (this.config.isTab) {
+ url: this.config.url
+ } else if(this.config.back) {
+ // 回退到上一页
+ .u-toast {
+ transition: opacity 0.3s;
+ background: #585858;
+ padding:0 40rpx;
+ .u-toast.u-show {
+ .u-text {
+ .u-position-center {
+ transform: translateX(-50%) translateY(-50%);
+ .u-position-top {
+ top: 20%;
+ .u-position-bottom {
+ bottom: 20%;
+ .u-type-primary {
+ border: 1px solid rgb(215, 234, 254);
+ .u-type-success {
+ border: 1px solid #BEF5C8;
+ .u-type-error {
+ border: 1px solid #fde2e2;
+ .u-type-warning {
+ border: 1px solid #faecd8;
+ .u-type-info {
+ border: 1px solid #ebeef5;
+ .u-type-default {
+ background-color: #585858;
@@ -0,0 +1,121 @@
+ <view class="u-tips" :class="['u-' + type, isShow ? 'u-tip-show' : '']" :style="{
+ top: navbarHeight + 'px',
+ }">{{ title }}</view>
+ * topTips 顶部提示
+ * @description 该组件一般用于页面顶部向下滑出一个提示,尔后自动收起的场景。
+ * @tutorial https://www.uviewui.com/components/topTips.html
+ * @property {String Number} navbar-height 导航栏高度(包含状态栏高度在内),单位PX
+ * @property {String Number} z-index z-index值(默认975)
+ * @example <u-top-tips ref="uTips"></u-top-tips>
+ name: "u-top-tips",
+ // 导航栏高度,用于提示的初始化
+ navbarHeight: {
+ default: 44,
+ isShow: false, // 是否显示消息组件
+ title: '', // 组件中显示的消息内容
+ type: 'primary', // 消息的类型(颜色不同),primary,success,error,warning,info
+ duration: 2000, // 组件显示的时间,单位为毫秒
+ return this.zIndex ? this.zIndex : this.$u.zIndex.topTips;
+ show(config = {}) {
+ // 先清除定时器(可能是上一次定义的,需要清除了再开始新的)
+ // 时间,内容,类型主题(type)等参数
+ if (config.duration) this.duration = config.duration;
+ if (config.type) this.type = config.type;
+ this.title = config.title;
+ }, this.duration);
+ view {
+ // 顶部弹出类型样式
+ padding: 20rpx 30rpx;
+ // 此处为最核心点,translateY(-100%)意味着将其从Y轴隐藏(隐藏到顶部(h5)或者说导航栏(app)下面)
+ transition: all 0.35s linear;
+ .u-tip-show {
+ .u-primary {
+ .u-success {
+ background: $u-type-success;
+ .u-warning {
+ background: $u-type-warning;
+ .u-error {
+ background: $u-type-error;
+ <view class="u-tr">
+ * tr 表格行标签
+ * @description 表格组件一般用于展示大量结构化数据的场景(搭配<u-table>使用)
+ * @example <u-tr></u-tr>
+ name: "u-tr",
+ .u-tr {
@@ -0,0 +1,614 @@
+ <view class="u-upload" v-if="!disabled">
+ v-if="showUploadList"
+ class="u-list-item u-preview-wrap"
+ v-for="(item, index) in lists"
+ width: $u.addUnit(width),
+ v-if="deletable"
+ class="u-delete-icon"
+ @tap.stop="deleteItem(index)"
+ background: delBgColor
+ <u-icon class="u-icon" :name="delIcon" size="20" :color="delColor"></u-icon>
+ <u-line-progress
+ v-if="showProgress && item.progress > 0 && !item.error"
+ :show-percent="false"
+ height="16"
+ class="u-progress"
+ :percent="item.progress"
+ ></u-line-progress>
+ <view @tap.stop="retry(index)" v-if="item.error" class="u-error-btn">点击重试</view>
+ <image @tap.stop="doPreviewImage(item.url || item.path, index)" class="u-preview-image" v-if="!item.isImage" :src="item.url || item.path" :mode="imageMode"></image>
+ <slot name="file" :file="lists"></slot>
+ <view style="display: inline-block;" @tap="selectFile" v-if="maxCount > lists.length">
+ <slot name="addBtn"></slot>
+ v-if="!customBtn"
+ class="u-list-item u-add-wrap"
+ hover-class="u-add-wrap__hover"
+ <u-icon name="plus" class="u-add-btn" size="40"></u-icon>
+ <view class="u-add-tips">{{ uploadText }}</view>
+ * upload 图片上传
+ * @description 该组件用于上传图片场景
+ * @tutorial https://www.uviewui.com/components/upload.html
+ * @property {String} action 服务器上传地址
+ * @property {String Number} max-count 最大选择图片的数量(默认99)
+ * @property {Boolean} custom-btn 如果需要自定义选择图片的按钮,设置为true(默认false)
+ * @property {Boolean} show-progress 是否显示进度条(默认true)
+ * @property {Boolean} disabled 是否启用(显示/移仓)组件(默认false)
+ * @property {String} image-mode 预览图片等显示模式,可选值为uni的image的mode属性值(默认aspectFill)
+ * @property {String} del-icon 右上角删除图标名称,只能为uView内置图标
+ * @property {String} del-bg-color 右上角关闭按钮的背景颜色
+ * @property {String | Number} index 在各个回调事件中的最后一个参数返回,用于区别是哪一个组件的事件
+ * @property {String} del-color 右上角关闭按钮图标的颜色
+ * @property {Object} header 上传携带的头信息,对象形式
+ * @property {Object} form-data 上传额外携带的参数
+ * @property {String} name 上传文件的字段名,供后端获取使用(默认file)
+ * @property {Array<String>} size-type original 原图,compressed 压缩图,默认二者都有(默认['original', 'compressed'])
+ * @property {Array<String>} source-type 选择图片的来源,album-从相册选图,camera-使用相机,默认二者都有(默认['album', 'camera'])
+ * @property {Boolean} preview-full-image 是否可以通过uni.previewImage预览已选择的图片(默认true)
+ * @property {Boolean} multiple 是否开启图片多选,部分安卓机型不支持(默认true)
+ * @property {Boolean} deletable 是否显示删除图片的按钮(默认true)
+ * @property {String Number} max-size 选择单个文件的最大大小,单位B(byte),默认不限制(默认Number.MAX_VALUE)
+ * @property {Array<Object>} file-list 默认显示的图片列表,数组元素为对象,必须提供url属性
+ * @property {Boolean} upload-text 选择图片按钮的提示文字(默认“选择图片”)
+ * @property {Boolean} auto-upload 选择完图片是否自动上传,见上方说明(默认true)
+ * @property {Boolean} show-tips 特殊情况下是否自动提示toast,见上方说明(默认true)
+ * @property {Boolean} show-upload-list 是否显示组件内部的图片预览(默认true)
+ * @event {Function} on-oversize 图片大小超出最大允许大小
+ * @event {Function} on-preview 全屏预览图片时触发
+ * @event {Function} on-remove 移除图片时触发
+ * @event {Function} on-success 图片上传成功时触发
+ * @event {Function} on-change 图片上传后,无论成功或者失败都会触发
+ * @event {Function} on-error 图片上传失败时触发
+ * @event {Function} on-progress 图片上传过程中的进度变化过程触发
+ * @event {Function} on-uploaded 所有图片上传完毕触发
+ * @event {Function} on-choose-complete 每次选择图片后触发,只是让外部可以得知每次选择后,内部的文件列表
+ * @example <u-upload :action="action" :file-list="fileList" ></u-upload>
+ name: 'u-upload',
+ //是否显示组件自带的图片预览功能
+ showUploadList: {
+ // 后端地址
+ action: {
+ // 最大上传数量
+ maxCount: {
+ default: 52
+ // 是否显示进度条
+ showProgress: {
+ // 是否启用
+ // 预览上传的图片时的裁剪模式,和image组件mode属性一致
+ imageMode: {
+ // 头部信息
+ header: {
+ // 额外携带的参数
+ formData: {
+ // 上传的文件字段名
+ default: 'file'
+ // 所选的图片的尺寸, 可选值为original compressed
+ sizeType: {
+ return ['original', 'compressed'];
+ sourceType: {
+ return ['album', 'camera'];
+ // 是否在点击预览图后展示全屏图片预览
+ previewFullImage: {
+ // 是否开启图片多选,部分安卓机型不支持
+ multiple: {
+ // 是否展示删除按钮
+ deletable: {
+ // 文件大小限制,单位为byte
+ maxSize: {
+ default: Number.MAX_VALUE
+ // 显示已上传的文件列表
+ fileList: {
+ // 上传区域的提示文字
+ uploadText: {
+ default: '选择图片'
+ // 是否自动上传
+ autoUpload: {
+ // 是否显示toast消息提示
+ // 是否通过slot自定义传入选择图标的按钮
+ customBtn: {
+ // 内部预览图片区域和选择图片按钮的区域宽度
+ // 内部预览图片区域和选择图片按钮的区域高度
+ // 右上角关闭按钮的背景颜色
+ delBgColor: {
+ default: '#fa3534'
+ // 右上角关闭按钮的叉号图标的颜色
+ delColor: {
+ // 右上角删除图标名称,只能为uView内置图标
+ delIcon: {
+ // 如果上传后的返回值为json字符串,是否自动转json
+ toJson: {
+ // 上传前的钩子,每个文件上传前都会执行
+ beforeUpload: {
+ // 允许上传的图片后缀
+ limitType:{
+ return ['png', 'jpg', 'jpeg', 'webp', 'gif'];
+ // 在各个回调事件中的最后一个参数返回,用于区别是哪一个组件的事件
+ mounted() {},
+ lists: [],
+ isInCount: true,
+ uploading: false
+ val.map(value => {
+ // 首先检查内部是否已经添加过这张图片,因为外部绑定了一个对象给fileList的话(对象引用),进行修改外部fileList
+ // 时,会触发watch,导致重新把原来的图片再次添加到this.lists
+ // 数组的some方法意思是,只要数组元素有任意一个元素条件符合,就返回true,而另一个数组的every方法的意思是数组所有元素都符合条件才返回true
+ let tmp = this.lists.some(val => {
+ return val.url == value.url;
+ // 如果内部没有这个图片(tmp为false),则添加到内部
+ !tmp && this.lists.push({ url: value.url, error: false, progress: 100 });
+ // 监听lists的变化,发出事件
+ lists(n) {
+ this.$emit('on-list-change', n, this.index);
+ // 清除列表
+ this.lists = [];
+ // 重新上传队列中上传失败的所有文件
+ reUpload() {
+ this.uploadFile();
+ // 选择图片
+ selectFile() {
+ const { name = '', maxCount, multiple, maxSize, sizeType, lists, camera, compressed, maxDuration, sourceType } = this;
+ let chooseFile = null;
+ const newMaxCount = maxCount - lists.length;
+ // 设置为只选择图片的时候使用 chooseImage 来实现
+ chooseFile = new Promise((resolve, reject) => {
+ count: multiple ? (newMaxCount > 9 ? 9 : newMaxCount) : 1,
+ sourceType: sourceType,
+ sizeType,
+ success: resolve,
+ fail: reject
+ chooseFile
+ .then(res => {
+ let file = null;
+ let listOldLength = this.lists.length;
+ res.tempFiles.map((val, index) => {
+ // 检查文件后缀是否允许,如果不在this.limitType内,就会返回false
+ if(!this.checkFileExt(val)) return ;
+ // 如果是非多选,index大于等于1或者超出最大限制数量时,不处理
+ if (!multiple && index >= 1) return;
+ if (val.size > maxSize) {
+ this.$emit('on-oversize', val, this.lists, this.index);
+ this.showToast('超出允许的文件大小');
+ if (maxCount <= lists.length) {
+ this.$emit('on-exceed', val, this.lists, this.index);
+ this.showToast('超出最大允许的文件个数');
+ lists.push({
+ url: val.path,
+ progress: 0,
+ error: false,
+ file: val
+ // 每次图片选择完,抛出一个事件,并将当前内部选择的图片数组抛出去
+ this.$emit('on-choose-complete', this.lists, this.index);
+ if (this.autoUpload) this.uploadFile(listOldLength);
+ .catch(error => {
+ // this.$emit('on-error', error);
+ // 提示用户消息
+ showToast(message, force = false) {
+ if (this.showTips || force) {
+ title: message,
+ // 该方法供用户通过ref调用,手动上传
+ upload() {
+ // 对失败的图片重新上传
+ retry(index) {
+ this.lists[index].progress = 0;
+ this.lists[index].error = false;
+ this.lists[index].response = null;
+ uni.showLoading({
+ title: '重新上传'
+ this.uploadFile(index);
+ // 上传图片
+ async uploadFile(index = 0) {
+ if (this.uploading) return;
+ // 全部上传完成
+ if (index >= this.lists.length) {
+ this.$emit('on-uploaded', this.lists, this.index);
+ // 检查是否是已上传或者正在上传中
+ if (this.lists[index].progress == 100) {
+ if (this.autoUpload == false) this.uploadFile(index + 1);
+ // 执行before-upload钩子
+ if(this.beforeUpload && typeof(this.beforeUpload) === 'function') {
+ // 执行回调,同时传入索引和文件列表当作参数
+ // 因为upload组件可能会被嵌套在其他组件内,比如u-form,这时this.$parent其实为u-form的this,
+ // 非页面的this,所以这里需要往上历遍,一直寻找到最顶端的$parent,这里用了this.$u.$parent.call(this)
+ // 明白意思即可,无需纠结this.$u.$parent.call(this)的细节
+ let beforeResponse = this.beforeUpload.bind(this.$u.$parent.call(this))(index, this.lists);
+ if (!!beforeResponse && typeof beforeResponse.then === 'function') {
+ await beforeResponse.then(res => {
+ // promise返回成功,不进行动作,继续上传
+ // 进入catch回调的话,继续下一张
+ return this.uploadFile(index + 1);
+ } else if(beforeResponse === false) {
+ // 如果返回false,继续下一张图片的上传
+ // 检查上传地址
+ if (!this.action) {
+ this.showToast('请配置上传地址', true);
+ this.uploading = true;
+ // 创建上传对象
+ const task = uni.uploadFile({
+ url: this.action,
+ filePath: this.lists[index].url,
+ name: this.name,
+ formData: this.formData,
+ header: this.header,
+ // 判断是否json字符串,将其转为json格式
+ let data = this.toJson && this.$u.test.jsonString(res.data) ? JSON.parse(res.data) : res.data;
+ if (![200, 201, 204].includes(res.statusCode)) {
+ this.uploadError(index, data);
+ // 上传成功
+ this.lists[index].response = data;
+ this.lists[index].progress = 100;
+ this.$emit('on-success', data, index, this.lists, this.index);
+ fail: e => {
+ this.uploadError(index, e);
+ complete: res => {
+ uni.hideLoading();
+ this.uploading = false;
+ this.uploadFile(index + 1);
+ this.$emit('on-change', res, index, this.lists, this.index);
+ task.onProgressUpdate(res => {
+ if (res.progress > 0) {
+ this.lists[index].progress = res.progress;
+ this.$emit('on-progress', res, index, this.lists, this.index);
+ // 上传失败
+ uploadError(index, err) {
+ this.lists[index].error = true;
+ this.$emit('on-error', err, index, this.lists, this.index);
+ this.showToast('上传失败,请重试');
+ // 删除一个图片
+ deleteItem(index) {
+ title: '提示',
+ content: '您确定要删除此项吗?',
+ if (res.confirm) {
+ if (this.lists[index].process < 100 && this.lists[index].process > 0) {
+ typeof this.lists[index].uploadTask != 'undefined' && this.lists[index].uploadTask.abort();
+ this.lists.splice(index, 1);
+ this.$emit('on-remove', index, this.lists, this.index);
+ this.showToast('移除成功');
+ // 用户通过ref手动的形式,移除一张图片
+ remove(index) {
+ // 判断索引的合法范围
+ if (index >= 0 && index < this.lists.length) {
+ this.$emit('on-list-change', this.lists, this.index);
+ // 预览图片
+ doPreviewImage(url, index) {
+ if (!this.previewFullImage) return;
+ const images = this.lists.map(item => item.url || item.path);
+ urls: images,
+ current: url,
+ success: () => {
+ this.$emit('on-preview', url, this.lists, this.index);
+ fail: () => {
+ title: '预览图片失败',
+ // 判断文件后缀是否允许
+ checkFileExt(file) {
+ // 检查是否在允许的后缀中
+ let noArrowExt = false;
+ // 获取后缀名
+ let fileExt = '';
+ const reg = /.+\./;
+ // 如果是H5,需要从name中判断
+ fileExt = file.name.replace(reg, "").toLowerCase();
+ // 非H5,需要从path中读取后缀
+ fileExt = file.path.replace(reg, "").toLowerCase();
+ // 使用数组的some方法,只要符合limitType中的一个,就返回true
+ noArrowExt = this.limitType.some(ext => {
+ // 转为小写
+ return ext.toLowerCase() === fileExt;
+ if(!noArrowExt) this.showToast(`不允许选择${fileExt}格式的文件`);
+ return noArrowExt;
+.u-upload {
+.u-list-item {
+ width: 200rpx;
+ height: 200rpx;
+ margin: 10rpx;
+ background: rgb(244, 245, 246);
+ border-radius: 10rpx;
+.u-preview-wrap {
+ border: 1px solid rgb(235, 236, 238);
+.u-add-wrap {
+.u-add-tips {
+ line-height: 40rpx;
+.u-add-wrap__hover {
+ background-color: rgb(235, 236, 238);
+.u-preview-image {
+.u-delete-icon {
+ right: 10rpx;
+ width: 44rpx;
+ height: 44rpx;
+.u-progress {
+ bottom: 10rpx;
+ left: 8rpx;
+ right: 8rpx;
+.u-error-btn {
+ padding: 4px 0;
@@ -0,0 +1,164 @@
+ <view class="u-code-wrap">
+ <!-- 此组件功能由js完成,无需写html逻辑 -->
+ * verificationCode 验证码输入框
+ * @description 考虑到用户实际发送验证码的场景,可能是一个按钮,也可能是一段文字,提示语各有不同,所以本组件 不提供界面显示,只提供提示语,由用户将提示语嵌入到具体的场景
+ * @tutorial https://www.uviewui.com/components/verificationCode.html
+ * @property {Number String} seconds 倒计时所需的秒数(默认60)
+ * @property {String} start-text 开始前的提示语,见官网说明(默认获取验证码)
+ * @property {String} change-text 倒计时期间的提示语,必须带有字母"x",见官网说明(默认X秒重新获取)
+ * @property {String} end-text 倒计结束的提示语,见官网说明(默认重新获取)
+ * @property {Boolean} keep-running 是否在H5刷新或各端返回再进入时继续倒计时(默认false)
+ * @event {Function} change 倒计时期间,每秒触发一次
+ * @event {Function} start 开始倒计时触发
+ * @event {Function} end 结束倒计时触发
+ * @example <u-verification-code :seconds="seconds" @end="end" @start="start" ref="uCode"
+ name: "u-verification-code",
+ // 倒计时总秒数
+ seconds: {
+ // 尚未开始时提示
+ default: '获取验证码'
+ // 正在倒计时中的提示
+ changeText: {
+ default: 'X秒重新获取'
+ // 倒计时结束时的提示
+ default: '重新获取'
+ // 是否在H5刷新或各端返回再进入时继续倒计时
+ keepRunning: {
+ // 为了区分多个页面,或者一个页面多个倒计时组件本地存储的继续倒计时变了
+ uniqueKey: {
+ secNum: this.seconds,
+ canGetCode: true, // 是否可以执行验证码操作
+ this.checkKeepRunning();
+ handler(n) {
+ this.secNum = n;
+ checkKeepRunning() {
+ // 获取上一次退出页面(H5还包括刷新)时的时间戳,如果没有上次的保存,此值可能为空
+ let lastTimestamp = Number(uni.getStorageSync(this.uniqueKey + '_$uCountDownTimestamp'));
+ if(!lastTimestamp) return this.changeEvent(this.startText);
+ // 当前秒的时间戳
+ let nowTimestamp = Math.floor((+ new Date()) / 1000);
+ // 判断当前的时间戳,是否小于上一次的本该按设定结束,却提前结束的时间戳
+ if(this.keepRunning && lastTimestamp && lastTimestamp > nowTimestamp) {
+ // 剩余尚未执行完的倒计秒数
+ this.secNum = lastTimestamp - nowTimestamp;
+ // 清除本地保存的变量
+ uni.removeStorageSync(this.uniqueKey + '_$uCountDownTimestamp');
+ // 开始倒计时
+ // 如果不存在需要继续上一次的倒计时,执行正常的逻辑
+ this.changeEvent(this.startText);
+ // 防止快速点击获取验证码的按钮而导致内部产生多个定时器导致混乱
+ this.$emit('start');
+ this.canGetCode = false;
+ // 这里放这句,是为了一开始时就提示,否则要等setInterval的1秒后才会有提示
+ this.changeEvent(this.changeText.replace(/x|X/, this.secNum));
+ this.setTimeToStorage();
+ if (--this.secNum) {
+ // 用当前倒计时的秒数替换提示字符串中的"x"字母
+ this.changeEvent(this.endText);
+ this.secNum = this.seconds;
+ this.canGetCode = true;
+ // 重置,可以让用户再次获取验证码
+ changeEvent(text) {
+ this.$emit('change', text);
+ // 保存时间戳,为了防止倒计时尚未结束,H5刷新或者各端的右上角返回上一页再进来
+ setTimeToStorage() {
+ if(!this.keepRunning || !this.timer) return;
+ // 记录当前的时间戳,为了下次进入页面,如果还在倒计时内的话,继续倒计时
+ // 倒计时尚未结束,结果大于0;倒计时已经开始,就会小于初始值,如果等于初始值,说明没有开始倒计时,无需处理
+ if(this.secNum > 0 && this.secNum <= this.seconds) {
+ // 获取当前时间戳(+ new Date()为特殊写法),除以1000变成秒,再去除小数部分
+ // 将本该结束时候的时间戳保存起来 => 当前时间戳 + 剩余的秒数
+ uni.setStorage({
+ key: this.uniqueKey + '_$uCountDownTimestamp',
+ data: nowTimestamp + Number(this.secNum)
+ // 组件销毁的时候,清除定时器,否则定时器会继续存在,系统不会自动清除
+ .u-code-wrap {
+ height: 0;
@@ -0,0 +1,181 @@
+ <view class="u-waterfall">
+ <view id="u-left-column" class="u-column"><slot name="left" :leftList="leftList"></slot></view>
+ <view id="u-right-column" class="u-column"><slot name="right" :rightList="rightList"></slot></view>
+ * waterfall 瀑布流
+ * @description 这是一个瀑布流形式的组件,内容分为左右两列,结合uView的懒加载组件效果更佳。相较于某些只是奇偶数左右分别,或者没有利用vue作用域插槽的做法,uView的瀑布流实现了真正的 组件化,搭配LazyLoad 懒加载和loadMore 加载更多组件,让您开箱即用,眼前一亮。
+ * @tutorial https://www.uviewui.com/components/waterfall.html
+ * @property {Array} flow-list 用于渲染的数据
+ * @property {String Number} add-time 单条数据添加到队列的时间间隔,单位ms,见上方注意事项说明(默认200)
+ * @example <u-waterfall :flowList="flowList"></u-waterfall>
+ name: "u-waterfall",
+ // 瀑布流数据
+ required: true,
+ default: function() {
+ // 每次向结构插入数据的时间间隔,间隔越长,越能保证两列高度相近,但是对用户体验越不好
+ // 单位ms
+ addTime: {
+ // id值,用于清除某一条数据时,根据此idKey名称找到并移除,如数据为{idx: 22, name: 'lisa'}
+ // 那么该把idKey设置为idx
+ idKey: {
+ default: 'id'
+ uWaterfall: this
+ leftList: [],
+ rightList: [],
+ tempList: [],
+ children: []
+ copyFlowList(nVal, oVal) {
+ // 取差值,即这一次数组变化新增的部分
+ let startIndex = Array.isArray(oVal) && oVal.length > 0 ? oVal.length : 0;
+ // 拼接上原有数据
+ this.tempList = this.tempList.concat(this.cloneData(nVal.slice(startIndex)));
+ this.splitData();
+ this.tempList = this.cloneData(this.copyFlowList);
+ // 破坏flowList变量的引用,否则watch的结果新旧值是一样的
+ copyFlowList() {
+ return this.cloneData(this.value);
+ async splitData() {
+ if (!this.tempList.length) return;
+ let leftRect = await this.$uGetRect('#u-left-column');
+ let rightRect = await this.$uGetRect('#u-right-column');
+ // 如果左边小于或等于右边,就添加到左边,否则添加到右边
+ let item = this.tempList[0];
+ // 解决多次快速上拉后,可能数据会乱的问题,因为经过上面的两个await节点查询阻塞一定时间,加上后面的定时器干扰
+ // 数组可能变成[],导致此item值可能为undefined
+ if(!item) return ;
+ if (leftRect.height < rightRect.height) {
+ this.leftList.push(item);
+ } else if (leftRect.height > rightRect.height) {
+ this.rightList.push(item);
+ // 这里是为了保证第一和第二张添加时,左右都能有内容
+ // 因为添加第一张,实际队列的高度可能还是0,这时需要根据队列元素长度判断下一个该放哪边
+ if (this.leftList.length <= this.rightList.length) {
+ // 移除临时列表的第一项
+ this.tempList.splice(0, 1);
+ // 如果临时数组还有数据,继续循环
+ if (this.tempList.length) {
+ }, this.addTime)
+ // 复制而不是引用对象和数组
+ cloneData(data) {
+ return JSON.parse(JSON.stringify(data));
+ // 清空数据列表
+ this.leftList = [];
+ this.rightList = [];
+ // 同时清除父组件列表中的数据
+ this.$emit('input', []);
+ this.tempList = [];
+ // 清除某一条指定的数据,根据id实现
+ remove(id) {
+ // 如果findIndex找不到合适的条件,就会返回-1
+ let index = -1;
+ index = this.leftList.findIndex(val => val[this.idKey] == id);
+ if(index != -1) {
+ // 如果index不等于-1,说明已经找到了要找的id,根据index索引删除这一条数据
+ this.leftList.splice(index, 1);
+ // 同理于上方面的方法
+ index = this.rightList.findIndex(val => val[this.idKey] == id);
+ if(index != -1) this.rightList.splice(index, 1);
+ // 同时清除父组件的数据中的对应id的条目
+ index = this.value.findIndex(val => val[this.idKey] == id);
+ if(index != -1) this.$emit('input', this.value.splice(index, 1));
+ // 修改某条数据的某个属性
+ modify(id, key, value) {
+ // 如果index不等于-1,说明已经找到了要找的id,修改对应key的值
+ this.leftList[index][key] = value;
+ if(index != -1) this.rightList[index][key] = value;
+ // 修改父组件的数据中的对应id的条目
+ // 首先复制一份value的数据
+ let data = this.cloneData(this.value);
+ // 修改对应索引的key属性的值为value
+ data[index][key] = value;
+ // 修改父组件通过v-model绑定的变量的值
+ this.$emit('input', data);
+.u-waterfall {
+.u-column {
@@ -0,0 +1,137 @@
+// 引入全局mixin
+import mixin from './libs/mixin/mixin.js'
+// 引入关于是否mixin集成小程序分享的配置
+// import wxshare from './libs/mixin/mpShare.js'
+// 全局挂载引入http相关请求拦截插件
+import http from './libs/request'
+function wranning(str) {
+ // 开发环境进行信息输出,主要是一些报错信息
+ // 这个环境的来由是在程序编写时候,点击hx编辑器运行调试代码的时候,详见:
+ // https://uniapp.dcloud.io/frame?id=%e5%bc%80%e5%8f%91%e7%8e%af%e5%a2%83%e5%92%8c%e7%94%9f%e4%ba%a7%e7%8e%af%e5%a2%83
+ if (process.env.NODE_ENV === 'development') {
+ console.warn(str)
+// 尝试判断在根目录的/store中是否有$u.mixin.js,此文件uView默认为需要挂在到全局的vuex的state变量
+// HX2.6.11版本,放到try中,控制台依然会警告,暂时不用此方式,
+// let vuexStore = {};
+// try {
+// vuexStore = require("@/store/$u.mixin.js");
+// } catch (e) {
+// //TODO handle the exception
+// }
+// post类型对象参数转为get类型url参数
+import queryParams from './libs/function/queryParams.js'
+// 路由封装
+import route from './libs/function/route.js'
+// 时间格式化
+import timeFormat from './libs/function/timeFormat.js'
+// 时间戳格式化,返回多久之前
+import timeFrom from './libs/function/timeFrom.js'
+// 颜色渐变相关,colorGradient-颜色渐变,hexToRgb-十六进制颜色转rgb颜色,rgbToHex-rgb转十六进制
+import colorGradient from './libs/function/colorGradient.js'
+// 生成全局唯一guid字符串
+import guid from './libs/function/guid.js'
+// 主题相关颜色,info|success|warning|primary|default|error,此颜色已在uview.scss中定义,但是为js中也能使用,故也定义一份
+import color from './libs/function/color.js'
+// 根据type获取图标名称
+import type2icon from './libs/function/type2icon.js'
+// 打乱数组的顺序
+import randomArray from './libs/function/randomArray.js'
+// 对象和数组的深度克隆
+import deepClone from './libs/function/deepClone.js'
+// 对象深度拷贝
+import deepMerge from './libs/function/deepMerge.js'
+// 添加单位
+import addUnit from './libs/function/addUnit.js'
+// 规则检验
+import test from './libs/function/test.js'
+// 随机数
+import random from './libs/function/random.js'
+// 去除空格
+import trim from './libs/function/trim.js'
+// toast提示,对uni.showToast的封装
+import toast from './libs/function/toast.js'
+// 获取父组件参数
+import getParent from './libs/function/getParent.js'
+// 获取整个父组件
+import $parent from './libs/function/$parent.js'
+// 获取sys()和os()工具方法
+// 获取设备信息,挂载到$u的sys()(system的缩写)属性中,
+// 同时把安卓和ios平台的名称"ios"和"android"挂到$u.os()中,方便取用
+import {sys, os} from './libs/function/sys.js'
+// 防抖方法
+import debounce from './libs/function/debounce.js'
+// 节流方法
+import throttle from './libs/function/throttle.js'
+// 配置信息
+import config from './libs/config/config.js'
+// 各个需要fixed的地方的z-index配置文件
+import zIndex from './libs/config/zIndex.js'
+const $u = {
+ queryParams: queryParams,
+ route: route,
+ timeFormat: timeFormat,
+ date: timeFormat, // 另名date
+ timeFrom,
+ colorGradient: colorGradient.colorGradient,
+ guid,
+ color,
+ sys,
+ os,
+ type2icon,
+ randomArray,
+ wranning,
+ get: http.get,
+ post: http.post,
+ put: http.put,
+ 'delete': http.delete,
+ hexToRgb: colorGradient.hexToRgb,
+ rgbToHex: colorGradient.rgbToHex,
+ test,
+ random,
+ deepClone,
+ deepMerge,
+ getParent,
+ $parent,
+ addUnit,
+ trim,
+ type: ['primary', 'success', 'error', 'warning', 'info'],
+ http,
+ toast,
+ config, // uView配置信息相关,比如版本号
+ debounce,
+ throttle,
+const install = Vue => {
+ Vue.mixin(mixin)
+ if (Vue.prototype.openShare) {
+ Vue.mixin(mpShare);
+ // Vue.mixin(vuexStore);
+ // 时间格式化,同时两个名称,date和timeFormat
+ Vue.filter('timeFormat', (timestamp, format) => {
+ return timeFormat(timestamp, format)
+ Vue.filter('date', (timestamp, format) => {
+ // 将多久以前的方法,注入到全局过滤器
+ Vue.filter('timeFrom', (timestamp, format) => {
+ return timeFrom(timestamp, format)
+ Vue.prototype.$u = $u
+ install