Browse Source

新增质控作业列表页面

Tly 1 year ago
parent
commit
1d76cdc958

+ 8 - 0
pages.json

@@ -42,6 +42,14 @@
 		        "navigationBarTitleText": "质控作业",
 		        "enablePullDownRefresh": true
 		    }
+		},
+		{
+		    "path" : "pages/ypczk/zkTask/zkTaskList",
+		    "style" :
+		    {
+		        "navigationBarTitleText": "全部任务",
+		        "enablePullDownRefresh": true
+		    }
 		}
     ],
 	"globalStyle": {

+ 195 - 0
pages/ypczk/zkTask/indexList.scss

@@ -0,0 +1,195 @@
+.view_container{
+	// overflow: auto;
+}
+::v-deep.yui-tabs{
+	
+	
+	position: sticky;
+	// margin-top: 2.8125rem;
+	// .tab-pane{
+	// 	$height1: calc(60px + 0.625rem + 2.8125rem);
+	// 	height: calc(100vh - #{$height1});
+	// 	width: 100%;
+	// }
+	// display: flex;
+	// flex-direction: column;
+	// height: 100vh;
+	.yui-tabs__wrap{
+	}
+	.yui-tabs__content{
+		// $height1: var(--status-bar-height);
+		// height: calc(100vh - 2.5rem);
+		// height: 100dvh;
+		// background-color: yellow;
+		
+		.content-wrap{
+			display: flex;
+			flex-direction: column;
+			padding-bottom: 100rpx;
+			$height1: var(--status-bar-height);
+			height:calc(100% - 235rpx);
+			// overflow: auto;
+			// height: calc(100vh - 2.5rem - #{$height1});
+			// height: calc(100vh - 2.5rem - #{$height1});
+			.order_list{
+				// flex: 1;
+				// overflow: auto;
+				height: calc(100% - 235rpx);
+				.loading_more{
+					display: flex;
+					align-items: center;
+					justify-content: space-between;
+				}
+			}
+		}
+		.content-wrap.empty{
+			display: flex;
+			flex-direction: column;
+			padding-bottom: 0;
+			$height1: var(--status-bar-height);
+			height:calc(100vh - 2.5rem);
+			height:calc(100dvh - 2.5rem);
+			.order_subtotal{
+				background-color: #fefdf2;
+			}
+		}
+	}
+	.order_subtotal{
+		display: flex;
+		justify-content: center;
+		align-items: flex-end;
+		height: 30rpx;
+		padding-top: 50rpx;
+		// &.yellow{
+		// 	background-color: #fefdf2;
+		// }
+	}
+	.data_empty{
+		flex: 1;
+		height: 100%;
+		width: 100%;
+		background-color: #fefdf2;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		// padding-bottom: 100rpx;
+		uni-image{
+			width: 240rpx;
+			height: 260rpx;
+		}
+	}
+}
+
+
+.leaderIndex{
+	.leaderIndex_header{
+		background: linear-gradient(180deg, rgba(189, 162, 115, 1) 0%, rgba(255, 255, 255, 1) 100%);
+		height: 450rpx;
+		.info{
+			display: flex;
+			padding: 10rpx 10rpx 20rpx 10rpx;
+			.info_img{
+				width:100rpx;
+				height:100rpx;
+				border-radius:50%;
+				background-color:white;
+				.headImg{
+					margin-left: auto;
+					margin-right: auto;
+					width: 80rpx;
+					height: 80rpx;
+				}
+			}
+			.userName{
+				font-size: 20px;
+				font-weight: 500;
+				color: rgba(255, 255, 255, 1);
+				text-align: center;
+				padding-left: 20rpx;
+				padding-right: 40rpx;
+			}
+			.eyeIcon{
+				width: 60rpx;
+				height: 60rpx;
+			}
+		}
+		.slogan{
+			display:flex;
+			align-items: center;
+			justify-content: center;
+			.slogan_img{
+				width: 670rpx;
+				height: 290rpx;
+			}
+		}
+	}
+	.leaderIndex_title{
+		display: flex;
+		padding-left: 30rpx;
+		.title_left{
+			display:flex;
+			align-items: center;
+			justify-content: center;
+			.uni-badge--x{
+				padding-left: 5rpx;
+			}
+		}
+		.title_right{
+			padding-left: 400rpx;
+			right: 20rpx;
+			.right_text{
+				font-size: 17px;
+				font-weight: 400;
+				color: rgba(153, 153, 153, 1);
+				padding-right: 5rpx;
+			}
+		}
+	}
+	.leaderIndex_list{
+		.list_title{
+			display: flex;
+			.statusIcon{
+				height: 50rpx;
+				width: 50rpx;
+				margin-right: 20rpx;
+			}
+			.showIcon{
+				margin-left: 10rpx;
+				width: 45rpx;
+				height: 45rpx;
+			}
+		}
+		.list_content{
+			flex: 1;
+			margin-left: 68rpx;
+			.list_item{
+				display: inline-block;
+				width: 100%;
+				padding-top: 3rpx;
+				padding-bottom: 3rpx;
+				.list_item_text{
+					position: absolute;
+					right: 30rpx;
+				}
+			}
+		}
+		.list_button{
+			border-top: 0.5px solid rgba(221, 221, 221, 1);
+			.button_group{
+				display: flex;
+				align-items: center;
+				justify-content: center;
+				.button_item{
+					flex: 1;
+					text-align: center;
+					color: rgba(190, 163, 117, 1);
+					font-size: 17px;
+					font-weight: 400;
+				}
+				.button_item:first-child{
+					border-right: 0.5px solid rgba(221, 221, 221, 1);
+				}
+			}
+		}
+	}
+}

+ 165 - 0
pages/ypczk/zkTask/zkTaskList.vue

@@ -0,0 +1,165 @@
+<!-- 首页-组长 -->
+<template>
+	<view>
+		<yui-tabs :tabs="pageInfo.tabs" :offsetTop="25" v-model="pageInfo.activeIndex" @change="tabChange" color="rgba(190, 163, 117, 1)"
+			titleActiveColor="rgba(190, 163, 117, 1)" sticky swipeable line-width="60rpx" line-height="2px" animated>
+			<template #pane0>
+			<view class="view_container leaderIndex">
+				<!-- 列表 -->
+				<view class="leaderIndex_list">
+					<uni-card v-for="(item,index) in pageData.taskList" padding="10rpx 0">
+						<view class="list_title">
+							<image :src="item.taskStatusIcon" class="statusIcon"></image>
+							<view>{{item.name}}</view>
+							<image :src="showIcon.nowIcon" class="showIcon" @click="changeState('list')"></image>
+						</view>
+						<view class="list_content">
+							<text class="list_item">任务编号<text class="list_item_text">{{item.taskNumber}}</text></text>
+							<text class="list_item">组长<text class="list_item_text">{{item.zzName}}</text></text>
+							<text class="list_item">待质控代煎企业<text
+									class="list_item_text">{{item.bzkOrgName}}</text></text>
+							<text class="list_item">地址<text class="list_item_text">{{item.address}}</text></text>
+							<text class="list_item">完成日期<text class="list_item_text">{{item.finishTime}}</text></text>
+						</view>
+						<view class="list_button">
+							<view class="button_group">
+								<view class="button_item" @click="signTask">签收</view>
+								<view class="button_item" @click="starTask">开始质控</view>
+							</view>
+						</view>
+					</uni-card>
+				</view>
+			</view>
+			</template>
+		</yui-tabs>
+	</view>
+</template>
+
+<script setup>
+	import http from '@/utils/request';
+	import {
+		reactive
+	} from "vue";
+	import {
+		onLoad,
+		onShow,
+		onUnload,
+		onPullDownRefresh
+	} from "@dcloudio/uni-app";
+
+	const pageInfo = reactive({
+		// 判断列表是否为空
+		isEmpty: true,
+		total: 0,
+		value: 0,
+		orderList: [],
+		tabs: ["所有", "待质控", "质控中", "完成"],
+		activeIndex: 0,
+		orderId: '',
+		detailStatus: ''
+	});
+
+	const pageData = reactive({
+		headImg: '/static/logo.png',
+		userName: '王军',
+		taskNum: 2,
+		taskList: []
+	})
+
+	const eyeIcon = reactive({
+		nowIcon: '',
+		showIcon: '/static/whiteDisplay.png',
+		hideIcon: '/static/whiteHide.png'
+	})
+
+	const showIcon = reactive({
+		nowIcon: '',
+		showIcon: '/static/blackDisplay.png',
+		hideIcon: '/static/blackHide.png'
+	})
+
+	const statusIcon = reactive({
+		daiIcon: '/static/dai_icon.png',
+		wanIcon: '/static/wan_icon.png',
+		zhiIcon: '/static/zhi_icon.png'
+	})
+
+	// 切换显示与隐藏
+	const changeState = (type) => {
+		if (type == 'header') {
+			console.log("头部切换")
+			if (eyeIcon.nowIcon == eyeIcon.hideIcon) {
+				eyeIcon.nowIcon = eyeIcon.showIcon
+			} else {
+				eyeIcon.nowIcon = eyeIcon.hideIcon
+			}
+		} else if (type == 'list') {
+			console.log("列表切换")
+			if (showIcon.nowIcon == showIcon.hideIcon) {
+				showIcon.nowIcon = showIcon.showIcon
+			} else {
+				showIcon.nowIcon = showIcon.hideIcon
+			}
+		}
+	}
+
+	// 跳转到任务详情
+	const toDetail = () => {
+		console.log("跳转到任务详情")
+	}
+
+	// 签收
+	const starTask = () => {
+		console.log("签收")
+	}
+
+	// 开始质控
+	const signTask = () => {
+		console.log("开始质控")
+	}
+
+	onPullDownRefresh(() => {
+		console.log("下拉刷新")
+		setTimeout(uni.stopPullDownRefresh(), 2000)
+
+	})
+
+	const getTaskData = () => {
+		http.get("app-api/findList").then(res => {
+			console.log(res)
+			pageData.taskList = res
+			pageData.taskList.forEach(item => {
+				item.finishTime = item.wcrq1.slice(2) + ' ~ ' + item.wcrq2.slice(0, -2)
+				if (item.status == '7003') {
+					item.taskStatusIcon = statusIcon.daiIcon
+				} else if (item.status == '7004') {
+					item.taskStatusIcon = statusIcon.wanIcon
+				} else {
+					item.taskStatusIcon = statusIcon.zhiIcon
+				}
+			})
+			console.log(pageData.taskList)
+		})
+	}
+
+	onShow(() => {
+		eyeIcon.nowIcon = eyeIcon.hideIcon
+		showIcon.nowIcon = showIcon.hideIcon
+		getTaskData()
+	})
+</script>
+
+
+<style lang="scss" scoped>
+	@import 'indexList.scss';
+
+	.yui-tabs {
+		position: relative;
+	}
+	::v-deep.uni-view.yui-tabs__wrap {
+	    margin-top: 25px!important;
+	}
+	.yui-tabs__wrap{
+		top: 50px !important;
+	}
+</style>

+ 351 - 0
uni_modules/yui-tabs/components/yui-tabs/css/index.less

@@ -0,0 +1,351 @@
+@bgColor: #fff; //背景色
+@themeColor: #0022AB; //主题色
+@inactiveColor: #646566; //标题未选中颜色
+@activeColor: #323233; //标题选中颜色
+@cardActiveColor: #fff; //type=="card"下的标题选中颜色
+@disabledColor: #c8c9cc; //禁用颜色
+@dotColor: #e53935; //小红点、徽标背景色
+@badgeColor: #fff; //徽标内容颜色
+
+uni-view , swiper-item, view{
+	flex-direction: row;
+	display: block;
+}
+
+
+
+.yui-tabs {
+	position: relative;
+	width: 100%;
+
+	.depend-wrap {
+		position: absolute;
+		top: 0;
+	}
+
+	// 标题栏占位
+	&__placeholder {
+		width: 100%;
+	}
+
+	// 开启粘性定位布局
+	&--fixed {
+
+		// 导航区域包裹层
+		.yui-tabs__wrap {
+			position: fixed;
+			top: 0;
+			right: 0;
+			left: 0;
+			z-index: 99;
+		}
+	}
+
+
+	// 导航区域包裹层
+	&__wrap {
+		position: relative;
+		display: flex;
+		align-items: center;
+		overflow: hidden;
+		visibility: hidden;
+		height: 0;
+		background: @bgColor;
+
+		// 不显示滚动条
+		::-webkit-scrollbar {
+			display: none;
+			width: 0;
+			height: 0;
+			-webkit-appearance: none;
+			background: transparent;
+			color: transparent;
+		}
+	}
+
+	// 标签页可见
+	&--visible {
+
+		// 导航区域包裹层
+		.yui-tabs__wrap {
+			visibility: visible;
+			height: auto;
+		}
+	}
+
+	// 卡片风格
+	&--card {
+
+		// 导航区域包裹层
+		.yui-tabs__wrap {
+			margin: 0 32rpx;
+			border-radius: 8rpx;
+		}
+	}
+
+	// scroll-view组件样式
+	&__scroll {
+		position: relative;
+		width: 100%;
+		height: 80rpx;
+
+		// 允许滚动
+		&.enable-sroll {
+			white-space: nowrap; // 使用横向滚动时,需要给<scroll-view>添加white-space: nowrap;样式
+		}
+	}
+
+
+	// 导航区域
+	&__nav {
+		position: relative;
+		box-sizing: content-box;
+		user-select: none;
+		height: 80rpx;
+		flex: 1;
+		display: flex;
+
+
+		// 导航标签
+		.yui-tab {
+			position: relative;
+			display: inline-block;
+			line-height: 80rpx;
+			font-size: 28rpx;
+			color: @inactiveColor;
+			text-align: center;
+			padding: 0 8rpx;
+			flex: 1;
+			cursor: pointer;
+			-webkit-tap-highlight-color: transparent;
+			transition-property: color background-color border-color;
+			transition-duration: 0.2s;
+
+			// 选中状态
+			&--active {
+				color: @activeColor;
+				font-weight: 500;
+			}
+
+			// 禁用状态
+			&--disabled {
+				color: @disabledColor;
+				cursor: not-allowed;
+			}
+
+
+			// 标题文字
+			&__text {
+				position: relative;
+				display: inline;
+			}
+
+
+			// 文字省略
+			&__ellipsis {
+				display: -webkit-box; //定义为盒子显示
+				overflow: hidden;
+				text-overflow: ellipsis; //文本溢出隐藏为省略号
+				-webkit-line-clamp: 1; // 限制一个块元素显示的文本行数
+				-webkit-box-orient: vertical; //盒模型子元素排列: vertical(竖排)orhorizontal(横排)
+			}
+
+			// 标签文字右上角徽标的内容
+			&__info {
+				display: inline-block;
+				position: absolute;
+				top: 0;
+				right: 0;
+				box-sizing: border-box;
+				min-width: 36rpx;
+				padding: 0 4rpx;
+				color: @badgeColor;
+				font-weight: 500;
+				font-size: 18rpx;
+				line-height: 26rpx;
+				text-align: center;
+				background-color: @dotColor;
+				border-radius: 36rpx;
+				transform: translate(50%, -50%);
+				transform-origin: 100%;
+				text-align: center;
+			}
+
+			&__info--dot {
+				line-height: unset;
+				padding: 0;
+				width: 12rpx;
+				min-width: 0;
+				height: 12rpx;
+				background-color: @dotColor;
+				border-radius: 100%;
+			}
+		}
+
+		// 文本风格
+		&--text {
+
+			.yui-tab {
+				&--active {
+					color: @themeColor;
+				}
+			}
+		}
+
+		// 卡片风格
+		&--card {
+			box-sizing: border-box;
+			border: 2rpx solid @themeColor;
+			border-radius: 8rpx;
+
+			.yui-tab {
+				color: @themeColor;
+
+				&--active {
+					background-color: @themeColor;
+					color: @cardActiveColor;
+				}
+			}
+		}
+
+		// 按钮风格
+		&--button {
+			.yui-tab {
+				height: 50rpx;
+				line-height: 50rpx;
+				margin-top: 15rpx;
+				flex: auto;
+				border-radius: 50rpx;
+				padding: 0 20rpx;
+				margin-left: 10rpx;
+
+				&:last-child {
+					margin-right: 10rpx;
+				}
+
+				&--active {
+					background-color: @themeColor;
+					color: @cardActiveColor;
+				}
+			}
+		}
+
+		// 线性按钮风格
+		&--line-button {
+			.yui-tab {
+				height: 50rpx;
+				line-height: 50rpx;
+				margin-top: 15rpx;
+				flex: auto;
+				border: 2rpx solid transparent;
+				border-radius: 50rpx;
+				padding: 0 20rpx;
+				margin-left: 10rpx;
+
+				&:last-child {
+					margin-right: 10rpx;
+				}
+
+				&--active {
+					border-color: @themeColor;
+					color: @themeColor;
+				}
+			}
+		}
+
+	}
+
+
+	// 标签右侧的补充区域
+	&__extra {
+		position: relative;
+		display: inline-flex;
+		white-space: nowrap;
+	}
+
+	// 底部线条
+	&__line {
+		position: absolute;
+		bottom: 6rpx;
+		left: 0;
+		width: 40rpx;
+		height: 6rpx;
+		background-color: @themeColor;
+		border-radius: 6rpx;
+		transform: translateX(-100%) translateX(-50%);
+		// transition-duration: 0.3s;
+	}
+
+
+	// 标签内容的滑动轨道容器
+	&__track {
+		position: relative;
+		display: flex;
+		width: 100%;
+		height: unset;
+		will-change: left;
+		background-color: @bgColor;
+	}
+
+	// 标签内容
+	&__content {
+		background-color: @bgColor;
+		overflow: hidden;
+
+		.yui-tab__pane {
+			flex-shrink: 0;
+			box-sizing: border-box;
+			width: 100%;
+		}
+	}
+
+	// 标签内容转场动画样式
+	&__content--animated {
+		overflow: hidden;
+
+		.yui-tab__pane {
+			transition-duration: 0.3s;
+		}
+	}
+	
+	// 滚动导航模式下
+	&__content--scrollspy {
+		overflow: hidden;
+	
+		.yui-tabs__track {
+			flex-direction: column;
+		}
+	}
+
+	// 使用swpier组件进行左右滑动
+	&--swiper {
+		display: flex;
+		flex-direction: column;
+		height: 100%;
+		box-sizing: border-box;
+		overflow: hidden;
+	}
+
+	// 承载标签内容的滑动容器
+	&__swiper {
+		flex: 1;
+		box-sizing: border-box;
+
+		.yui-tabs__swiper--item {
+			flex: 1;
+			flex-direction: column;
+			box-sizing: border-box;
+		}
+
+		.yui-tabs__swiper--wrap {
+			position: absolute;
+			left: 0;
+			top: 0;
+			right: 0;
+			bottom: 0;
+			box-sizing: border-box;
+			display: flex;
+			flex: 1;
+		}
+	}
+}

+ 129 - 0
uni_modules/yui-tabs/components/yui-tabs/utils/const.js

@@ -0,0 +1,129 @@
+const emits = ['input', 'change', 'click', 'rendered', 'scroll']
+let valueField = "value" // v-model绑定属性名
+// #ifdef VUE3
+emits.splice(0, 1, 'update:modelValue')
+valueField = "modelValue"
+// #endif
+
+
+const props = {
+	color: String, //标签主题色, 默认值为"#0022AB"
+	background: String, //标签栏背景色,默认值为"#fff"
+	lineWidth: [Number, String], //底部条宽度,默认单位为px, 默认值为20px
+	lineHeight: [Number, String], //底部条高度,默认单位为px,默认值为3px
+	titleActiveColor: String, //标题选中态颜色
+	titleInactiveColor: String, //标题默认态颜色
+	// 标签栏样式
+	wrapStyle: {
+		type: [Object, null],
+		default: () => {}
+	},
+	// 动画时间,单位秒
+	duration: {
+		type: [Number, String],
+		default: 0.3,
+	},
+	// 样式风格类型,可选值为 text、card、button、line-button
+	type: {
+		type: String,
+		default: "line"
+	},
+	// 标签页数据,支持字符串类型与对象类型的数组结构
+	// 对象类型需符合{label:'标签1',slot:'slotName'}这样的格式,slot为自定义的标签内容插槽名,否则插槽名默认为"pane"+tab下标的命名
+	tabs: {
+		type: Array,
+		default: () => []
+	},
+	// 是否省略过长的标题文字
+	ellipsis: {
+		type: Boolean,
+		default: true,
+	},
+	// 标签栏滚动时当前标签居中
+	scrollToCenter: {
+		type: Boolean,
+		default: true,
+	},
+	//  标签栏的滚动阈值(仅在ellipsis="false"且type不为"card"下时有效),标签数量超过阈值且总宽度超过标签栏宽度时开始横向滚动(切换时会自动将当前标签居中)
+	scrollThreshold: {
+		type: [Number, String],
+		default: 5
+	},
+
+	// 是否开启延迟渲染(首次切换到标签时才触发内容渲染)
+	isLazyRender: {
+		type: Boolean,
+		default: true,
+	},
+	// 是否开启切换标签内容时的转场动画
+	animated: {
+		type: Boolean,
+		default: false
+	},
+	// 在点击标签标题时,页面是否会滚动回到顶部
+	tabClickScrollTop: {
+		type: Boolean,
+		default: false
+	},
+	// 对于只想使用标题栏功能而不关注标签内容,可使用该属性关闭标签内容的渲染
+	noRenderConent: Boolean,
+	// 滚动导航: 通过 scrollspy 属性可以开启滚动导航模式,该模式下,内容将会平铺展示。
+	scrollspy: Boolean,
+	// 切换标签前的回调函数,返回 false 可阻止切换,支持返回 Promise
+	beforeChange: Function,
+	// 滑动切换是否使用swiper组件实现
+	swiper: {
+		type: Boolean,
+		default: false,
+	},
+	// 是否开启手势滑动切换
+	swipeable: {
+		type: Boolean,
+		default: false,
+	},
+	// 是否开启标签内容的拖动动画(该属性依赖于swipeable、is-lazy-render的开启;该属性开启时考虑给包裹内容的容器增加一个min-height,因为开启该属性后,其他未显示出来的标签内容会沿用当前显示的高度,拖动切换后由于高度不一致会有回弹)
+	swipeAnimated: {
+		type: Boolean,
+		default: false,
+	},
+	// 滑动切换的滑动距离阈值,手指滑动页面触发切换的阈值,单位为px,表示横向滑动整个可视区域的多少px时才切换标签内容
+	swipeThreshold: {
+		type: [Number, String],
+		default: 50,
+	},
+	// 保证组件的可见性,主要用于处理选中标签的底部线条位置
+	visible: {
+		type: Boolean,
+		default: true
+	},
+	// 标签页是否滚动吸顶
+	fixed: Boolean,
+	// 滚动吸顶下与顶部的最小距离,默认 px
+	offsetTop: {
+		type: Number,
+		default: 0
+	},
+	// 滚动吸顶/粘性布局下,标签栏的z-index值
+	zIndex: {
+		type: Number,
+		default: 99
+	},
+	// 是否使用粘性定位布局
+	sticky: Boolean,
+	// 粘性布局的判断阈值
+	stickyThreshold: {
+		type: Number,
+		default: 0
+	},
+}
+//  v-model绑定属性,绑定当前选中标签的标识符(标签的下标)
+props[valueField] = {
+	type: Number,
+	default: -1
+}
+
+export {
+	emits,
+	props,
+	valueField
+}

+ 76 - 0
uni_modules/yui-tabs/components/yui-tabs/utils/touchMixin.js

@@ -0,0 +1,76 @@
+function getDirection(x, y) {
+	if (x > y) {
+		return 'horizontal';
+	}
+
+	if (y > x) {
+		return 'vertical';
+	}
+
+	return '';
+}
+
+export const touchMixin = {
+	data() {
+		return {
+			direction: '',
+			startX: '',
+			startY: '',
+			nextIndex: -1,
+			moved: false, //是否为一次水平滑动
+		};
+	},
+	methods: {
+		touchStart(event) {
+			if (!this.swipeable) { return }
+			this.resetTouchStatus();
+			this.startX = event.touches[0].clientX;
+			this.startY = event.touches[0].clientY;
+		},
+		touchMove(event) {
+			const touch = event.touches[0];
+			this.deltaX = touch.clientX < 0 ? 0 : touch.clientX - this.startX;
+			this.deltaY = touch.clientY - this.startY;
+			const offsetX = Math.abs(this.deltaX);
+			const offsetY = Math.abs(this.deltaY);
+			// 当距离大于某个值时锁定方向
+			const lock_distance = 10;
+			if (!this.direction || (offsetX < lock_distance && offsetY < lock_distance)) {
+				this.direction = getDirection(offsetX, offsetY);
+			}
+
+			if (this.direction === "horizontal") { //水平滑动
+				const { deltaX, dataLen, contentWidth, currentIndex, swipeAnimated } = this
+				// 如果当前为第一页内容,则不允许向右滑;最后一页内容,则不允许左滑
+				if ((deltaX > 0 && currentIndex === 0) || (deltaX < 0 && currentIndex === dataLen - 1)) {
+					return
+				}
+				this.nextIndex = currentIndex + (deltaX > 0 ? -1 : 1)
+
+				this.moved = true //标记为一次水平滑动
+
+				// 改变标签内容的样式,模拟拖动动画效果
+				if (swipeAnimated) {
+					const offsetWidth = contentWidth * currentIndex * -1 + deltaX
+					this.changeTrackStyle(true, 0, offsetWidth)
+				}
+			}
+		},
+		touchEnd() {
+			if (!this.moved) { return }
+			const { deltaX, nextIndex, dataLen, swipeThreshold } = this;
+			if (Math.abs(deltaX) >= swipeThreshold) { //当滑动距离大于某个值时切换标签
+				this.setCurrentIndex(nextIndex)
+			} else { //否则还原
+				this.changeTrackStyle(false)
+			}
+		},
+		resetTouchStatus() {
+			this.direction = '';
+			this.deltaX = 0;
+			this.deltaY = 0;
+			this.newIndex = -1;
+			this.moved = false;
+		},
+	}
+}

+ 126 - 0
uni_modules/yui-tabs/components/yui-tabs/utils/uitls.js

@@ -0,0 +1,126 @@
+/**
+ * 判断传入的值是否为空
+ * @param {*} val 
+ * @returns 
+ */
+export function isNull(val) {
+	if (typeof val == "boolean") {
+		return false;
+	}
+	if (typeof val == "number") {
+		return false;
+	}
+	if (val instanceof Array) {
+		if (val.length == 0) return true;
+	} else if (val instanceof Object) {
+		if (JSON.stringify(val) === "{}") return true;
+	} else {
+		if (
+			val == "null" ||
+			val == null ||
+			val == "undefined" ||
+			val == undefined ||
+			val == ""
+		)
+			return true;
+		return false;
+	}
+	return false;
+}
+
+// 不为空
+export function isDef(val) {
+	return val !== undefined && val !== null;
+}
+
+// 是否是一个数字
+export function isNumeric(val) {
+	return /^\d+(\.\d+)?$/.test(val);
+}
+
+// 是一个对象
+export function isObject(val) {
+	return val !== null && typeof val === 'object';
+}
+// 是一个字符串
+export function isString(val) {
+	return Object.prototype.toString.call(val) === "[object String]"
+}
+
+// 空操作
+export function noop() {}
+
+// 是一个函数
+export function isFunction(val) {
+	return typeof val === 'function';
+}
+
+// 是一个promise对象
+export function isPromise(val) {
+	return isObject(val) && isFunction(val.then) && isFunction(val.catch);
+}
+
+
+
+// 添加单位
+export function addUnit(value) {
+	if (!isDef(value)) {
+		return undefined;
+	}
+
+	value = String(value);
+	return isNumeric(value) ? `${value}px` : value;
+}
+
+// 获得角度
+export function getAngle(angx, angy) {
+	return Math.atan2(angy, angx) * 180 / Math.PI;
+};
+
+// 根据起点终点返回方向 1向上 2向下 3向左 4向右 0未滑动
+export function getDirection(startx, starty, endx, endy) {
+	var angx = endx - startx;
+	var angy = endy - starty;
+	var result = 0;
+
+	// 如果滑动距离太短
+	if (Math.abs(angx) < 5 && Math.abs(angy) < 5) {
+		return result;
+	}
+
+	var angle = getAngle(angx, angy);
+	if (angle >= -135 && angle <= -45) {
+		result = 1;
+	} else if (angle > 45 && angle < 135) {
+		result = 2;
+	} else if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135)) {
+		result = 3;
+	} else if (angle >= -45 && angle <= 45) {
+		result = 4;
+	}
+
+	return result;
+}
+
+// 调用拦截器
+export function callInterceptor(options) {
+	const {
+		interceptor,
+		args,
+		done
+	} = options;
+
+	if (interceptor) {
+		const returnVal = interceptor(...args);
+		if (isPromise(returnVal)) {
+			returnVal.then((value) => {
+				if (value) done();
+			}).catch(noop);
+		} else if (returnVal) {
+			done();
+		}
+	} else {
+		done();
+	}
+}
+

+ 567 - 0
uni_modules/yui-tabs/components/yui-tabs/version/yui-tabs(1.0.5).vue

@@ -0,0 +1,567 @@
+<template>
+	<view class="yui-tabs" :class="{'yui-tabs--visible':visible,'yui-tabs--fixed':fixed}">
+		<!-- 标签区域 -->
+		<view class="yui-tabs__wrap" :style="[wrapStyle,innerWrapStyle]">
+			<!-- scrollX为true,表示允许横向滚动 -->
+			<scroll-view v-if="scrollX" class="yui-tabs__scroll" :scroll-x="scrollX" :scroll-anchoring="true"
+				enable-flex :scroll-into-view="scrollId" scroll-with-animation :style="[scrollStyle]">
+				<view class="yui-tabs__nav">
+					<view class="yui-tab" v-for="(tab,index) in tabList" :key="index" @click="handleClick(index)"
+						:id="`tab_${index}`" :class="[tabClass(index, tab)]" :style="[tabStyle(tab)]">
+						<view class="yui-tab__text" :class="{'yui-tab__text--ellipsis':ellipsis}">
+							{{tab.label}}
+						</view>
+					</view>
+					<view class="yui-tabs__line" :style="[lineStyle,lineAnimatedStyle]"></view>
+				</view>
+			</scroll-view>
+			<view v-else class="yui-tabs__nav">
+				<view class="yui-tab" v-for="(tab,index) in tabList" :key="index" @click="handleClick(index)"
+					:id="`tab_${index}`" :class="[tabClass(index, tab)]" :style="[tabStyle(tab)]">
+					<view class="yui-tab__text" :class="{'yui-tab__text--ellipsis':ellipsis}">
+						{{tab.label}}
+					</view>
+				</view>
+				<view class="yui-tabs__line" :style="[lineStyle,lineAnimatedStyle]"></view>
+			</view>
+			<view class="yui-tabs__extra">
+				<slot name="extra"></slot>
+			</view>
+		</view>
+		<!-- 标签内容 -->
+		<view class="yui-tabs__content" :class="{'yui-tabs__content--animated':animated}">
+			<view class="yui-tabs__track" :style="[trackStyle]">
+				<view class="yui-tab__pane" v-for="(tab,index) in tabList" :key="index" :style="[paneStyle(tab)]"
+					@touchstart="touchStart" @touchend="touchEnd($event,index)">
+					<view v-if="tab.rendered ? true :value == index">
+						<slot :name="tab.slot"></slot>
+					</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+	import {
+		isNull,
+		addUnit,
+		isDef,
+		isObject,
+		getDirection
+	} from "@/common/uitls.js"
+	export default {
+		name: "yui-tabs",
+		emits: ['input', 'change', 'click'],
+		// uni-app自定义v-model需要按照如下的规范,直接用value和input,否则在微信小程序上会失效
+		model: {
+			prop: 'value',
+			event: 'input'
+		},
+		props: {
+			color: String, //标签主题色, 默认值为"#0022AB"
+			background: String, //标签栏背景色,默认值为"#fff"
+			lineWidth: [Number, String], //底部条宽度,默认单位为px, 默认值为20px
+			lineHeight: [Number, String], //底部条高度,默认单位为px,默认值为3px
+			titleActiveColor: String, //标题选中态颜色
+			titleInactiveColor: String, //标题默认态颜色
+			// 标签页数据,支持字符串类型与对象类型的数组结构
+			// 对象类型需符合{label:'标签1',slot:'slotName'}这样的格式,slot为自定义的标签内容插槽名,否则插槽名默认为"pane"+tab下标的命名
+			tabs: {
+				type: Array,
+				default: () => []
+			},
+			// 是否开启延迟渲染(首次切换到标签时才触发内容渲染)
+			isLazyRender: {
+				type: Boolean,
+				default: true,
+			},
+			// 是否开启切换标签内容时的转场动画
+			animated: {
+				type: Boolean,
+				default: false
+			},
+			// 保证组件的可见性,主要用于处理选中标签的底部线条位置
+			visible: {
+				type: Boolean,
+				default: true
+			},
+			// v-model绑定属性,绑定当前选中标签的标识符(标签的下标)
+			value: {
+				type: Number,
+				default: -1
+			},
+			// 标签页是否滚动吸顶
+			fixed: Boolean,
+			// 滚动吸顶下与顶部的最小距离,默认 px
+			offsetTop: {
+				type: Number,
+				default: 0
+			},
+			// 滚动吸顶下,标签栏的z-index值
+			zIndex: {
+				type: Number,
+				default: 99
+			},
+			// 标签栏样式
+			wrapStyle: {
+				type: [Object, null],
+				default: () => {}
+			},
+			// 动画时间,单位秒
+			duration: {
+				type: [Number, String],
+				default: 0.3,
+			},
+			// 导航标签滚动阈值,标签数量超过阈值且总宽度超过标签栏宽度时开始横向滚动
+			swipeThreshold: {
+				type: [Number, String],
+				default: 5
+			},
+			// 是否省略过长的标题文字(注意:标签数量未超过导航标签滚动阈值时才生效)
+			ellipsis: {
+				type: Boolean,
+				default: true,
+			},
+			// 是否开启手势滑动切换
+			swipeable: {
+				type: Boolean,
+				default: false,
+			},
+			// 滚动阈值,手指滑动页面触发切换的阈值,单位为px,表示横向滑动整个可视区域的多少px时才切换标签内容
+			scrollThreshold: {
+				type: [Number, String],
+				default: 50,
+			},
+		},
+		data() {
+			return {
+				tabList: [],
+				scrollId: 'tab_0',
+				extraWidth: 0, //标签栏右侧额外区域宽度
+				trackStyle: null, //标签内容滑动轨道样式
+				touchInfo: {
+					inited: false, //标记左右滑动时的初始化状态
+					startX: null, //记录touch位置的横坐标
+					startY: null //记录touch位置的纵坐标
+				},
+				// 标签栏底部线条动画相关
+				translateX: null,
+				lineAnimated: false, //是否开启标签栏动画
+				lineAnimatedStyle: {
+					transform: `translateX(-100%) translateX(-50%)`,
+					transitionDuration: `0s`
+				}, //标签栏底部线条动画样式
+			}
+		},
+		computed: {
+			// 导航区域包裹层样式
+			innerWrapStyle() {
+				const style = {
+					backgroundColor: this.background,
+				}
+
+				// 滚动吸顶下
+				if (this.fixed) {
+					style.top = this.offsetTop + "px"
+					style.zIndex = this.zIndex
+				}
+				return style
+			},
+			// 滚动区域样式
+			scrollStyle() {
+				return {
+					width: `calc(100% - ${this.extraWidth}px)`
+				}
+			},
+			// 标签栏底部线条样式
+			lineStyle() {
+				const {
+					lineWidth,
+					lineHeight,
+					duration
+				} = this;
+				const lineStyle = {
+					width: addUnit(lineWidth),
+					backgroundColor: this.color,
+				}
+
+				if (isDef(lineHeight)) {
+					const height = addUnit(lineHeight);
+					lineStyle.height = height;
+					lineStyle.borderRadius = height;
+				}
+				return lineStyle
+			},
+			// 是否允许横向滚动
+			scrollX() {
+				return this.tabs.length > this.swipeThreshold
+			},
+		},
+		watch: {
+			// 监听选中标识符变化
+			value: {
+				handler(val, oldVal) {
+					this.tabChange(val, oldVal) //标签切换
+					this.changeStyle() // 样式切换
+				}
+			},
+			// 监听tabs变化,重新初始化tabList
+			tabs: {
+				handler(val) {
+					this.initTabList() //初始化tabList
+					this.changeStyle() // 样式切换
+				},
+				deep: true
+			},
+			// 可见时也需要计算translateX
+			visible: {
+				handler() {
+					this.lineAnimated = false //是否开启标签栏动画
+					// this.init() //初始化操作
+				}
+			},
+			// 监听translateX,设置标签栏底部线条动画
+			translateX: {
+				handler(val) {
+					const transform = `translateX(${isDef(val) ? val + "px" : '-100%'}) translateX(-50%)`
+					const duration = `${this.lineAnimated?this.duration:'0'}s`
+					this.$set(this.lineAnimatedStyle, 'transform', transform)
+					this.$set(this.lineAnimatedStyle, 'transitionDuration', duration)
+				}
+			},
+		},
+		created() {
+			this.initTabList() // 初始化tabList
+		},
+		mounted() {
+			this.init() //初始化操作
+		},
+		methods: {
+			// 获取元素位置信息
+			getRect(select) {
+				return new Promise((res, rej) => {
+					if (!select) rej('Parameter is empty');
+					let query
+					// #ifdef MP-ALIPAY
+					query = uni.createSelectorQuery()
+					// #endif
+					// #ifndef MP-ALIPAY
+					query = uni.createSelectorQuery().in(this)
+					// #endif
+					query.select(select).boundingClientRect(rect => res(rect)).exec();
+				})
+			},
+			// 标签项class
+			tabClass(index, tab) {
+				return `yui-tab_${index} ${tab.active?'yui-tab--active':''} ${tab.disabled?'yui-tab--disabled':''}`
+			},
+			// 标签项style
+			tabStyle(tab) {
+				return {
+					color: tab.active ? this.titleActiveColor : this.titleInactiveColor
+				}
+			},
+			// 标签内容style
+			paneStyle(tab) {
+				if (this.animated) {
+					return {
+						visibility: tab.show ? 'visible' : 'hidden',
+						height: tab.show ? 'auto' : '0px'
+					}
+				}
+				return {
+					display: tab.show ? 'block' : 'none'
+				}
+			},
+			// 初始化操作 
+			async init() {
+				//获取额外区域的宽度
+				let rect = await this.getRect('.yui-tabs__extra')
+				this.extraWidth = rect ? rect.width : 0
+
+				//获取标签容器距离视口左侧的left值
+				rect = await this.getRect('.yui-tabs')
+				const parentLeft = rect ? rect.left : 0
+				// 保存每个tab的translateX
+				this.tabList.forEach(async (tab, index) => {
+					const rect = await this.getRect('.yui-tab_' + index);
+					tab.translateX = rect.left + rect.width / 2 - parentLeft
+					if (index === this.value) this.changeStyle() // 样式切换
+				})
+			},
+			// 初始化tabList
+			initTabList() {
+				const tabs = this.tabs.filter(o => !isNull(o))
+				this.tabList = tabs.map((item, index) => {
+					const isCurr = this.value == index
+					let obj = {
+						label: '', //标签名称
+						slot: 'pane' + index, //标签内容的插槽名称,默认以"pane"+标签下标命名
+						disabled: false, //是否禁用标签
+						active: isCurr, //是否选中
+						rendered: isCurr || !this.isLazyRender, //标记是否渲染过
+						show: isCurr // this.animated ? true : isCurr //是否显示内容(标签内容转场动画不使用v-show控制显隐,直接显示)
+					}
+					if (isObject(item)) {
+						obj.label = item.label
+						obj.slot = isNull(item.slot) ? obj.slot : item.slot
+					} else {
+						obj.label = item
+					}
+					return obj
+				})
+			},
+			// 标签点击事件
+			handleClick(index) {
+				if (this.tabList[index].disabled) return //禁用时不允许切换
+				this.$emit('click', index, this.tabs[index]) // 标签点击事件
+				if (this.value == index) return //不允许重复切换同一标签
+				const oldValue = this.value //获取旧的index
+				//更新v-model绑定的值
+				this.$emit('input', index) //更新v-model绑定的值
+			},
+			// 标签切换
+			tabChange(value, oldValue) {
+				const oldTab = this.tabList[oldValue] //上一个tab
+				const currTab = this.tabList[value] //当前tab
+				// 设置选中态
+				oldTab.active = false
+				currTab.active = true
+				currTab.rendered = true //标记渲染过
+
+				oldTab.show = false //隐藏旧内容区域
+				currTab.show = true //隐藏当前tab对应的内容区域
+
+				// 触发change事件
+				this.$emit('change', value, this.tabs[value])
+			},
+			// 样式切换
+			changeStyle() {
+				this.scrollId = `tab_${this.value-1}`; //设置scroll-into-view
+				this.setTranslateX() //设置translateX
+				this.changeTrackStyle(false, this.duration) //改变标签内容滑动轨道样式
+			},
+			// 设置translateX,用于改变标签栏底部线条位置
+			setTranslateX() {
+				if (this.tabList[this.value].disabled) return
+				this.translateX = this.tabList[this.value].translateX
+
+				this.$nextTick(() => {
+					this.lineAnimated = true //是否开启标签栏动画
+				})
+			},
+			// 改变标签内容滑动轨道样式
+			changeTrackStyle(isSlide = false, duration = 0, offsetWidth = 0) {
+				if (!this.animated) return
+				// isSlide标记是否为左右滑动时,否则为点击标签的动画转场
+				this.trackStyle = {
+					'transform': isSlide ? `translate3d(${offsetWidth}px,0,0)` : `translateX(${-100 * this.value}%)`,
+					'transition': `transform ${duration}s ease-in-out`
+				}
+			},
+			touchStart(e) {
+				// 禁止滑动
+				if (!this.swipeable) return
+				this.touchInfo.inited = true //touch开始时,将touchInfo对象设置为已初始化状态
+				const touch = e.touches[0];
+				// 记录touch位置的横坐标与纵坐标
+				this.touchInfo.startX = touch.pageX
+				this.touchInfo.startY = touch.pageY
+			},
+			touchEnd(e, index) {
+				if (!this.touchInfo.inited) return
+				const {
+					pageX,
+					pageY
+				} = e.changedTouches[0];
+				const {
+					startX,
+					startY
+				} = this.touchInfo || {}
+
+				// 滑动方向不为左右时阻止
+				const direction = getDirection(startX, startY, pageX, pageY)
+				if (direction != 3 && direction != 4) return
+
+				// 横坐标偏移量
+				const deltaX = pageX - startX
+
+				// 标记是左滑还是右滑
+				const isLeftSide = deltaX >= 0
+				const len = this.tabList.length
+				// 如果当前为第一页内容,则不允许左滑;最后一页内容,则不允许右滑
+				if ((isLeftSide && index == 0) || (!isLeftSide && index == len - 1)) {
+					return
+				}
+
+				// 移动的横坐标偏移量大于指定的滚动阈值时,则切换显示状态,否则还原
+				if (Math.abs(deltaX) > Number(this.scrollThreshold)) {
+					// 根据是否为左滑查找需要滑动到的标签内容页下标,切换标签内容
+					index = index + (isLeftSide ? -1 : 1)
+					if (index > -1 && index < len) this.handleClick(index)
+				} else {
+					this.changeTrackStyle(false, this.duration)
+				}
+				// 一次touch完成后,重置touchInfo对象尚未初始化状态
+				this.touchInfo.inited = false
+			},
+		}
+	}
+</script>
+
+<style lang="less" scoped>
+	.yui-tabs {
+		position: relative;
+		width: 100%;
+
+
+		// 开启粘性定位布局
+		&--fixed {
+			.yui-tabs__wrap {
+				position: fixed;
+				top: 0;
+				right: 0;
+				left: 0;
+				z-index: 99;
+			}
+		}
+
+		// 不显示滚动条
+		::-webkit-scrollbar {
+			display: none;
+			width: 0 !important;
+			height: 0 !important;
+			-webkit-appearance: none;
+			background: transparent;
+			color: transparent;
+		}
+
+		// 导航区域包裹层
+		&__wrap {
+			position: relative;
+			display: flex;
+			background-color: #fff;
+			align-items: center;
+			overflow: hidden;
+			visibility: hidden;
+			height: 0;
+
+		}
+
+		// 导航区域可见
+		&--visible .yui-tabs__wrap {
+			visibility: visible;
+			height: auto;
+		}
+
+
+		// scroll-view组件样式
+		&__scroll {
+			position: relative;
+			white-space: nowrap; // 使用横向滚动时,需要给<scroll-view>添加white-space: nowrap;样式
+			width: 100%;
+			height: 80rpx;
+		}
+
+
+		// 导航区域
+		&__nav {
+			position: relative;
+			box-sizing: content-box;
+			user-select: none;
+			height: 80rpx;
+			flex: 1;
+			display: flex;
+
+			// 导航标签
+			.yui-tab {
+				display: inline-block;
+				line-height: 80rpx;
+				font-size: 28rpx;
+				color: #333;
+				text-align: center;
+				padding: 0 8rpx;
+				flex: 1;
+				cursor: pointer;
+				-webkit-tap-highlight-color: transparent;
+
+				&--active {
+					color: #212121;
+					font-weight: 500;
+				}
+
+				&--disabled {
+					color: #c8c9cc;
+					cursor: not-allowed;
+				}
+
+
+				// 标题文字
+				&__text {
+
+					// 省略过长的标题文字
+					&--ellipsis {
+						display: -webkit-box; //定义为盒子显示
+						overflow: hidden;
+						text-overflow: ellipsis; //文本溢出隐藏为省略号
+						-webkit-line-clamp: 1; // 限制一个块元素显示的文本行数
+						-webkit-box-orient: vertical; //盒模型子元素排列: vertical(竖排)orhorizontal(横排)
+					}
+				}
+			}
+		}
+
+
+		// 标签右侧的补充区域
+		&__extra {
+			position: relative;
+			display: inline-flex;
+			white-space: nowrap;
+		}
+
+		// 底部线条
+		&__line {
+			position: absolute;
+			bottom: 3px;
+			left: 0;
+			width: 20px;
+			height: 3px;
+			background-color: #0022AB;
+			border-radius: 3px;
+			transform: translateX(-100%) translateX(-50%);
+			// transition-duration: 0.3s;
+		}
+
+		// 标签内容
+		&__content {
+			background-color: #fff;
+			overflow: hidden;
+
+			.yui-tab__pane {
+				flex-shrink: 0;
+				box-sizing: border-box;
+				width: 100%;
+			}
+		}
+
+		// 标签内容转场动画样式
+		&__content--animated {
+			overflow: hidden;
+
+			.yui-tab__pane {
+				transition-duration: 0.3s;
+			}
+		}
+
+		// 标签内容的滑动轨道容器
+		&__track {
+			position: relative;
+			display: flex;
+			width: 100%;
+			height: 100%;
+			will-change: left;
+			background-color: #fff;
+		}
+	}
+</style>

+ 861 - 0
uni_modules/yui-tabs/components/yui-tabs/version/yui-tabs(1.0.6).vue

@@ -0,0 +1,861 @@
+<template>
+	<view class="yui-tabs" :class="[tabsClass]">
+		<!-- 依赖元素,用于处理滚动吸顶所需 -->
+		<view class="depend-wrap"></view>
+		<!-- 标签区域 -->
+		<view class="yui-tabs__wrap" :style="[innerWrapStyle,wrapStyle]">
+			<!-- scrollX为true,表示允许横向滚动 -->
+			<scroll-view class="yui-tabs__scroll" :class="[scrollX?'enable-sroll':'']" :scroll-x="scrollX"
+				:scroll-anchoring="true" enable-flex :scroll-left="scrollLeft"
+				:scroll-into-view="!scrollToCenter?scrollId:''" scroll-with-animation :style="[scrollStyle]">
+				<view class="yui-tabs__nav" :class="[navClass]" :style=[navStyle]>
+					<view class="yui-tab" v-for="(tab,index) in tabList" :key="index" @tap.stop="handleClick(index)"
+						:id="`tab_${index}`" :class="[tabClass(index, tab)]" :style="[tabStyle(tab)]">
+						<view class="yui-tab__text">
+							<slot :name="tab.titleSlot">{{tab.label}}</slot>
+							<text :class="[infoClass(tab)]" v-if="tab.badge || tab.dot">{{tab.badge}}</text>
+						</view>
+					</view>
+					<view v-if="isLine" class="yui-tabs__line" :style="[lineStyle,lineAnimatedStyle]"></view>
+				</view>
+			</scroll-view>
+			<!-- 标签栏额外内容 -->
+			<view class="yui-tabs__extra">
+				<slot name="extra"></slot>
+			</view>
+		</view>
+		<!-- 标签内容 -->
+		<view class="yui-tabs__content" :class="{'yui-tabs__content--animated':animated}">
+			<view class="yui-tabs__track" :style="[trackStyle]">
+				<view class="yui-tab__pane" :class="[paneClass(index,tab)]" v-for="(tab,index) in tabList" :key="index"
+					:style="[tab.paneStyle]" @touchstart="touchStart" @touchmove="touchMove($event,index)"
+					@touchend="touchEnd($event,index)">
+					<view v-if="tab.rendered ? true :value == index">
+						<slot :name="tab.slot"></slot>
+					</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+	// 1.优化滑动切换与上下滚动互相影响的bug。
+	// 2.考虑是否增加滚动导航
+	import {
+		isNull,
+		addUnit,
+		isDef,
+		isObject,
+		getDirection
+	} from "../yui-tabs/utils/uitls.js"
+	export default {
+		name: "yui-tabs",
+		emits: ['input', 'change', 'click', 'rendered', 'scroll'],
+		// uni-app自定义v-model需要按照如下的规范,直接用value和input,否则在微信小程序上会失效
+		model: {
+			prop: 'value',
+			event: 'input'
+		},
+		props: {
+			color: String, //标签主题色, 默认值为"#0022AB"
+			background: String, //标签栏背景色,默认值为"#fff"
+			lineWidth: [Number, String], //底部条宽度,默认单位为px, 默认值为20px
+			lineHeight: [Number, String], //底部条高度,默认单位为px,默认值为3px
+			titleActiveColor: String, //标题选中态颜色
+			titleInactiveColor: String, //标题默认态颜色
+			// 标签栏样式
+			wrapStyle: {
+				type: [Object, null],
+				default: () => {}
+			},
+			// 动画时间,单位秒
+			duration: {
+				type: [Number, String],
+				default: 0.3,
+			},
+			// 样式风格类型,可选值为 card
+			type: {
+				type: String,
+				default: "line"
+			},
+			// v-model绑定属性,绑定当前选中标签的标识符(标签的下标)
+			value: {
+				type: Number,
+				default: -1
+			},
+			// 标签页数据,支持字符串类型与对象类型的数组结构
+			// 对象类型需符合{label:'标签1',slot:'slotName'}这样的格式,slot为自定义的标签内容插槽名,否则插槽名默认为"pane"+tab下标的命名
+			tabs: {
+				type: Array,
+				default: () => []
+			},
+			// 是否开启延迟渲染(首次切换到标签时才触发内容渲染)
+			isLazyRender: {
+				type: Boolean,
+				default: true,
+			},
+			// 是否开启切换标签内容时的转场动画
+			animated: {
+				type: Boolean,
+				default: false
+			},
+			// 保证组件的可见性,主要用于处理选中标签的底部线条位置
+			visible: {
+				type: Boolean,
+				default: true
+			},
+			// 标签页是否滚动吸顶
+			fixed: Boolean,
+			// 滚动吸顶下与顶部的最小距离,默认 px
+			offsetTop: {
+				type: Number,
+				default: 0
+			},
+			// 滚动吸顶/粘性布局下,标签栏的z-index值
+			zIndex: {
+				type: Number,
+				default: 99
+			},
+			// 是否使用粘性定位布局
+			sticky: Boolean,
+			// 粘性布局的判断阈值
+			stickyThreshold: {
+				type: Number,
+				default: 0
+			},
+			// 标签栏滚动时当前标签居中
+			scrollToCenter: {
+				type: Boolean,
+				default: true,
+			},
+			//  标签栏的滚动阈值(仅在ellipsis="false"且type不为"card"下时有效),标签数量超过阈值且总宽度超过标签栏宽度时开始横向滚动(切换时会自动将当前标签居中)
+			scrollThreshold: {
+				type: [Number, String],
+				default: 5
+			},
+			// 是否省略过长的标题文字
+			ellipsis: {
+				type: Boolean,
+				default: true,
+			},
+			// 是否开启手势滑动切换
+			swipeable: {
+				type: Boolean,
+				default: false,
+			},
+			// 是否开启标签内容的拖动动画(该属性依赖于swipeable、is-lazy-render的开启;该属性开启时考虑给包裹内容的容器增加一个min-height,因为开启该属性后,其他未显示出来的标签内容会沿用当前显示的高度,拖动切换后由于高度不一致会有回弹)
+			swipeAnimated: {
+				type: Boolean,
+				default: false,
+			},
+			// 滑动切换的滑动距离阈值,手指滑动页面触发切换的阈值,单位为px,表示横向滑动整个可视区域的多少px时才切换标签内容
+			swipeThreshold: {
+				type: [Number, String],
+				default: 50,
+			},
+		},
+		data() {
+			return {
+				tabList: [], //标签页数据
+				scrollId: 'tab_0', //值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
+				scrollLeft: 0, //设置横向滚动条位置
+				extraWidth: 0, //标签栏右侧额外区域宽度
+				contentWidth: 0, //标签内容宽度
+				trackStyle: null, //标签内容滑动轨道样式
+				touchInfo: {
+					inited: false, //标记左右滑动时的初始化状态
+					startX: null, //记录touch位置的横坐标
+					startY: null, //记录touch位置的纵坐标
+					moved: false, //用来判断是否是一次移动
+					deltaX: 0, //记录拖动的横坐标距离
+					isLeftSide: false, //标记是否为左滑
+				},
+				// 标签栏底部线条动画相关
+				lineAnimated: false, //是否开启标签栏底部线条动画(首次不开启)
+				lineAnimatedStyle: {
+					transform: `translateX(-100%) translateX(-50%)`,
+					transitionDuration: `0s`
+				}, //标签栏底部线条动画样式
+				isFixed: false, //是否吸顶
+			}
+		},
+		computed: {
+			// 样式风格是否为line
+			isLine() {
+				return this.type === "line"
+			},
+			// 标签页容器class
+			tabsClass() {
+				return `yui-tabs--${this.type} ${this.visible?'yui-tabs--visible':''} ${this.fixed || this.isFixed?'yui-tabs--fixed':''} `
+			},
+			// 标签栏class
+			navClass() {
+				return `yui-tabs__nav--${this.type}`
+			},
+			// 标签栏style
+			navStyle() {
+				const style = {}
+				if (this.type === "card") style.borderColor = this.color
+				return style
+			},
+			// 标签栏包裹层样式
+			innerWrapStyle() {
+				const style = {
+					background: this.background,
+				}
+				// 滚动吸顶下
+				if (this.fixed || this.isFixed) {
+					style.top = this.offsetTop + "px"
+					style.zIndex = this.zIndex
+				}
+				return style
+			},
+			// 滚动区域样式
+			scrollStyle() {
+				return {
+					width: `calc(100% - ${this.extraWidth}px)`
+				}
+			},
+			// 标签栏底部线条样式
+			lineStyle() {
+				const {
+					lineWidth,
+					lineHeight,
+					duration
+				} = this;
+				const lineStyle = {
+					width: addUnit(lineWidth),
+					backgroundColor: this.color,
+				}
+
+				if (isDef(lineHeight)) {
+					const height = addUnit(lineHeight);
+					lineStyle.height = height;
+					lineStyle.borderRadius = height;
+				}
+				return lineStyle
+			},
+			// 是否允许横向滚动
+			scrollX() {
+				return !this.ellipsis && this.type !== "card" && this.tabs.length > this.scrollThreshold
+			},
+			dataLen() {
+				return this.tabList.length
+			}
+		},
+		watch: {
+			// 监听选中标识符变化
+			value: {
+				handler(val, oldVal) {
+					this.tabChange(val, oldVal) //标签切换
+					this.changeStyle() // 样式切换
+				}
+			},
+			// 监听tabs变化,重新初始化tabList
+			tabs: {
+				handler(val) {
+					this.updateTabList(); //更新tabList
+				},
+				deep: true
+			},
+		},
+		created() {
+			this.initTabList() // 初始化tabList
+		},
+		mounted() {
+			this.init() //初始化操作
+			this.listenEvent(); //监听事件
+		},
+		methods: {
+			// 获取元素位置信息
+			getRect(select) {
+				return new Promise((res, rej) => {
+					if (!select) rej('Parameter is empty');
+					let query
+					// #ifdef MP-ALIPAY
+					query = uni.createSelectorQuery()
+					// #endif
+					// #ifndef MP-ALIPAY
+					query = uni.createSelectorQuery().in(this)
+					// #endif
+					query.select(select).boundingClientRect(rect => res(rect)).exec();
+				})
+			},
+			// 标签项class
+			tabClass(index, tab) {
+				return `yui-tab_${index} ${tab.active?'yui-tab--active':''} ${this.ellipsis && !this.scrollX?'yui-tab__ellipsis':''}`
+			},
+			// 标签内容class
+			paneClass(index, tab) {
+				return `yui-tab_pane${index} ${tab.active?'yui-pane--active':''}`
+			},
+			// 标签项style
+			tabStyle(tab) {
+				let activeColor = this.titleActiveColor
+				let inactiveColor = this.titleInactiveColor
+				let background = ""
+				// type="text" 时,选中时使用主题色
+				if (this.type === "text" && isNull(activeColor)) {
+					activeColor = this.color
+				}
+
+				// type="card" 时,未选中则使用主题色
+				if (this.type === "card") {
+					background = this.color
+					if (isNull(inactiveColor)) inactiveColor = this.color
+				}
+
+				// type="button" 时
+				if (this.type === "button") {
+					background = this.color
+				}
+				return {
+					color: tab.active ? activeColor : inactiveColor,
+					background: tab.active ? background : "",
+				}
+			},
+			// 标题右上角信息class
+			infoClass(tab) {
+				return ` yui-tab__info ${tab.dot?'yui-tab__info--dot':''}`
+			},
+			// 监听事件
+			listenEvent() {
+				const that = this
+				// 粘性定位布局下的吸顶处理
+				if (this.sticky) {
+					uni.$on('onPageScroll', function(e) {
+						that.getRect('.depend-wrap').then(rect => {
+							that.isFixed = rect.bottom - that.stickyThreshold <= that.offsetTop
+							// 	滚动时触发,仅在 sticky 模式下生效,{ scrollTop: 距离顶部位置, isFixed: 是否吸顶 }
+							that.$emit("scroll", {
+								scrollTop: e.scrollTop,
+								isFixed: that.isFixed
+							})
+						})
+					})
+				}
+			},
+			// 初始化操作 
+			async init() {
+				//获取额外区域的宽度
+				let rect = await this.getRect('.yui-tabs__extra')
+				this.extraWidth = rect ? rect.width : 0
+
+				//获取标签内容区域的宽度
+				rect = await this.getRect('.yui-tabs__content')
+				this.contentWidth = rect ? rect.width : 0
+
+				//获取标签容器距离视口左侧的left值
+				rect = await this.getRect('.yui-tabs')
+				const parentLeft = rect ? rect.left : 0
+				// 保存每个tab的translateX
+				this.tabList.forEach(async (tab, index) => {
+					const rect = await this.getRect('.yui-tab_' + index);
+					tab.translateX = rect ? rect.left + rect.width / 2 - parentLeft : 0
+					tab.scrollLeft = tab.translateX - this.contentWidth / 2
+					if (tab.scrollLeft < 0) tab.scrollLeft = 0
+					if (index === this.value) {
+						this.tabChange(this.value, -1) //标签切换
+						this.changeStyle(); //样式切换
+					}
+				})
+			},
+			// 初始化tabList
+			initTabList() {
+				const tabs = this.tabs.filter(o => !isNull(o))
+				this.tabList = tabs.map((item, index) => {
+					const isCurr = this.value == index
+					const tab = {
+						label: '', //标签名称
+						slot: 'pane' + index, //标签内容的插槽名称,默认以"pane"+标签下标命名
+						titleSlot: 'title' + index, //标签标题的插槽名称,默认以"title"+标签下标命名
+						disabled: false, //是否禁用标签
+						active: false, //是否选中
+						rendered: !this.isLazyRender, //标记是否渲染过
+						show: false, // 是否显示内容
+						dot: false, //是否在标题右上角显示小红点
+						translateX: 0, //底部线条translateX值,
+						scrollLeft: 0, //当前标签对应的横向滚动条位置
+					}
+
+					tab.paneStyle = this.animated ? {
+						visibility: tab.show ? 'visible' : 'visible',
+						height: tab.show ? 'auto' : '0px'
+					} : {
+						display: tab.show ? 'block' : 'none'
+					};
+					// 读取标签对象值
+					if (isObject(item)) {
+						tab.label = item.label
+						tab.slot = isNull(item.slot) ? tab.slot : item.slot
+						tab.titleSlot = isNull(item.titleSlot) ? tab.titleSlot : item.titleSlot
+						tab.dot = isNull(item.dot) ? tab.dot : item.dot
+						tab.badge = !isNull(item.badge) && !tab.dot ? item.badge : ""
+					} else {
+						tab.label = item
+					}
+					return tab
+				})
+
+			},
+			// 更新tabList
+			updateTabList() {
+				// 如果长度有变化,表示tabs有删除或新增,重新init一次
+				if (this.tabs.length != this.dataLen) {
+					this.initTabList() //初始化tabList
+				} else {
+					// 否则仅仅更改label,badge,dot值
+					this.tabs.forEach((item, i) => {
+						const tab = this.tabList[i]
+						// 读取标签对象值
+						if (isObject(item)) {
+							this.$set(tab, "label", item.label)
+							this.$set(tab, "dot", isNull(item.dot) ? tab.dot : item.dot)
+							this.$set(tab, "badge", !isNull(item.badge) && !tab.dot ? item.badge : "")
+						} else {
+							this.$set(tab, "label", item)
+						}
+					})
+				}
+
+				this.$nextTick(() => {
+					this.init() //初始化操作
+				})
+			},
+			// 标签点击事件
+			handleClick(index) {
+				// if (this.tabList[index].disabled) return //禁用时不允许切换
+				this.$emit('click', index, this.tabs[index]) // 标签点击事件
+				if (this.value == index) return //不允许重复切换同一标签
+				const oldValue = this.value //获取旧的index
+				//更新v-model绑定的值
+				this.$emit('input', index) //更新v-model绑定的值
+			},
+			// 标签切换
+			tabChange(value, oldValue) {
+				const oldTab = this.tabList[oldValue] || {} //上一个tab
+				const currTab = this.tabList[value] //当前tab
+				// 设置选中态
+				oldTab.active = false
+				currTab.active = true
+
+				// 触发rendered事件
+				if (this.isLazyRender && !currTab.rendered) {
+					this.$emit('rendered', value, this.tabs[value])
+				}
+				currTab.rendered = true //标记渲染过
+
+				oldTab.show = false //隐藏旧内容区域
+				currTab.show = true //显示当前tab对应的内容区域
+				// 触发change事件
+				if (oldValue !== -1) this.$emit('change', value, this.tabs[value])
+			},
+			// 样式切换
+			changeStyle() {
+				// 标签栏允许滚动
+				if (this.scrollX) {
+					if (this.scrollToCenter) {
+						// 设置横向滚动条位置,当前标签滚动到中心位置
+						this.scrollLeft = this.tabList[this.value].scrollLeft
+						console.log(this.scrollLeft);
+					} else {
+						//设置scroll-into-view
+						this.scrollId = `tab_${this.value-1}`;
+					}
+				}
+				this.changeLineStyle() //改变标签栏底部线条位置
+				this.changeTrackStyle(false, this.duration) //改变标签内容滑动轨道样式
+				this.changePaneStyle() //改变标签内容样式
+			},
+			// 改变标签栏底部线条位置
+			changeLineStyle() {
+				// 仅在 type="line" 时有效
+				if (!this.isLine) return
+				const val = this.tabList[this.value].translateX
+				const transform = `translateX(${isDef(val) ? val + "px" : '-100%'}) translateX(-50%)`
+				const duration = `${this.lineAnimated?this.duration:'0'}s`
+				this.$set(this.lineAnimatedStyle, 'transform', transform)
+				this.$set(this.lineAnimatedStyle, 'transitionDuration', duration)
+
+				this.$nextTick(() => {
+					this.lineAnimated = true //是否开启标签栏动画
+				})
+			},
+			// 改变标签内容滑动轨道样式
+			changeTrackStyle(isSlide = false, duration = 0, offsetWidth = 0) {
+				if (!this.animated) return
+				// isSlide为true,表示左右滑动;false表示点击标签的转场动画
+				this.trackStyle = {
+					'transform': isSlide ? `translate3d(${offsetWidth}px,0,0)` : `translateX(${-100 * this.value}%)`,
+					'transition': `transform ${duration}s ease-in-out`
+				}
+			},
+			// 改变标签内容样式
+			changePaneStyle() {
+				this.getRect('.yui-tab__pane' + this.value).then(rect => {
+					// 有拖动动画时,隐藏的标签内容高度取显示的标签内容高度
+					const height = rect && this.swipeAnimated ? rect.height : 0
+					this.tabList.forEach(tab => {
+						const paneStyle = this.animated ? {
+							// 有拖动动画时或指定标签内容显示时,为visible
+							visibility: this.swipeAnimated || tab.show ? 'visible' : 'hidden',
+							height: tab.show ? 'auto' : height + 'px'
+						} : {
+							display: tab.show ? 'block' : 'none'
+						};
+						this.$set(tab, "paneStyle", paneStyle)
+					})
+				})
+			},
+			touchStart(e) {
+				// 禁止滑动
+				if (!this.swipeable) return
+				this.touchInfo.inited = true //touch开始时,将touchInfo对象设置为已初始化状态
+				const touch = e.touches[0];
+				// 记录touch位置的横坐标与纵坐标
+				this.touchInfo.startX = touch.pageX
+				this.touchInfo.startY = touch.pageY
+
+				this.touchInfo.moved = false //用来判断是否是一次移动
+			},
+			touchMove(e, index) {
+				if (!this.touchInfo.inited) return
+				const {
+					pageX,
+					pageY
+				} = e.changedTouches[0];
+				const {
+					startX,
+					startY
+				} = this.touchInfo || {}
+
+				// 滑动方向不为左右时阻止
+				const direction = getDirection(startX, startY, pageX, pageY)
+				if (direction != 3 && direction != 4) return
+				e.stopPropagation()
+
+				// 横坐标偏移量
+				const deltaX = pageX - startX
+
+				// 标记是左滑还是右滑
+				const isLeftSide = deltaX >= 0
+				// 如果当前为第一页内容,则不允许左滑;最后一页内容,则不允许右滑
+				if ((isLeftSide && index == 0) || (!isLeftSide && index == this.dataLen - 1)) {
+					return
+				}
+				this.touchInfo.isLeftSide = isLeftSide
+				this.touchInfo.moved = true
+				this.touchInfo.deltaX = Math.abs(deltaX)
+				// 改变标签内容的样式,模拟拖动动画效果
+				if (this.swipeAnimated) {
+					const offsetWidth = this.contentWidth * this.value * -1 + deltaX
+					this.changeTrackStyle(true, 0, offsetWidth)
+				}
+			},
+			touchEnd(e, index) {
+				if (!this.touchInfo.moved) return
+				const {
+					isLeftSide,
+					deltaX
+				} = this.touchInfo || {}
+				// 移动的横坐标偏移量大于指定的滚动阈值时,则切换显示状态,否则还原
+				if (Math.abs(deltaX) > Number(this.swipeThreshold)) {
+					// 根据是否为左滑查找需要滑动到的标签内容页下标,切换标签内容
+					index = index + (isLeftSide ? -1 : 1)
+					if (index > -1 && index < this.dataLen) this.handleClick(index)
+				} else {
+					this.changeTrackStyle(false, this.duration)
+				}
+				// 一次touch完成后,重置touchInfo对象尚未初始化状态
+				this.touchInfo.inited = false
+			},
+			// 外层元素大小或组件显示状态变化时,可以调用此方法来触发重绘
+			resize() {
+				this.init()
+			},
+		}
+	}
+</script>
+
+<style lang="less" scoped>
+	@bgColor: #fff; //背景色
+	@themeColor: #0022AB; //主题色
+	@inactiveColor: #646566; //标题未选中颜色
+	@activeColor: #323233; //标题选中颜色
+	@cardActiveColor: #fff; //type=="card"下的标题选中颜色
+	@disabledColor: #c8c9cc; //禁用颜色
+	@dotColor: #e53935; //小红点、徽标背景色
+	@badgeColor: #fff; //徽标内容颜色
+
+	.yui-tabs {
+		position: relative;
+		width: 100%;
+
+		.depend-wrap {
+			position: absolute;
+			top: 0;
+		}
+
+
+		// 开启粘性定位布局
+		&--fixed {
+
+			// 导航区域包裹层
+			.yui-tabs__wrap {
+				position: fixed;
+				top: 0;
+				right: 0;
+				left: 0;
+				z-index: 99;
+			}
+		}
+
+		// 不显示滚动条
+		::-webkit-scrollbar {
+			display: none;
+			width: 0;
+			height: 0;
+			-webkit-appearance: none;
+			background: transparent;
+			color: transparent;
+		}
+
+		// 导航区域包裹层
+		&__wrap {
+			position: relative;
+			display: flex;
+			align-items: center;
+			overflow: hidden;
+			visibility: hidden;
+			height: 0;
+			background: @bgColor;
+		}
+
+		// 标签页可见
+		&--visible {
+
+			// 导航区域包裹层
+			.yui-tabs__wrap {
+				visibility: visible;
+				height: auto;
+			}
+		}
+
+		// 卡片风格
+		&--card {
+
+			// 导航区域包裹层
+			.yui-tabs__wrap {
+				margin: 0 32rpx;
+				border-radius: 8rpx;
+			}
+		}
+
+		// 按钮风格
+		&--button {
+
+			// 导航区域包裹层
+			.yui-tabs__wrap {
+				margin: 0 32rpx;
+			}
+		}
+
+
+		// scroll-view组件样式
+		&__scroll {
+			position: relative;
+			width: 100%;
+			height: 80rpx;
+
+			// 允许滚动
+			&.enable-sroll {
+				white-space: nowrap; // 使用横向滚动时,需要给<scroll-view>添加white-space: nowrap;样式
+			}
+		}
+
+
+		// 导航区域
+		&__nav {
+			position: relative;
+			box-sizing: content-box;
+			user-select: none;
+			height: 80rpx;
+			flex: 1;
+			display: flex;
+
+
+			// 导航标签
+			.yui-tab {
+				position: relative;
+				display: inline-block;
+				line-height: 80rpx;
+				font-size: 28rpx;
+				color: @inactiveColor;
+				text-align: center;
+				padding: 0 8rpx;
+				flex: 1;
+				cursor: pointer;
+				-webkit-tap-highlight-color: transparent;
+				transition-property: color background-color;
+				transition-duration: 0.1s;
+
+				// 选中状态
+				&--active {
+					color: @activeColor;
+					font-weight: 500;
+				}
+
+				// 禁用状态
+				&--disabled {
+					color: @disabledColor;
+					cursor: not-allowed;
+				}
+
+
+				// 标题文字
+				&__text {
+					position: relative;
+					display: inline;
+				}
+
+
+				// 文字省略
+				&__ellipsis {
+					display: -webkit-box; //定义为盒子显示
+					overflow: hidden;
+					text-overflow: ellipsis; //文本溢出隐藏为省略号
+					-webkit-line-clamp: 1; // 限制一个块元素显示的文本行数
+					-webkit-box-orient: vertical; //盒模型子元素排列: vertical(竖排)orhorizontal(横排)
+				}
+
+				// 标签文字右上角徽标的内容
+				&__info {
+					display: inline-block;
+					position: absolute;
+					top: 0;
+					right: 0;
+					box-sizing: border-box;
+					min-width: 36rpx;
+					padding: 0 4rpx;
+					color: @badgeColor;
+					font-weight: 500;
+					font-size: 18rpx;
+					line-height: 26rpx;
+					text-align: center;
+					background-color: @dotColor;
+					border-radius: 36rpx;
+					transform: translate(50%, -50%);
+					transform-origin: 100%;
+					text-align: center;
+				}
+
+				&__info--dot {
+					line-height: unset;
+					padding: 0;
+					width: 12rpx;
+					min-width: 0;
+					height: 12rpx;
+					background-color: @dotColor;
+					border-radius: 100%;
+				}
+			}
+
+			// 文本风格
+			&--text {
+
+				.yui-tab {
+					&--active {
+						color: @themeColor;
+					}
+				}
+			}
+
+			// 卡片风格
+			&--card {
+				box-sizing: border-box;
+				border: 2rpx solid @themeColor;
+				border-radius: 8rpx;
+
+				.yui-tab {
+					color: @themeColor;
+
+					&--active {
+						background-color: @themeColor;
+						color: @cardActiveColor;
+					}
+				}
+			}
+
+			// 按钮风格
+			&--button {
+				.yui-tab {
+					height: 40rpx;
+					line-height: 40rpx;
+					margin-top: 20rpx;
+					border-radius: 4rpx;
+					flex: unset;
+
+					&--active {
+						background-color: @themeColor;
+						color: @cardActiveColor;
+					}
+				}
+			}
+
+		}
+
+
+		// 标签右侧的补充区域
+		&__extra {
+			position: relative;
+			display: inline-flex;
+			white-space: nowrap;
+		}
+
+		// 底部线条
+		&__line {
+			position: absolute;
+			bottom: 6rpx;
+			left: 0;
+			width: 40rpx;
+			height: 6rpx;
+			background-color: @themeColor;
+			border-radius: 6rpx;
+			transform: translateX(-100%) translateX(-50%);
+			// transition-duration: 0.3s;
+		}
+
+
+		// 标签内容的滑动轨道容器
+		&__track {
+			position: relative;
+			display: flex;
+			width: 100%;
+			height: unset;
+			will-change: left;
+			background-color: @bgColor;
+		}
+
+		// 标签内容
+		&__content {
+			background-color: @bgColor;
+			overflow: hidden;
+
+			.yui-tab__pane {
+				flex-shrink: 0;
+				box-sizing: border-box;
+				width: 100%;
+			}
+		}
+
+		// 标签内容转场动画样式
+		&__content--animated {
+			overflow: hidden;
+
+			.yui-tab__pane {
+				transition-duration: 0.3s;
+			}
+		}
+	}
+</style>

+ 907 - 0
uni_modules/yui-tabs/components/yui-tabs/version/yui-tabs(1.0.7).vue

@@ -0,0 +1,907 @@
+<template>
+	<view class="yui-tabs" :class="[tabsClass]">
+		<!-- 依赖元素,用于处理滚动吸顶所需 -->
+		<view class="depend-wrap"></view>
+		<!-- 标签区域 -->
+
+		<view class="yui-tabs__wrap" :style="[innerWrapStyle,wrapStyle]">
+			<!-- scrollX为true,表示允许横向滚动 -->
+			<scroll-view class="yui-tabs__scroll" :class="[scrollX?'enable-sroll':'']" :scroll-x="scrollX"
+				:scroll-anchoring="true" enable-flex :scroll-left="scrollLeft"
+				:scroll-into-view="!scrollToCenter?scrollId:''" scroll-with-animation :style="[scrollStyle]">
+				<view class="yui-tabs__nav" :class="[navClass]" :style=[navStyle]>
+					<view class="yui-tab" v-for="(tab,index) in tabList" :key="index"
+						@tap.stop="handleClick(index,true)" :id="`tab_${index}`" :class="[tabClass(index, tab)]"
+						:style="[tabStyle(tab)]">
+						<view class="yui-tab__text">
+							<slot :name="tab.titleSlot">{{tab.label}}</slot>
+							<text :class="[infoClass(tab)]" v-if="tab.badge || tab.dot">{{tab.badge}}</text>
+						</view>
+					</view>
+					<view v-if="isLine" class="yui-tabs__line" :style="[lineStyle,lineAnimatedStyle]"></view>
+				</view>
+			</scroll-view>
+			<!-- 标签栏额外内容 -->
+			<view class="yui-tabs__extra">
+				<slot name="extra"></slot>
+			</view>
+		</view>
+		<view v-show="isFixed" class="yui-tabs__placeholder" :style="[{height:placeholderHeight+'px'}]"></view>
+		<!-- 标签内容:普通实现 -->
+		<view v-if="!swiper" class="yui-tabs__content" :class="{'yui-tabs__content--animated':animated}">
+			<view class="yui-tabs__track" :style="[trackStyle]">
+				<view class="yui-tab__pane" :class="[paneClass(index,tab)]" v-for="(tab,index) in tabList" :key="index"
+					:style="[tab.paneStyle]" @touchstart="touchStart" @touchmove="touchMove($event,index)"
+					@touchend="touchEnd($event,index)">
+					<view v-if="tab.rendered ? true :value == index">
+						<slot :name="tab.slot"></slot>
+					</view>
+				</view>
+			</view>
+		</view>
+
+		<!-- 标签内容:使用swiper组件实现左右滑动 -->
+		<swiper v-if="swiper" class="yui-tabs__swiper" :current="current" :duration="swiperDuration"
+			@change="onSwiperChange">
+			<swiper-item class="yui-tabs__swiper--item" v-for="(tab,index) in tabList" :key="index"
+				@touchmove="stopTouchMove">
+				<view class="yui-tabs__swiper--wrap" v-if="tab.rendered ? true :value == index">
+					<slot :name="tab.slot"></slot>
+				</view>
+			</swiper-item>
+		</swiper>
+	</view>
+</template>
+
+<script>
+	// 1.优化滑动切换与上下滚动互相影响的bug。
+	// 2.考虑是否增加滚动导航
+	import {
+		isNull,
+		addUnit,
+		isDef,
+		isObject,
+		getDirection
+	} from "../yui-tabs/utils/uitls"
+	import {
+		props
+	} from "../yui-tabs/utils/const"
+	export default {
+		name: "yui-tabs",
+		emits: ['input', 'change', 'click', 'rendered', 'scroll'],
+		// uni-app自定义v-model需要按照如下的规范,直接用value和input,否则在微信小程序上会失效
+		model: {
+			prop: 'value',
+			event: 'input'
+		},
+		// shared:表示页面 wxss 样式将影响到自定义组件
+		options: {
+			styleIsolation: 'shared'
+		},
+		props,
+		data() {
+			return {
+				tabList: [], //标签页数据
+				scrollId: 'tab_0', //值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
+				scrollLeft: 0, //设置横向滚动条位置
+				extraWidth: 0, //标签栏右侧额外区域宽度
+				contentWidth: 0, //标签内容宽度
+				trackStyle: null, //标签内容滑动轨道样式
+				touchInfo: {
+					inited: false, //标记左右滑动时的初始化状态
+					startX: null, //记录touch位置的横坐标
+					startY: null, //记录touch位置的纵坐标
+					moved: false, //用来判断是否是一次移动
+					deltaX: 0, //记录拖动的横坐标距离
+					isLeftSide: false, //标记是否为左滑
+				},
+				// 标签栏底部线条动画相关
+				lineAnimated: false, //是否开启标签栏底部线条动画(首次不开启)
+				lineAnimatedStyle: {
+					transform: `translateX(-100%) translateX(-50%)`,
+					transitionDuration: `0s`
+				}, //标签栏底部线条动画样式
+				isFixed: false, //是否吸顶
+				current: this.value, //当前显示的滚动卡片
+				isTabClick: false, //是否为标签标题点击
+				placeholderHeight: 0, //标题栏占位高度
+			}
+		},
+		computed: {
+			// 样式风格是否为line
+			isLine() {
+				return this.type === "line"
+			},
+			// 标签页容器class
+			tabsClass() {
+				return `yui-tabs--${this.type} ${this.visible?'yui-tabs--visible':''} ${this.fixed || this.isFixed?'yui-tabs--fixed':''} ${this.swiper?'yui-tabs--swiper':''} `
+			},
+			// 标签栏class
+			navClass() {
+				return `yui-tabs__nav--${this.type}`
+			},
+			// 标签栏style
+			navStyle() {
+				const style = {}
+				if (this.type === "card") style.borderColor = this.color
+				return style
+			},
+			// 标签栏包裹层样式
+			innerWrapStyle() {
+				const style = {
+					background: this.background,
+				}
+				// 滚动吸顶下
+				if (this.fixed || this.isFixed) {
+					style.top = this.offsetTop + "px"
+					style.zIndex = this.zIndex
+				}
+				return style
+			},
+			// 滚动区域样式
+			scrollStyle() {
+				return {
+					width: `calc(100% - ${this.extraWidth}px)`
+				}
+			},
+			// 标签栏底部线条样式
+			lineStyle() {
+				const {
+					lineWidth,
+					lineHeight,
+					duration
+				} = this;
+				const lineStyle = {
+					width: addUnit(lineWidth),
+					backgroundColor: this.color,
+				}
+
+				if (isDef(lineHeight)) {
+					const height = addUnit(lineHeight);
+					lineStyle.height = height;
+					lineStyle.borderRadius = height;
+				}
+				return lineStyle
+			},
+			// 是否允许横向滚动
+			scrollX() {
+				return !this.ellipsis && this.type !== "card" && this.tabs.length > this.scrollThreshold
+			},
+			// 标签数量
+			dataLen() {
+				return this.tabList.length
+			},
+			// swiper组件滑动动画时长
+			swiperDuration() {
+				return this.animated ? this.duration * 1000 : 0
+			},
+		},
+		watch: {
+			// 监听选中标识符变化
+			value: {
+				handler(val, oldVal) {
+					this.current = val
+					this.tabChange(val, oldVal) //标签切换
+					this.changeStyle() // 样式切换
+				}
+			},
+			// 监听tabs变化,重新初始化tabList
+			tabs: {
+				handler(val) {
+					this.updateTabList(); //更新tabList
+				},
+				deep: true
+			},
+		},
+		created() {
+			this.initTabList() // 初始化tabList
+		},
+		mounted() {
+			this.$nextTick(() => {
+				this.init() //初始化操作
+				this.listenEvent(); //监听事件
+			})
+		},
+		methods: {
+			getNode(select) {
+				let query
+				// #ifdef MP-ALIPAY
+				query = uni.createSelectorQuery()
+				// #endif
+				// #ifndef MP-ALIPAY
+				query = uni.createSelectorQuery().in(this)
+				// #endif
+				return query.select(select)
+			},
+			// 获取元素位置信息
+			getRect(select) {
+				return new Promise((res, rej) => {
+					if (!select) rej('Parameter is empty');
+					this.getNode(select).boundingClientRect(rect => res(rect)).exec();
+				})
+			},
+			// 标签项class
+			tabClass(index, tab) {
+				return `yui-tab_${index} ${tab.active?'yui-tab--active':''} ${this.ellipsis && !this.scrollX?'yui-tab__ellipsis':''}`
+			},
+			// 标签内容class
+			paneClass(index, tab) {
+				return `yui-tab_pane${index} ${tab.active?'yui-pane--active':''}`
+			},
+			// 标签项style
+			tabStyle(tab) {
+				let activeColor = this.titleActiveColor
+				let inactiveColor = this.titleInactiveColor
+				let background = ""
+				let borderColor = ""
+				// type="line" 时
+				if (this.type === "line") {
+					if (isNull(activeColor)) activeColor = "#646566"
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				}
+
+				// type="text" 时,选中时使用主题色
+				if (this.type === "text") {
+					if (isNull(activeColor)) activeColor = this.color
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				}
+
+				// type="card" 时,未选中则使用主题色
+				if (this.type === "card") {
+					background = this.color
+					if (isNull(activeColor)) activeColor = "#fff"
+					if (isNull(inactiveColor)) inactiveColor = this.color
+				}
+
+				// type="button" 时
+				if (this.type === "button") {
+					background = this.color
+					if (isNull(activeColor)) activeColor = "#fff"
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				}
+
+				// type="line-button" 时
+				if (this.type === "line-button") {
+					borderColor = this.color
+					if (isNull(activeColor)) activeColor = this.color
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				}
+				return {
+					color: tab.active ? activeColor : inactiveColor,
+					background: tab.active ? background : "",
+					borderColor: tab.active ? borderColor : "",
+				}
+			},
+			// 标题右上角信息class
+			infoClass(tab) {
+				return ` yui-tab__info ${tab.dot?'yui-tab__info--dot':''}`
+			},
+			// 监听事件
+			listenEvent() {
+				const that = this
+				// 粘性定位布局下的吸顶处理
+				if (that.sticky) {
+					uni.$on('onPageScroll', function(e) {
+						that.getNode('.depend-wrap').boundingClientRect(rect => {
+							that.isFixed = rect.bottom - that.stickyThreshold <= that.offsetTop
+							// 	滚动时触发,仅在 sticky 模式下生效,{ scrollTop: 距离顶部位置, isFixed: 是否吸顶 }
+							that.$emit("scroll", {
+								scrollTop: e.scrollTop,
+								isFixed: that.isFixed
+							})
+						}).exec()
+					})
+				}
+			},
+			// 初始化操作 
+			async init() {
+				//获取额外区域的宽度
+				let rect = await this.getRect('.yui-tabs__extra')
+				this.extraWidth = rect ? rect.width : 0
+
+				//获取标签内容区域的宽度
+				rect = await this.getRect('.yui-tabs__content')
+				this.contentWidth = rect ? rect.width : 0
+
+
+				rect = await this.getRect('.yui-tabs__wrap')
+				const halfWrapWidth = rect ? rect.width / 2 : 0
+				this.placeholderHeight = rect ? rect.height : 0
+
+				//获取标签容器距离视口左侧的left值
+				rect = await this.getRect('.yui-tabs')
+				const parentLeft = rect ? rect.left : 0
+				// 保存每个tab的translateX
+				this.tabList.forEach(async (tab, index) => {
+					const rect = await this.getRect('.yui-tab_' + index);
+					tab.translateX = rect ? rect.left + rect.width / 2 - parentLeft : 0
+					tab.scrollLeft = tab.translateX - halfWrapWidth
+					if (tab.scrollLeft < 0) tab.scrollLeft = 0
+					if (index === this.value) {
+						this.tabChange(this.value, -1) //标签切换
+						this.changeStyle(); //样式切换
+					}
+				})
+			},
+			// 初始化tabList
+			initTabList() {
+				const tabs = this.tabs.filter(o => !isNull(o))
+				this.tabList = tabs.map((item, index) => {
+					const isCurr = this.value == index
+					const tab = {
+						label: '', //标签名称
+						slot: 'pane' + index, //标签内容的插槽名称,默认以"pane"+标签下标命名
+						titleSlot: 'title' + index, //标签标题的插槽名称,默认以"title"+标签下标命名
+						disabled: false, //是否禁用标签
+						active: false, //是否选中
+						rendered: !this.isLazyRender, //标记是否渲染过
+						show: false, // 是否显示内容
+						dot: false, //是否在标题右上角显示小红点
+						translateX: 0, //底部线条translateX值,
+						scrollLeft: 0, //当前标签对应的横向滚动条位置
+					}
+
+					tab.paneStyle = this.animated ? {
+						visibility: tab.show ? 'visible' : 'visible',
+						height: tab.show ? 'auto' : '0px'
+					} : {
+						display: tab.show ? 'block' : 'none'
+					};
+					// 读取标签对象值
+					if (isObject(item)) {
+						tab.label = item.label
+						tab.slot = isNull(item.slot) ? tab.slot : item.slot
+						tab.titleSlot = isNull(item.titleSlot) ? tab.titleSlot : item.titleSlot
+						tab.dot = isNull(item.dot) ? tab.dot : item.dot
+						tab.badge = !isNull(item.badge) && !tab.dot ? item.badge : ""
+					} else {
+						tab.label = item
+					}
+					return tab
+				})
+
+			},
+			// 更新tabList
+			updateTabList() {
+				// 如果长度有变化,表示tabs有删除或新增,重新init一次
+				if (this.tabs.length != this.dataLen) {
+					this.initTabList() //初始化tabList
+				} else {
+					// 否则仅仅更改label,badge,dot值
+					this.tabs.forEach((item, i) => {
+						const tab = this.tabList[i]
+						// 读取标签对象值
+						if (isObject(item)) {
+							this.$set(tab, "label", item.label)
+							this.$set(tab, "dot", isNull(item.dot) ? tab.dot : item.dot)
+							this.$set(tab, "badge", !isNull(item.badge) && !tab.dot ? item.badge : "")
+						} else {
+							this.$set(tab, "label", item)
+						}
+					})
+				}
+
+				this.$nextTick(() => {
+					this.init() //初始化操作
+				})
+			},
+			// 标签点击事件
+			handleClick(index, isTabClick = false) {
+				this.isTabClick = isTabClick // 是否为标签标题点击
+				// if (this.tabList[index].disabled) return //禁用时不允许切换
+				this.$emit('click', index, this.tabs[index], this.isTabClick) // 标签点击事件
+				if (this.value == index) return //不允许重复切换同一标签
+				//更新v-model绑定的值
+				this.$emit('input', index) //更新v-model绑定的值
+
+				//标签点击时页面是否滚动回到顶部
+				if (this.tabClickScrollTop) {
+					setTimeout(function() {
+						uni.pageScrollTo({
+							scrollTop: 0,
+							duration: 0
+						});
+					}, this.duration * 1000);
+				}
+			},
+			// 标签切换
+			tabChange(value, oldValue) {
+				const oldTab = this.tabList[oldValue] || {} //上一个tab
+				const currTab = this.tabList[value] //当前tab
+				// 设置选中态
+				oldTab.active = false
+				currTab.active = true
+
+				// 触发rendered事件
+				if (this.isLazyRender && !currTab.rendered) {
+					this.$emit('rendered', value, this.tabs[value])
+				}
+				currTab.rendered = true //标记渲染过
+
+				oldTab.show = false //隐藏旧内容区域
+				currTab.show = true //显示当前tab对应的内容区域
+				// 触发change事件
+				if (oldValue !== -1) this.$emit('change', value, this.tabs[value], this.isTabClick)
+			},
+			// 样式切换
+			changeStyle() {
+				// 标签栏允许滚动
+				if (this.scrollX) {
+					if (this.scrollToCenter) {
+						// 设置横向滚动条位置,当前标签滚动到中心位置
+						this.scrollLeft = this.tabList[this.value].scrollLeft
+					} else {
+						//设置scroll-into-view
+						this.scrollId = `tab_${this.value-1}`;
+					}
+				}
+				this.changeLineStyle() //改变标签栏底部线条位置
+
+				// 标签内容滑动非swiper实现时
+				if (!this.swiper) {
+					this.changeTrackStyle(false, this.duration) //改变标签内容滑动轨道样式
+					this.changePaneStyle() //改变标签内容样式
+				}
+			},
+			// 改变标签栏底部线条位置
+			changeLineStyle() {
+				// 仅在 type="line" 时有效
+				if (!this.isLine) return
+				const val = this.tabList[this.value].translateX
+				const transform = `translateX(${isDef(val) ? val + "px" : '-100%'}) translateX(-50%)`
+				const duration = `${this.lineAnimated?this.duration:'0'}s`
+				this.$set(this.lineAnimatedStyle, 'transform', transform)
+				this.$set(this.lineAnimatedStyle, 'transitionDuration', duration)
+
+				this.$nextTick(() => {
+					this.lineAnimated = true //是否开启标签栏动画
+				})
+			},
+			// 改变标签内容滑动轨道样式
+			changeTrackStyle(isSlide = false, duration = 0, offsetWidth = 0) {
+				if (!this.animated) return
+				// isSlide为true,表示左右滑动;false表示点击标签的转场动画
+				this.trackStyle = {
+					'transform': isSlide ? `translate3d(${offsetWidth}px,0,0)` : `translateX(${-100 * this.value}%)`,
+					'transition': `transform ${duration}s ease-in-out`
+				}
+			},
+			// 改变标签内容样式
+			changePaneStyle() {
+				this.getRect('.yui-tab__pane' + this.value).then(rect => {
+					// 有拖动动画时,隐藏的标签内容高度取显示的标签内容高度
+					const height = rect && this.swipeAnimated ? rect.height : 0
+					this.tabList.forEach(tab => {
+						const paneStyle = this.animated ? {
+							// 有拖动动画时或指定标签内容显示时,为visible
+							visibility: this.swipeAnimated || tab.show ? 'visible' : 'hidden',
+							height: tab.show ? 'auto' : height + 'px'
+						} : {
+							display: tab.show ? 'block' : 'none'
+						};
+						this.$set(tab, "paneStyle", paneStyle)
+					})
+				})
+			},
+			// 禁止swiper组件手动滑动
+			stopTouchMove() {
+				if (!this.swipeable) {
+					event.stopPropagation()
+				}
+			},
+			// swiper组件的current改变时会触发 change 事件
+			onSwiperChange(e) {
+				const current = e.target.current || e.detail.current
+				this.$emit('input', current) //更新v-model绑定的值
+			},
+			touchStart(e) {
+				// 禁止滑动
+				if (!this.swipeable) return
+				this.touchInfo.inited = true //touch开始时,将touchInfo对象设置为已初始化状态
+				const touch = e.touches[0];
+				// 记录touch位置的横坐标与纵坐标
+				this.touchInfo.startX = touch.pageX
+				this.touchInfo.startY = touch.pageY
+
+				this.touchInfo.moved = false //用来判断是否是一次移动
+			},
+			touchMove(e, index) {
+				if (!this.touchInfo.inited) return
+				const {
+					pageX,
+					pageY
+				} = e.changedTouches[0];
+				const {
+					startX,
+					startY
+				} = this.touchInfo || {}
+
+				// 滑动方向不为左右时阻止
+				const direction = getDirection(startX, startY, pageX, pageY)
+				if (direction != 3 && direction != 4) return
+				e.stopPropagation()
+
+				// 横坐标偏移量
+				const deltaX = pageX - startX
+
+				// 标记是左滑还是右滑
+				const isLeftSide = deltaX >= 0
+				// 如果当前为第一页内容,则不允许左滑;最后一页内容,则不允许右滑
+				if ((isLeftSide && index == 0) || (!isLeftSide && index == this.dataLen - 1)) {
+					return
+				}
+				this.touchInfo.isLeftSide = isLeftSide
+				this.touchInfo.moved = true
+				this.touchInfo.deltaX = Math.abs(deltaX)
+				// 改变标签内容的样式,模拟拖动动画效果
+				if (this.swipeAnimated) {
+					const offsetWidth = this.contentWidth * this.value * -1 + deltaX
+					this.changeTrackStyle(true, 0, offsetWidth)
+				}
+			},
+			touchEnd(e, index) {
+				if (!this.touchInfo.moved) return
+				const {
+					isLeftSide,
+					deltaX
+				} = this.touchInfo || {}
+				// 移动的横坐标偏移量大于指定的滚动阈值时,则切换显示状态,否则还原
+				if (Math.abs(deltaX) > Number(this.swipeThreshold)) {
+					// 根据是否为左滑查找需要滑动到的标签内容页下标,切换标签内容
+					index = index + (isLeftSide ? -1 : 1)
+					if (index > -1 && index < this.dataLen) this.handleClick(index)
+				} else {
+					this.changeTrackStyle(false, this.duration)
+				}
+				// 一次touch完成后,重置touchInfo对象尚未初始化状态
+				this.touchInfo.inited = false
+			},
+			// 外层元素大小或组件显示状态变化时,可以调用此方法来触发重绘
+			resize() {
+				this.init()
+			},
+		}
+	}
+</script>
+
+<style lang="less" scoped>
+	@bgColor: #fff; //背景色
+	@themeColor: #0022AB; //主题色
+	@inactiveColor: #646566; //标题未选中颜色
+	@activeColor: #323233; //标题选中颜色
+	@cardActiveColor: #fff; //type=="card"下的标题选中颜色
+	@disabledColor: #c8c9cc; //禁用颜色
+	@dotColor: #e53935; //小红点、徽标背景色
+	@badgeColor: #fff; //徽标内容颜色
+
+	.yui-tabs {
+		position: relative;
+		width: 100%;
+
+		.depend-wrap {
+			position: absolute;
+			top: 0;
+		}
+
+		&__placeholder {
+			width: 100%;
+		}
+
+
+		// 开启粘性定位布局
+		&--fixed {
+
+			// 导航区域包裹层
+			.yui-tabs__wrap {
+				position: fixed;
+				top: 0;
+				right: 0;
+				left: 0;
+				z-index: 99;
+			}
+		}
+
+
+		// 导航区域包裹层
+		&__wrap {
+			position: relative;
+			display: flex;
+			align-items: center;
+			overflow: hidden;
+			visibility: hidden;
+			height: 0;
+			background: @bgColor;
+
+			// 不显示滚动条
+			::-webkit-scrollbar {
+				display: none;
+				width: 0;
+				height: 0;
+				-webkit-appearance: none;
+				background: transparent;
+				color: transparent;
+			}
+		}
+
+		// 标签页可见
+		&--visible {
+
+			// 导航区域包裹层
+			.yui-tabs__wrap {
+				visibility: visible;
+				height: auto;
+			}
+		}
+
+		// 卡片风格
+		&--card {
+
+			// 导航区域包裹层
+			.yui-tabs__wrap {
+				margin: 0 32rpx;
+				border-radius: 8rpx;
+			}
+		}
+
+		// scroll-view组件样式
+		&__scroll {
+			position: relative;
+			width: 100%;
+			height: 80rpx;
+
+			// 允许滚动
+			&.enable-sroll {
+				white-space: nowrap; // 使用横向滚动时,需要给<scroll-view>添加white-space: nowrap;样式
+
+				.yui-tab__text {
+					white-space: nowrap;
+				}
+			}
+		}
+
+
+		// 导航区域
+		&__nav {
+			position: relative;
+			box-sizing: content-box;
+			user-select: none;
+			height: 80rpx;
+			flex: 1;
+			display: flex;
+
+
+			// 导航标签
+			.yui-tab {
+				position: relative;
+				display: inline-block;
+				line-height: 80rpx;
+				font-size: 28rpx;
+				color: @inactiveColor;
+				text-align: center;
+				padding: 0 8rpx;
+				flex: 1;
+				cursor: pointer;
+				-webkit-tap-highlight-color: transparent;
+				transition-property: color background-color border-color;
+				transition-duration: 0.2s;
+
+				// 选中状态
+				&--active {
+					color: @activeColor;
+					font-weight: 500;
+				}
+
+				// 禁用状态
+				&--disabled {
+					color: @disabledColor;
+					cursor: not-allowed;
+				}
+
+
+				// 标题文字
+				&__text {
+					position: relative;
+					display: inline;
+				}
+
+
+				// 文字省略
+				&__ellipsis {
+					display: -webkit-box; //定义为盒子显示
+					overflow: hidden;
+					text-overflow: ellipsis; //文本溢出隐藏为省略号
+					-webkit-line-clamp: 1; // 限制一个块元素显示的文本行数
+					-webkit-box-orient: vertical; //盒模型子元素排列: vertical(竖排)orhorizontal(横排)
+				}
+
+				// 标签文字右上角徽标的内容
+				&__info {
+					display: inline-block;
+					position: absolute;
+					top: 0;
+					left: 100%;
+					box-sizing: border-box;
+					min-width: 36rpx;
+					padding: 0 4rpx;
+					color: @badgeColor;
+					font-weight: 500;
+					font-size: 18rpx;
+					line-height: 26rpx;
+					text-align: center;
+					background-color: @dotColor;
+					border-radius: 36rpx;
+					transform: translateY(-50%);
+					transform-origin: 100%;
+					text-align: center;
+				}
+
+				&__info--dot {
+					line-height: unset;
+					padding: 0;
+					width: 12rpx;
+					min-width: 0;
+					height: 12rpx;
+					background-color: @dotColor;
+					border-radius: 100%;
+				}
+			}
+
+			// 文本风格
+			&--text {
+
+				.yui-tab {
+					&--active {
+						color: @themeColor;
+					}
+				}
+			}
+
+			// 卡片风格
+			&--card {
+				box-sizing: border-box;
+				border: 2rpx solid @themeColor;
+				border-radius: 8rpx;
+
+				.yui-tab {
+					color: @themeColor;
+
+					&--active {
+						background-color: @themeColor;
+						color: @cardActiveColor;
+					}
+				}
+			}
+
+			// 按钮风格
+			&--button {
+				.yui-tab {
+					height: 50rpx;
+					line-height: 50rpx;
+					margin-top: 15rpx;
+					flex: auto;
+					border-radius: 50rpx;
+					padding: 0 20rpx;
+					margin-left: 10rpx;
+
+					&:last-child {
+						margin-right: 10rpx;
+					}
+
+					&--active {
+						background-color: @themeColor;
+						color: @cardActiveColor;
+					}
+				}
+			}
+
+			// 线性按钮风格
+			&--line-button {
+				.yui-tab {
+					height: 50rpx;
+					line-height: 50rpx;
+					margin-top: 15rpx;
+					flex: auto;
+					border: 2rpx solid transparent;
+					border-radius: 50rpx;
+					padding: 0 20rpx;
+					margin-left: 10rpx;
+
+					&:last-child {
+						margin-right: 10rpx;
+					}
+
+					&--active {
+						border-color: @themeColor;
+						color: @themeColor;
+					}
+				}
+			}
+
+		}
+
+
+		// 标签右侧的补充区域
+		&__extra {
+			position: relative;
+			display: inline-flex;
+			white-space: nowrap;
+		}
+
+		// 底部线条
+		&__line {
+			position: absolute;
+			bottom: 6rpx;
+			left: 0;
+			width: 40rpx;
+			height: 6rpx;
+			background-color: @themeColor;
+			border-radius: 6rpx;
+			transform: translateX(-100%) translateX(-50%);
+			// transition-duration: 0.3s;
+		}
+
+
+		// 标签内容的滑动轨道容器
+		&__track {
+			position: relative;
+			display: flex;
+			width: 100%;
+			height: unset;
+			will-change: left;
+			background-color: @bgColor;
+		}
+
+		// 标签内容
+		&__content {
+			background-color: @bgColor;
+			overflow: hidden;
+
+			.yui-tab__pane {
+				flex-shrink: 0;
+				box-sizing: border-box;
+				width: 100%;
+			}
+		}
+
+		// 标签内容转场动画样式
+		&__content--animated {
+			overflow: hidden;
+
+			.yui-tab__pane {
+				transition-duration: 0.3s;
+			}
+		}
+
+		// 使用swpier组件进行左右滑动
+		&--swiper {
+			display: flex;
+			flex-direction: column;
+			height: 100%;
+			box-sizing: border-box;
+			overflow: hidden;
+		}
+
+		// 承载标签内容的滑动容器
+		&__swiper {
+			flex: 1;
+			box-sizing: border-box;
+
+			.yui-tabs__swiper--item {
+				flex: 1;
+				flex-direction: column;
+				box-sizing: border-box;
+			}
+
+			.yui-tabs__swiper--wrap {
+				position: absolute;
+				left: 0;
+				top: 0;
+				right: 0;
+				bottom: 0;
+				box-sizing: border-box;
+				display: flex;
+				flex: 1;
+			}
+		}
+	}
+</style>

+ 654 - 0
uni_modules/yui-tabs/components/yui-tabs/version/yui-tabs(1.0.8).vue

@@ -0,0 +1,654 @@
+<template>
+	<view class="yui-tabs" :class="[tabsClass]">
+		<!-- 依赖元素,用于处理滚动吸顶所需 -->
+		<view class="depend-wrap"></view>
+		<!-- 标签区域 -->
+
+		<view class="yui-tabs__wrap" :style="[innerWrapStyle,wrapStyle]">
+			<!-- scrollX为true,表示允许横向滚动 -->
+			<scroll-view class="yui-tabs__scroll" :class="[scrollX?'enable-sroll':'']" :scroll-x="scrollX"
+				:scroll-anchoring="true" enable-flex :scroll-left="scrollLeft"
+				:scroll-into-view="!scrollToCenter?scrollId:''" scroll-with-animation :style="[scrollStyle]">
+				<view class="yui-tabs__nav" :class="[navClass]" :style=[navStyle]>
+					<view class="yui-tab" v-for="(tab,index) in tabList" :key="index" @tap.stop="onClick(index,true)"
+						:id="`tab_${index}`" :class="[tabClass(index, tab)]" :style="[tabStyle(tab)]">
+						<view class="yui-tab__text">
+							<slot :name="tab.titleSlot">{{tab.label}}</slot>
+							<text :class="[infoClass(tab)]" v-if="tab.badge || tab.dot">{{tab.badge}}</text>
+						</view>
+					</view>
+					<view v-if="isLine" class="yui-tabs__line" :style="[lineStyle,lineAnimatedStyle]"></view>
+				</view>
+			</scroll-view>
+			<!-- 标签栏额外内容 -->
+			<view class="yui-tabs__extra">
+				<slot name="extra"></slot>
+			</view>
+		</view>
+		<view v-if="isFixed" class="yui-tabs__placeholder" :style="[{height:placeholderHeight+'px'}]"></view>
+		<!-- 标签内容:普通实现 -->
+		<view v-if="!swiper" class="yui-tabs__content"
+			:class="{'yui-tabs__content--animated':animated,'yui-tabs__content--scrollspy':scrollspy}">
+			<view class="yui-tabs__track" :style="[trackStyle]">
+				<view class="yui-tab__pane" :class="[paneClass(index,tab)]" v-for="(tab,index) in tabList" :key="index"
+					:style="[tab.paneStyle]" @touchstart="touchStart" @touchmove="touchMove($event,index)"
+					@touchend="touchEnd($event,index)">
+					<view v-if="tab.rendered ? true :value == index">
+						<slot :name="tab.slot"></slot>
+					</view>
+				</view>
+			</view>
+		</view>
+
+		<!-- 标签内容:使用swiper组件实现左右滑动 -->
+		<swiper v-if="swiper" class="yui-tabs__swiper" :current="current" :duration="swiperDuration"
+			@change="onSwiperChange">
+			<swiper-item class="yui-tabs__swiper--item" v-for="(tab,index) in tabList" :key="index"
+				@touchmove="stopTouchMove">
+				<view class="yui-tabs__swiper--wrap" v-if="tab.rendered ? true :value == index">
+					<slot :name="tab.slot"></slot>
+				</view>
+			</swiper-item>
+		</swiper>
+	</view>
+</template>
+
+<script>
+	// 1.优化滑动切换与上下滚动互相影响的bug。
+	// 2.考虑是否增加滚动导航
+	// * 微信小程序中调试基础库为2.25.0
+	import {
+		isNull,
+		addUnit,
+		isDef,
+		isObject,
+		getDirection,
+		callInterceptor,
+	} from "../yui-tabs/utils/uitls"
+	import {
+		emits,
+		props,
+		valueField
+	} from "../yui-tabs/utils/const"
+
+	export default {
+		name: "yui-tabs",
+		emits,
+		// shared:表示页面 wxss 样式将影响到自定义组件
+		options: {
+			styleIsolation: 'shared'
+		},
+		props,
+		data() {
+			return {
+				tabList: [], //标签页数据
+				scrollId: 'tab_0', //值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
+				scrollLeft: 0, //设置横向滚动条位置
+				extraWidth: 0, //标签栏右侧额外区域宽度
+				contentWidth: 0, //标签内容宽度
+				trackStyle: null, //标签内容滑动轨道样式
+				touchInfo: {
+					inited: false, //标记左右滑动时的初始化状态
+					startX: null, //记录touch位置的横坐标
+					startY: null, //记录touch位置的纵坐标
+					moved: false, //用来判断是否是一次移动
+					deltaX: 0, //记录拖动的横坐标距离
+					isLeftSide: false, //标记是否为左滑
+					upDown: false,
+				},
+				// 标签栏底部线条动画相关
+				lineAnimated: false, //是否开启标签栏底部线条动画(首次不开启)
+				lineAnimatedStyle: {
+					transform: `translateX(-100%) translateX(-50%)`,
+					transitionDuration: `0s`
+				}, //标签栏底部线条动画样式
+				isFixed: false, //是否吸顶
+				current: this[valueField], //当前显示的滚动卡片
+				isTabClick: false, //是否为标签标题点击
+				placeholderHeight: 0, //标题栏占位高度
+				windowHeight: 0, //屏幕高度
+				lockedScrollspy: false, //锁定滚动导航模式下点击标题栏触发的滚动逻辑
+			}
+		},
+		computed: {
+			// 样式风格是否为line
+			isLine() {
+				return this.type === "line"
+			},
+			// 标签页容器class
+			tabsClass() {
+				return `yui-tabs--${this.type} ${this.visible?'yui-tabs--visible':''} ${this.fixed || this.isFixed?'yui-tabs--fixed':''} ${this.swiper?'yui-tabs--swiper':''} `
+			},
+			// 标签栏class
+			navClass() {
+				return `yui-tabs__nav--${this.type}`
+			},
+			// 标签栏style
+			navStyle() {
+				const style = {}
+				if (this.type === "card") style.borderColor = this.color
+				return style
+			},
+			// 标签栏包裹层样式
+			innerWrapStyle() {
+				const style = {
+					background: this.background,
+				}
+				// 滚动吸顶下
+				if (this.fixed || this.isFixed) {
+					style.top = this.offsetTop + "px"
+					style.zIndex = this.zIndex
+				}
+				return style
+			},
+			// 滚动区域样式
+			scrollStyle() {
+				return {
+					width: `calc(100% - ${this.extraWidth}px)`
+				}
+			},
+			// 标签栏底部线条样式
+			lineStyle() {
+				const {
+					lineWidth,
+					lineHeight,
+					duration
+				} = this;
+				const lineStyle = {
+					width: addUnit(lineWidth),
+					backgroundColor: this.color,
+				}
+
+				if (isDef(lineHeight)) {
+					const height = addUnit(lineHeight);
+					lineStyle.height = height;
+					lineStyle.borderRadius = height;
+				}
+				return lineStyle
+			},
+			// 是否允许横向滚动
+			scrollX() {
+				return !this.ellipsis && this.type !== "card" && this.tabs.length > this.scrollThreshold
+			},
+			// 标签数量
+			dataLen() {
+				return this.tabList.length
+			},
+			// swiper组件滑动动画时长
+			swiperDuration() {
+				return this.animated ? this.duration * 1000 : 0
+			},
+			// 粘性布局下的滚动偏移量
+			scrollOffset() {
+				return this.sticky ? this.offsetTop + this.placeholderHeight : 0;
+			},
+		},
+		watch: {
+			// 监听tabs变化,重新初始化tabList
+			tabs: {
+				handler(val) {
+					this.updateTabList(); //更新tabList
+				},
+				deep: true
+			},
+		},
+		created() {
+			// 监听选中标识符变化
+			this.$watch(valueField, (val, oldVal) => {
+				this.current = val
+				this.tabChange(val, oldVal) //标签切换
+				this.changeStyle() // 样式切换
+			})
+
+			this.initTabList() // 初始化tabList
+		},
+		mounted() {
+			this.$nextTick(() => {
+				this.init() //初始化操作
+				this.listenEvent(); //监听事件
+			})
+		},
+		methods: {
+			// @exposed-api
+			resize() {
+				// 外层元素大小或组件显示状态变化时,可以调用此方法来触发重绘
+				this.init()
+			},
+			// 获取查询节点信息的对象
+			getSelectorQuery() {
+				let query = null
+				// #ifdef MP-ALIPAY
+				query = uni.createSelectorQuery()
+				// #endif
+				// #ifndef MP-ALIPAY
+				query = uni.createSelectorQuery().in(this)
+				// #endif
+				return query
+			},
+			// 获取元素位置信息
+			getRect(...selectors) {
+				return new Promise((resolve, reject) => {
+					if (!selectors) reject('Parameter is empty');
+					const query = this.getSelectorQuery()
+					selectors.forEach(seletor => {
+						query.select(seletor).boundingClientRect()
+					})
+					query.exec(data => {
+						data = data || []
+						resolve(data.length === 1 ? data[0] : data)
+					});
+				})
+			},
+			// 标签项class
+			tabClass(index, tab) {
+				return `yui-tab_${index} ${tab.active?'yui-tab--active':''} ${this.ellipsis && !this.scrollX?'yui-tab__ellipsis':''}`
+			},
+			// 标签内容class
+			paneClass(index, tab) {
+				return `yui-tab_pane${index} ${tab.active?'yui-pane--active':''}`
+			},
+			// 标签项style
+			tabStyle(tab) {
+				let activeColor = this.titleActiveColor
+				let inactiveColor = this.titleInactiveColor
+				let background = ""
+				let borderColor = ""
+				// type="line" 时
+				if (this.type === "line") {
+					if (isNull(activeColor)) activeColor = "#646566"
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				}
+
+				// type="text" 时,选中时使用主题色
+				if (this.type === "text") {
+					if (isNull(activeColor)) activeColor = this.color
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				}
+
+				// type="card" 时,未选中则使用主题色
+				if (this.type === "card") {
+					background = this.color
+					if (isNull(activeColor)) activeColor = "#fff"
+					if (isNull(inactiveColor)) inactiveColor = this.color
+				}
+
+				// type="button" 时
+				if (this.type === "button") {
+					background = this.color
+					if (isNull(activeColor)) activeColor = "#fff"
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				}
+
+				// type="line-button" 时
+				if (this.type === "line-button") {
+					borderColor = this.color
+					if (isNull(activeColor)) activeColor = this.color
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				}
+				return {
+					color: tab.active ? activeColor : inactiveColor,
+					background: tab.active ? background : "",
+					borderColor: tab.active ? borderColor : "",
+				}
+			},
+			// 标题右上角信息class
+			infoClass(tab) {
+				return ` yui-tab__info ${tab.dot?'yui-tab__info--dot':''}`
+			},
+			// 监听事件
+			listenEvent() {
+				const that = this
+				if (that.sticky || that.scrollspy) {
+					uni.$on('onPageScroll', function(e) {
+						// 粘性定位布局的吸顶处理
+						that.getRect('.depend-wrap').then((rect) => {
+							that.isFixed = rect.bottom - that.stickyThreshold <= that.offsetTop
+							// 	滚动时触发,仅在 sticky 模式下生效,{ scrollTop: 距离顶部位置, isFixed: 是否吸顶 }
+							that.$emit("scroll", {
+								scrollTop: e.scrollTop,
+								isFixed: that.isFixed
+							})
+						})
+
+						// 滚动导航模式下对选中标签的处理
+						if (that.scrollspy && !that.lockedScrollspy) {
+							that.getCurrIndexOnScroll().then(index => {
+								that.setCurrentIndex(index) //设置当前下标
+							})
+						}
+					})
+				}
+			},
+			// 滚动时获取要选中的下标
+			getCurrIndexOnScroll(res = []) {
+				return new Promise((resolve, rejct) => {
+					const selectors = this.tabList.map((o, i) => '.yui-tab_pane' + i)
+					this.getRect(...selectors).then(res => {
+						if (res.length === 0) return
+						// 标签内容的top小于标题栏的top,则说明已经与标题栏部分重合
+						let index = res.reduce((idx, o, i) => o.top < this.scrollOffset ? i : idx, 0)
+						// 判断最后一个标签内容是否完整显示在底部,是则默认选中
+						// const lastRect = res[res.length - 1] //最后一个标签内容
+						// if (lastRect.bottom <= this.windowHeight) index = res.length - 1
+						resolve(index)
+					})
+				})
+			},
+			// 初始化tabList
+			initTabList() {
+				const tabs = this.tabs.filter(o => !isNull(o))
+				this.tabList = tabs.map((item, index) => {
+					const isCurr = this[valueField] == index
+					const tab = {
+						label: '', //标签名称
+						slot: 'pane' + index, //标签内容的插槽名称,默认以"pane"+标签下标命名
+						titleSlot: 'title' + index, //标签标题的插槽名称,默认以"title"+标签下标命名
+						disabled: false, //是否禁用标签
+						active: false, //是否选中
+						rendered: !this.isLazyRender || this.scrollspy, //标记是否渲染过,非懒加载与滚动导航模式下默认渲染
+						show: this.scrollspy, // 是否显示内容(滚动导航模式默认显示)
+						dot: false, //是否在标题右上角显示小红点
+						translateX: 0, //底部线条translateX值,
+						scrollLeft: 0, //当前标签对应的横向滚动条位置
+						paneTop: 0, //滚动导航模式下标签内容距离屏幕顶部的距离
+					}
+
+					tab.paneStyle = this.animated ? {
+						visibility: tab.show ? 'visible' : 'visible',
+						height: tab.show ? 'auto' : '0px'
+					} : {
+						display: tab.show ? 'block' : 'none'
+					};
+					// 读取标签对象值
+					if (isObject(item)) {
+						tab.label = item.label
+						tab.slot = isNull(item.slot) ? tab.slot : item.slot
+						tab.titleSlot = isNull(item.titleSlot) ? tab.titleSlot : item.titleSlot
+						tab.dot = isNull(item.dot) ? tab.dot : item.dot
+						tab.badge = !isNull(item.badge) && !tab.dot ? item.badge : ""
+					} else {
+						tab.label = item
+					}
+					return tab
+				})
+
+			},
+			// 更新tabList
+			updateTabList() {
+				// 如果长度有变化,表示tabs有删除或新增,重新init一次
+				if (this.tabs.length != this.dataLen) {
+					this.initTabList() //初始化tabList
+				} else {
+					// 否则仅仅更改label,badge,dot值
+					this.tabs.forEach((item, i) => {
+						const tab = this.tabList[i]
+						// 读取标签对象值
+						if (isObject(item)) {
+							this.$set(tab, "label", item.label)
+							this.$set(tab, "dot", isNull(item.dot) ? tab.dot : item.dot)
+							this.$set(tab, "badge", !isNull(item.badge) && !tab.dot ? item.badge : "")
+						} else {
+							this.$set(tab, "label", item)
+						}
+					})
+				}
+
+				this.$nextTick(() => {
+					this.init() //初始化操作
+				})
+			},
+			// 初始化操作
+			async init() {
+				//屏幕高度
+				this.windowHeight = uni.getSystemInfoSync().windowHeight;
+
+				//获取额外区域的宽度
+				let rect = await this.getRect('.yui-tabs__extra')
+				this.extraWidth = rect ? rect.width : 0
+
+				//获取标签内容区域的宽度
+				rect = await this.getRect('.yui-tabs__content')
+				this.contentWidth = rect ? rect.width : 0
+
+				// 获取标题栏包裹层的rect
+				rect = await this.getRect('.yui-tabs__wrap')
+				const halfWrapWidth = rect ? rect.width / 2 : 0
+				this.placeholderHeight = rect ? rect.height : 0
+
+				//获取标签容器距离视口左侧的left值
+				rect = await this.getRect('.yui-tabs')
+				const parentLeft = rect ? rect.left : 0
+
+				// 计算每个tab的相关参数
+				const isSpy = this.scrollspy //是否滚动导航模式
+				const selectors = this.tabList.reduce((arr, tab, index) => {
+					arr.push('.yui-tab_' + index)
+					if (isSpy) arr.push('.yui-tab_pane' + index)
+					return arr
+				}, [])
+				const rects = await this.getRect(...selectors);
+				this.tabList.forEach((tab) => {
+					const [r1, r2] = rects.splice(0, isSpy ? 2 : 1)
+					tab.translateX = r1 ? r1.left + r1.width / 2 - parentLeft : 0 //标签线条偏移量
+					tab.scrollLeft = tab.translateX - halfWrapWidth //标签相对于屏幕左侧的距离值
+					if (isSpy) tab.paneTop = r2 ? r2.top : 0; //标签内容相对于屏幕顶部的距离值
+				})
+
+				const currIdx = this[valueField]
+				this.setCurrentIndex(currIdx, false) //设置当前选中的下标
+				this.tabChange(currIdx, -1) //标签切换
+				this.changeStyle(); //样式切换
+			},
+			// 标签点击事件
+			onClick(index, isTabClick = false) {
+				this.isTabClick = isTabClick // 是否为标签标题点击
+				this.$emit('click', index, this.tabs[index], this.isTabClick) // 标签点击事件
+				// if (this.tabList[index].disabled) return //禁用时不允许切换
+				callInterceptor({
+					interceptor: this.beforeChange,
+					args: [index],
+					done: () => {
+						// 不允许重复切换同一标签
+						// if (this[valueField] === index) return
+						this.setCurrentIndex(index) //设置当前下标
+						setTimeout(() => {
+							this.scrollToTop() //滚动到顶 
+							this.scrollToCurrContent() //滚动到当前标签内容
+						})
+					},
+				});
+			},
+			// 设置当前下标
+			setCurrentIndex(newIdx, shouldEmitChange = true) {
+				const oldIdx = this[valueField]
+				const shouldEmit = oldIdx !== newIdx;
+				const currTab = this.tabList[newIdx] //当前tab
+				// 非滚动导航模式下,触发rendered事件
+				if (this.isLazyRender && !this.scrollspy && !currTab.rendered) {
+					this.$emit('rendered', newIdx, this.tabs[newIdx])
+				}
+
+				if (shouldEmit) { //不允许重复切换同一标签
+					this.$emit(emits[0], newIdx) // 更新v-model绑定的值
+
+					if (shouldEmitChange) {
+						this.$emit('change', newIdx, this.tabs[newIdx], this.isTabClick)
+					}
+				}
+			},
+			// 滚动到顶
+			scrollToTop() {
+				//标签点击时页面回滚顶部
+				if (!this.scrollspy && this.tabClickScrollTop) {
+					setTimeout(function() {
+						uni.pageScrollTo({
+							scrollTop: 0,
+							duration: 0
+						});
+					}, this.duration * 1000);
+				}
+			},
+			// 滚动到当前标签内容
+			scrollToCurrContent(immediate = false) {
+				if (this.scrollspy) {
+					const duration = immediate ? 0 : this.duration * 1000
+					this.lockedScrollspy = true
+					uni.pageScrollTo({
+						scrollTop: this.tabList[this[valueField]].paneTop,
+						duration,
+					});
+					setTimeout(() => {
+						this.lockedScrollspy = false
+					}, duration * 2)
+				}
+			},
+			// 标签切换
+			tabChange(value, oldValue) {
+				console.log("currIdx:", value);
+				const oldTab = this.tabList[oldValue] || {} //上一个tab
+				const currTab = this.tabList[value] //当前tab
+
+				// 设置选中态
+				oldTab.active = false
+				currTab.active = true
+
+				//非滚动导航模式下
+				if (!this.scrollspy) {
+					currTab.rendered = true //标记当前tab的内容渲染过
+					oldTab.show = false //隐藏旧tab的内容
+					currTab.show = true //显示当前tab的内容
+				}
+			},
+			// 样式切换
+			changeStyle() {
+				this.scrollIntoView() //将活动的tab滚动到视图中
+				this.setLine() //改变标签栏底部线条位置
+				// 标签内容滑动非swiper实现及非滚动导航模式时
+				if (!this.swiper && !this.scrollspy) {
+					this.changeTrackStyle(false, this.duration) //改变标签内容滑动轨道样式
+					this.changePaneStyle() //改变标签内容样式
+				}
+			},
+			// 将活动的tab滚动到视图中
+			scrollIntoView() {
+				// 标签栏允许滚动:设置横向滚动条位置,scrollToCenter为true,当前标签则滚动至中心位置
+				if (!this.scrollX) return
+				if (this.scrollToCenter) this.scrollLeft = this.tabList[this[valueField]].scrollLeft
+				else this.scrollId = `tab_${this[valueField]-1}`;
+			},
+			// 设置标签栏底部线条位置
+			setLine() {
+				if (!this.isLine) return // 仅在 type="line" 时有效
+				const val = this.tabList[this[valueField]].translateX
+				const transform = `translateX(${isDef(val) ? val + "px" : '-100%'}) translateX(-50%)`
+				const duration = `${this.lineAnimated?this.duration:'0'}s`
+				this.$set(this.lineAnimatedStyle, 'transform', transform)
+				this.$set(this.lineAnimatedStyle, 'transitionDuration', duration)
+				this.lineAnimated = true //是否开启标签栏动画
+			},
+			// 改变标签内容滑动轨道样式
+			changeTrackStyle(isSlide = false, duration = 0, offsetWidth = 0) {
+				if (!this.animated) return
+				// isSlide为true,表示左右滑动;false表示点击标签的转场动画
+				this.trackStyle = {
+					'transform': isSlide ? `translate3d(${offsetWidth}px,0,0)` :
+						`translateX(${-100 * this[valueField]}%)`,
+					'transition': `transform ${duration}s ease-in-out`
+				}
+			},
+			// 改变标签内容样式
+			changePaneStyle() {
+				this.getRect('.yui-tab__pane' + this[valueField]).then(rect => {
+					// 有拖动动画时,隐藏的标签内容高度取显示的标签内容高度
+					const height = rect && this.swipeAnimated ? rect.height : 0
+					this.tabList.forEach(tab => {
+						const paneStyle = this.animated ? {
+							// 有拖动动画时或指定标签内容显示时,为visible
+							visibility: this.swipeAnimated || tab.show ? 'visible' : 'hidden',
+							height: tab.show ? 'auto' : height + 'px'
+						} : {
+							display: tab.show ? 'block' : 'none'
+						};
+						this.$set(tab, "paneStyle", paneStyle)
+					})
+				})
+			},
+			// 禁止swiper组件手动滑动
+			stopTouchMove(event) {
+				if (!this.swipeable) event.stopPropagation()
+			},
+			// swiper组件的current改变时会触发 change 事件
+			onSwiperChange(e) {
+				this.setCurrentIndex(e.target.current || e.detail.current) //设置当前下标
+			},
+			touchStart(e) {
+				// 禁止滑动
+				if (!this.swipeable) return
+				this.touchInfo.inited = true //touch开始时,将touchInfo对象设置为已初始化状态
+				const touch = e.touches[0];
+				// 记录touch位置的横坐标与纵坐标
+				this.touchInfo.startX = touch.pageX
+				this.touchInfo.startY = touch.pageY
+				this.touchInfo.moved = false //用来判断是否是一次移动
+			},
+			touchMove(e, index) {
+				if (!this.touchInfo.inited && this.touchInfo.upDown) return
+				const {
+					pageX,
+					pageY
+				} = e.changedTouches[0]
+				const {
+					startX,
+					startY
+				} = this.touchInfo || {}
+
+				// 滑动方向不为左右时阻止
+				const direction = getDirection(startX, startY, pageX, pageY)
+				if (direction != 3 && direction != 4) {
+					e.stopPropagation()
+
+					return
+				}
+
+				// 横坐标偏移量
+				const deltaX = pageX - startX
+
+				// 标记是左滑还是右滑
+				const isLeftSide = deltaX >= 0
+				// 如果当前为第一页内容,则不允许左滑;最后一页内容,则不允许右滑
+				if ((isLeftSide && index == 0) || (!isLeftSide && index == this.dataLen - 1)) {
+					return
+				}
+				this.touchInfo.isLeftSide = isLeftSide
+				this.touchInfo.moved = true
+				this.touchInfo.deltaX = Math.abs(deltaX)
+				// 改变标签内容的样式,模拟拖动动画效果
+				if (this.swipeAnimated) {
+					const offsetWidth = this.contentWidth * this.value * -1 + deltaX
+					this.changeTrackStyle(true, 0, offsetWidth)
+				}
+			},
+			touchEnd(e, index) {
+				if (!this.touchInfo.moved) return
+				const {
+					isLeftSide,
+					deltaX
+				} = this.touchInfo || {}
+				// 移动的横坐标偏移量大于指定的滚动阈值时,则切换显示状态,否则还原
+				if (Math.abs(deltaX) > Number(this.swipeThreshold)) {
+					// 根据是否为左滑查找需要滑动到的标签内容页下标,切换标签内容
+					index = index + (isLeftSide ? -1 : 1)
+					if (index > -1 && index < this.dataLen) this.onClick(index)
+				} else {
+					this.changeTrackStyle(false, this.duration)
+				}
+				// 一次touch完成后,重置touchInfo对象尚未初始化状态
+				this.touchInfo.inited = false
+			},
+		}
+	}
+</script>
+
+<style lang="less" scoped>
+	@import url("css/index.less");
+</style>

+ 587 - 0
uni_modules/yui-tabs/components/yui-tabs/yui-tabs.vue

@@ -0,0 +1,587 @@
+<template>
+	<view class="yui-tabs" :class="[tabsClass]">
+		<!-- 依赖元素,用于处理滚动吸顶所需 -->
+		<view class="depend-wrap"></view>
+		<!-- 标签区域 -->
+		<view class="yui-tabs__wrap" :style="[innerWrapStyle,wrapStyle]">
+			<!-- scrollX为true,表示允许横向滚动 -->
+			<scroll-view class="yui-tabs__scroll" :class="[scrollX?'enable-sroll':'']" :scroll-x="scrollX" :scroll-anchoring="true" enable-flex :scroll-left="scrollLeft"
+				:scroll-into-view="!scrollToCenter?scrollId:''" scroll-with-animation :style="[scrollStyle]">
+				<view class="yui-tabs__nav" :class="[navClass]" :style="[navStyle]">
+					<view class="yui-tab" v-for="(tab,index) in tabList" :key="index" @tap.stop="onClick(index,true)" :id="`tab_${index}`" :class="[tabClass(index, tab)]" :style="[tabStyle(tab)]">
+						<view class="yui-tab__text">
+							<!-- #ifndef VUE3 -->
+							<slot :name="tab.titleSlot">{{tab.label}}</slot>
+							<!-- #endif -->
+							<!-- #ifdef VUE3 -->
+							{{tab.label}}
+							<!-- #endif -->
+							<text :class="[infoClass(tab)]" v-if="tab.badge || tab.dot">{{tab.badge}}</text>
+						</view>
+					</view>
+					<view v-if="isLine" class="yui-tabs__line" :style="[lineStyle,lineAnimatedStyle]"></view>
+				</view>
+			</scroll-view>
+			<!-- 标签栏额外内容 -->
+			<view class="yui-tabs__extra">
+				<slot name="extra"></slot>
+			</view>
+		</view>
+		<view v-if="isFixed" class="yui-tabs__placeholder" :style="[{height:placeholderHeight+'px'}]"></view>
+		<!-- 标签内容:普通实现 -->
+		<view v-if="!noRenderConent && !swiper" class="yui-tabs__content" :class="{'yui-tabs__content--animated':animated,'yui-tabs__content--scrollspy':scrollspy}">
+			<view class="yui-tabs__track" :style="[trackStyle]">
+				<view class="yui-tab__pane" :class="[paneClass(index,tab)]" v-for="(tab,index) in tabList" :key="index" :style="[tab.paneStyle]" @touchstart="touchStart"
+					@touchmove="touchMove($event,index)" @touchend="touchEnd($event,index)">
+					<view v-if="tab.rendered ? true :value == index">
+						<slot :name="tab.slot"></slot>
+					</view>
+				</view>
+			</view>
+		</view>
+
+		<!-- 标签内容:使用swiper组件实现左右滑动 -->
+		<swiper v-if="!noRenderConent && swiper" class="yui-tabs__swiper" :current="current" :duration="swiperDuration" @change="onSwiperChange">
+			<swiper-item class="yui-tabs__swiper--item" v-for="(tab,index) in tabList" :key="index" @touchmove="stopTouchMove">
+				<view class="yui-tabs__swiper--wrap" v-if="tab.rendered ? true :value == index">
+					<slot :name="tab.slot"></slot>
+				</view>
+			</swiper-item>
+		</swiper>
+	</view>
+</template>
+
+<script>
+	import {
+		isNull,
+		addUnit,
+		isDef,
+		isObject,
+		getDirection,
+		callInterceptor,
+	} from "../yui-tabs/utils/uitls"
+	import {
+		emits,
+		props,
+		valueField
+	} from "../yui-tabs/utils/const"
+	import { touchMixin } from "../yui-tabs/utils/touchMixin"
+
+	export default {
+		name: "yui-tabs",
+		mixins: [touchMixin],
+		emits,
+		// shared:表示页面 wxss 样式将影响到自定义组件
+		options: {
+			styleIsolation: 'shared'
+		},
+		props,
+		data() {
+			return {
+				currentIndex: null,
+				tabList: [], //标签页数据
+				scrollId: 'tab_0', //值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
+				scrollLeft: 0, //设置横向滚动条位置
+				extraWidth: 0, //标签栏右侧额外区域宽度
+				contentWidth: 0, //标签内容宽度
+				trackStyle: null, //标签内容滑动轨道样式
+				touchInfo: {
+					inited: false, //标记左右滑动时的初始化状态
+					startX: null, //记录touch位置的横坐标
+					startY: null, //记录touch位置的纵坐标
+					moved: false, //用来判断是否是一次移动
+					deltaX: 0, //记录拖动的横坐标距离
+					isLeftSide: false, //标记是否为左滑
+				},
+				// 标签栏底部线条动画相关
+				lineAnimated: false, //是否开启标签栏底部线条动画(首次不开启)
+				lineAnimatedStyle: {
+					transform: `translateX(-100%) translateX(-50%)`,
+					transitionDuration: `0s`
+				}, //标签栏底部线条动画样式
+				isFixed: true, //是否吸顶
+				current: this.currentIndex, //当前显示的滚动卡片
+				isTabClick: false, //是否为标签标题点击
+				placeholderHeight: 0, //标题栏占位高度
+				windowHeight: 0, //屏幕高度
+				lockedScrollspy: false, //锁定滚动导航模式下点击标题栏触发的滚动逻辑
+			}
+		},
+		computed: {
+			// 样式风格是否为line
+			isLine() {
+				return this.type === "line"
+			},
+			// 标签页容器class
+			tabsClass() {
+				return `yui-tabs--${this.type} ${this.visible?'yui-tabs--visible':''} ${this.fixed || this.isFixed?'yui-tabs--fixed':''} ${this.swiper?'yui-tabs--swiper':''} `
+			},
+			// 标签栏class
+			navClass() {
+				return `yui-tabs__nav--${this.type}`
+			},
+			// 标签栏style
+			navStyle() {
+				const style = {}
+				if (this.type === "card") style.borderColor = this.color
+				return style
+			},
+			// 标签栏包裹层样式
+			innerWrapStyle() {
+				const style = {
+					background: this.background,
+				}
+				// 滚动吸顶下
+				if (this.fixed || this.isFixed) {
+					style.top = this.offsetTop + 22 + "px"
+					style.zIndex = this.zIndex
+				}
+				return style
+			},
+			// 滚动区域样式
+			scrollStyle() {
+				return {
+					width: `calc(100% - ${this.extraWidth}px)`
+				}
+			},
+			// 标签栏底部线条样式
+			lineStyle() {
+				const {
+					lineWidth,
+					lineHeight,
+					duration
+				} = this;
+				const lineStyle = {
+					width: addUnit(lineWidth),
+					backgroundColor: this.color,
+				}
+
+				if (isDef(lineHeight)) {
+					const height = addUnit(lineHeight);
+					lineStyle.height = height;
+					lineStyle.borderRadius = height;
+				}
+				return lineStyle
+			},
+			// 是否允许横向滚动
+			scrollX() {
+				return !this.ellipsis && this.type !== "card" && this.tabs.length > this.scrollThreshold
+			},
+			// 标签数量
+			dataLen() {
+				return this.tabList.length
+			},
+			// swiper组件滑动动画时长
+			swiperDuration() {
+				return this.animated ? this.duration * 1000 : 0
+			},
+			// 粘性布局下的滚动偏移量
+			scrollOffset() {
+				return this.sticky ? this.offsetTop + this.placeholderHeight : 0;
+			},
+		},
+		watch: {
+			// 监听tabs变化,重新初始化tabList
+			tabs: {
+				handler() {
+					this.updateTabList(); //更新tabList
+				},
+				deep: true
+			},
+			currentIndex: {
+				handler(newIdx, oldIdx) {
+					this.current = newIdx
+					this.tabChange(newIdx, oldIdx) //标签切换
+					this.changeStyle() // 样式切换
+				},
+			},
+		},
+		created() {
+			// 监听选中标识符变化
+			this.$watch(valueField, (index) => {
+				if (index !== this.currentIndex) {
+					this.setCurrentIndex(index) //设置当前下标
+					setTimeout(() => {
+						this.scrollToTop() //滚动到顶 
+						this.scrollToCurrContent() //滚动到当前标签内容
+					}, 0)
+				}
+			})
+			this.initTabList() // 初始化tabList
+		},
+		mounted() {
+			this.$nextTick(() => {
+				this.init() //初始化操作
+				this.listenEvent(); //监听事件
+			})
+		},
+		methods: {
+			// @exposed-api
+			resize() {
+				// 外层元素大小或组件显示状态变化时,可以调用此方法来触发重绘
+				this.init()
+			},
+			// 获取查询节点信息的对象
+			getSelectorQuery() {
+				let query = null
+				// #ifdef MP-ALIPAY
+				query = uni.createSelectorQuery()
+				// #endif
+				// #ifndef MP-ALIPAY
+				query = uni.createSelectorQuery().in(this)
+				// #endif
+				return query
+			},
+			// 获取元素位置信息
+			getRect(...selectors) {
+				return new Promise((resolve, reject) => {
+					if (!selectors) reject('Parameter is empty');
+					const query = this.getSelectorQuery()
+					selectors.forEach(seletor => {
+						query.select(seletor).boundingClientRect()
+					})
+					query.exec(data => {
+						data = data || []
+						resolve(data.length === 1 ? data[0] : data)
+					});
+				})
+			},
+			// 标签项class
+			tabClass(index, tab) {
+				return `yui-tab_${index} ${tab.active?'yui-tab--active':''} ${this.ellipsis && !this.scrollX?'yui-tab__ellipsis':''}`
+			},
+			// 标签内容class
+			paneClass(index, tab) {
+				return `yui-tab__pane${index} ${tab.active?'yui-pane--active':''}`
+			},
+			// 标签项style
+			tabStyle(tab) {
+				let activeColor = this.titleActiveColor
+				let inactiveColor = this.titleInactiveColor
+				let background = ""
+				let borderColor = ""
+				// type="line" 时
+				if (this.type === "line") {
+					if (isNull(activeColor)) activeColor = "#646566"
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				} else if (this.type === "text") { // type="text" 时,选中时使用主题色
+					if (isNull(activeColor)) activeColor = this.color
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				} else if (this.type === "card") { // type="card" 时,未选中则使用主题色
+					background = this.color
+					if (isNull(activeColor)) activeColor = "#fff"
+					if (isNull(inactiveColor)) inactiveColor = this.color
+				} else if (this.type === "button") { // type="button" 时
+					background = this.color
+					if (isNull(activeColor)) activeColor = "#fff"
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				} else if (this.type === "line-button") { // type="line-button" 时
+					borderColor = this.color
+					if (isNull(activeColor)) activeColor = this.color
+					if (isNull(inactiveColor)) inactiveColor = "#323233"
+				}
+				return {
+					color: tab.active ? activeColor : inactiveColor,
+					background: tab.active ? background : "",
+					borderColor: tab.active ? borderColor : "",
+				}
+			},
+			// 标题右上角信息class
+			infoClass(tab) {
+				return ` yui-tab__info ${tab.dot?'yui-tab__info--dot':''}`
+			},
+			// 监听事件
+			listenEvent() {
+				const that = this
+				if (that.sticky || that.scrollspy) {
+					uni.$on('onPageScroll', function(e) {
+						const {
+							stickyThreshold,
+							offsetTop,
+							scrollspy,
+							lockedScrollspy,
+						} = that
+						// 粘性定位布局的吸顶处理
+						that.getRect('.depend-wrap').then((rect) => {
+							that.isFixed = rect.bottom - stickyThreshold <= offsetTop
+							
+							// 	滚动时触发,仅在 sticky 模式下生效,{ scrollTop: 距离顶部位置, isFixed: 是否吸顶 }
+							that.$emit("scroll", {
+								scrollTop: e.scrollTop,
+								isFixed: that.isFixed
+							})
+						})
+
+						// 滚动导航模式下对选中标签的处理
+						if (scrollspy && !lockedScrollspy) {
+							that.getCurrIndexOnScroll().then(index => {
+								that.setCurrentIndex(index) //设置当前下标
+							})
+						}
+					})
+				}
+			},
+			// 滚动时获取要选中的下标
+			getCurrIndexOnScroll(res = []) {
+				return new Promise((resolve, rejct) => {
+					const selectors = this.tabList.map((o, i) => '.yui-tab__pane' + i)
+					this.getRect(...selectors).then(res => {
+						// 标签内容的top小于标题栏的top,则说明已经与标题栏部分重合
+						let index = res.reduce((idx, o, i) => o.top < this.scrollOffset ? i : idx, 0)
+						// 判断最后一个标签内容是否完整显示在底部,是则默认选中
+						// const lastRect = res[res.length - 1] //最后一个标签内容
+						// if (lastRect.bottom <= this.windowHeight) index = res.length - 1
+						resolve(index)
+					})
+				})
+			},
+			// 初始化tabList
+			initTabList() {
+				const tabs = this.tabs.filter(o => !isNull(o))
+				this.tabList = tabs.map((item, index) => {
+					const isCurr = this.currentIndex == index
+					const tab = {
+						label: '', //标签名称
+						slot: 'pane' + index, //标签内容的插槽名称,默认以"pane"+标签下标命名
+						titleSlot: 'title' + index, //标签标题的插槽名称,默认以"title"+标签下标命名
+						disabled: false, //是否禁用标签
+						active: false, //是否选中
+						rendered: !this.isLazyRender || this.scrollspy, //标记是否渲染过,非懒加载与滚动导航模式下默认渲染
+						show: this.scrollspy, // 是否显示内容(滚动导航模式默认显示)
+						dot: false, //是否在标题右上角显示小红点
+						translateX: 0, //底部线条translateX值,
+						scrollLeft: 0, //当前标签对应的横向滚动条位置
+						paneTop: 0, //滚动导航模式下标签内容距离屏幕顶部的距离
+					}
+
+					tab.paneStyle = this.animated ? {
+						visibility: tab.show ? 'visible' : 'visible',
+						height: tab.show ? 'auto' : '0px'
+					} : {
+						display: tab.show ? 'block' : 'none'
+					};
+					// 读取标签对象值
+					if (isObject(item)) {
+						tab.label = item.label
+						tab.slot = isNull(item.slot) ? tab.slot : item.slot
+						tab.titleSlot = isNull(item.titleSlot) ? tab.titleSlot : item.titleSlot
+						tab.dot = isNull(item.dot) ? tab.dot : item.dot
+						tab.badge = !isNull(item.badge) && !tab.dot ? item.badge : ""
+					} else {
+						tab.label = item
+					}
+					return tab
+				})
+
+			},
+			// 更新tabList
+			updateTabList() {
+				// 如果长度有变化,表示tabs有删除或新增,重新init一次
+				if (this.tabs.length != this.dataLen) {
+					this.initTabList() //初始化tabList
+				} else {
+					// 否则仅仅更改label,badge,dot值
+					this.tabs.forEach((item, i) => {
+						const tab = this.tabList[i]
+						// 读取标签对象值
+						if (isObject(item)) {
+							this.$set(tab, "label", item.label)
+							this.$set(tab, "dot", isNull(item.dot) ? tab.dot : item.dot)
+							this.$set(tab, "badge", !isNull(item.badge) && !tab.dot ? item.badge : "")
+						} else {
+							this.$set(tab, "label", item)
+						}
+					})
+				}
+
+				this.$nextTick(() => {
+					this.init() //初始化操作
+				})
+			},
+			// 初始化操作
+			async init() {
+				//屏幕高度
+				this.windowHeight = uni.getSystemInfoSync().windowHeight;
+
+				//获取额外区域的宽度
+				let rect = await this.getRect('.yui-tabs__extra')
+				this.extraWidth = rect ? rect.width : 0
+
+				//获取标签内容区域的宽度
+				rect = await this.getRect('.yui-tabs__content')
+				this.contentWidth = rect ? rect.width : 0
+
+				// 获取标题栏包裹层的rect
+				rect = await this.getRect('.yui-tabs__wrap')
+				const halfWrapWidth = rect ? rect.width / 2 : 0
+				this.placeholderHeight = rect ? rect.height : 0
+
+
+				//获取标签容器距离视口左侧的left值
+				rect = await this.getRect('.yui-tabs')
+				const parentLeft = rect ? rect.left : 0
+
+				// 计算每个tab的相关参数
+				const isSpy = this.scrollspy //是否滚动导航模式
+				const selectors = this.tabList.reduce((arr, tab, index) => {
+					arr.push('.yui-tab_' + index)
+					if(isSpy) arr.push('.yui-tab__pane' + index)
+					return arr
+				}, [])
+				const rects = await this.getRect(...selectors);
+				this.tabList.forEach((tab) => {
+					const [r1, r2] = rects.splice(0, isSpy ? 2 : 1)
+					tab.translateX = r1 ? r1.left + r1.width / 2 - parentLeft : 0 //标签线条偏移量
+					tab.scrollLeft = tab.translateX - halfWrapWidth //标签相对于屏幕左侧的距离值
+					if (isSpy) tab.paneTop = r2 ? r2.top : 0; //标签内容相对于屏幕顶部的距离值
+				})
+				this.setCurrentIndex(this[valueField]) //设置当前下标
+			},
+			// 标签点击事件
+			onClick(index, isTabClick = false) {
+				this.isTabClick = isTabClick // 是否为标签标题点击
+				if (isTabClick) this.$emit('click', index, this.tabs[index], this.isTabClick) // 标签点击事件
+				callInterceptor({
+					interceptor: this.beforeChange,
+					args: [index],
+					done: () => {
+						this.setCurrentIndex(index) //设置当前下标
+						setTimeout(() => {
+							this.scrollToTop() //滚动到顶 
+							this.scrollToCurrContent() //滚动到当前标签内容
+						}, 0)
+					},
+				});
+			},
+			// 设置当前下标
+			setCurrentIndex(newIdx) {
+				const shouldEmit = this.currentIndex !== newIdx
+				const shouldEmitChange = this.currentIndex !== null
+				const currTab = this.tabList[newIdx] //当前tab
+				// 非滚动导航模式下,触发rendered事件
+				if (this.isLazyRender && !this.scrollspy && !currTab.rendered) {
+					this.$emit('rendered', newIdx, this.tabs[newIdx])
+				}
+
+				this.currentIndex = newIdx
+				if (shouldEmit) { //禁止重复切换
+					this.$emit(emits[0], newIdx) // 更新v-model绑定的值
+
+					if (shouldEmitChange) {
+						this.$emit('change', newIdx, this.tabs[newIdx], this.isTabClick)
+					}
+				}
+			},
+			// 滚动到顶
+			scrollToTop() {
+				//标签点击时页面回滚顶部
+				if (!this.scrollspy && this.tabClickScrollTop) {
+					setTimeout(function() {
+						uni.pageScrollTo({
+							scrollTop: 0,
+							duration: 0
+						});
+					}, this.duration * 1000);
+				}
+			},
+			// 滚动到当前标签内容
+			scrollToCurrContent(immediate = false) {
+				if (this.scrollspy) {
+					const duration = immediate ? 0 : this.duration * 1000
+					this.lockedScrollspy = true
+					uni.pageScrollTo({
+						scrollTop: this.tabList[this.currentIndex].paneTop,
+						duration,
+					});
+					setTimeout(() => {
+						this.lockedScrollspy = false
+					}, duration * 2)
+				}
+			},
+			// 标签切换
+			tabChange(newIdx, oldIdx) {
+				const oldTab = this.tabList[oldIdx] || {} //上一个tab
+				const currTab = this.tabList[newIdx] || {} //当前tab
+				// 设置选中态
+				oldTab.active = false
+				currTab.active = true
+
+				//非滚动导航模式下
+				if (!this.scrollspy) {
+					currTab.rendered = true //标记当前tab的内容渲染过
+					oldTab.show = false //隐藏旧tab的内容
+					currTab.show = true //显示当前tab的内容
+				}
+			},
+			// 样式切换
+			changeStyle() {
+				this.scrollIntoView() //将活动的tab滚动到视图中
+				this.setLine() //改变标签栏底部线条位置
+				// 标签内容滑动非swiper实现及非滚动导航模式时
+				if (!this.swiper && !this.scrollspy) {
+					this.changeTrackStyle(false, this.duration) //改变标签内容滑动轨道样式
+					this.changePaneStyle() //改变标签内容样式
+				}
+			},
+			// 将活动的tab滚动到视图中
+			scrollIntoView() {
+				// 标签栏允许滚动:设置横向滚动条位置,scrollToCenter为true,当前标签则滚动至中心位置
+				if (this.scrollX) {
+					if (this.scrollToCenter) this.scrollLeft = this.tabList[this.currentIndex].scrollLeft
+					else this.scrollId = `tab_${this.currentIndex-1}`;
+				}
+			},
+			// 设置标签栏底部线条位置
+			setLine() {
+				if (this.isLine) { // 仅在 type="line" 时有效
+					console.log("currentIndex:", this.currentIndex);
+					const val = this.tabList[this.currentIndex].translateX
+					const transform = `translateX(${isDef(val) ? val + "px" : '-100%'}) translateX(-50%)`
+					const duration = `${this.lineAnimated?this.duration:'0'}s`
+					this.$set(this.lineAnimatedStyle, 'transform', transform)
+					this.$set(this.lineAnimatedStyle, 'transitionDuration', duration)
+					this.lineAnimated = true //是否开启标签栏动画
+				}
+			},
+			// 改变标签内容滑动轨道样式
+			changeTrackStyle(isSlide = false, duration = 0, offsetWidth = 0) {
+				if (this.animated) {
+					// isSlide为true,表示左右滑动;false表示点击标签的转场动画
+					this.trackStyle = {
+						'transform': isSlide ? `translate3d(${offsetWidth}px,0,0)` : `translateX(${-100 * this.currentIndex}%)`,
+						'transition': `transform ${duration}s ease-in-out`
+					}
+				}
+			},
+			// 改变标签内容样式
+			changePaneStyle() {
+				this.getRect('.yui-tab__pane' + this.currentIndex).then(rect => {
+					// 有拖动动画时,隐藏的标签内容高度取显示的标签内容高度
+					const height = rect && this.swipeAnimated ? rect.height : 0
+					this.tabList.forEach(tab => {
+						const paneStyle = this.animated ? {
+							// 有拖动动画时或指定标签内容显示时,为visible
+							visibility: this.swipeAnimated || tab.show ? 'visible' : 'hidden',
+							height: tab.show ? 'auto' : height + 'px'
+						} : {
+							display: tab.show ? 'block' : 'none'
+						};
+						this.$set(tab, "paneStyle", paneStyle)
+					})
+				})
+			},
+			// 禁止swiper组件手动滑动
+			stopTouchMove(event) {
+				if (!this.swipeable) event.stopPropagation()
+			},
+			// swiper组件的current改变时会触发 change 事件
+			onSwiperChange(e) {
+				this.setCurrentIndex(e.target.current || e.detail.current) //设置当前下标
+			},
+		}
+	}
+</script>
+
+<style lang="less" scoped>
+	@import url("css/index.less");
+</style>

+ 84 - 0
uni_modules/yui-tabs/package.json

@@ -0,0 +1,84 @@
+{
+	"id": "yui-tabs",
+	"displayName": "Tabs标签页 灵活配置 简单易用(仿vant的tabs)",
+	"version": "1.1.0",
+	"description": "tab标签页,支持标签栏滚动、滑动切换、滚动吸顶、标题超出隐藏、标题徽标",
+	"keywords": [
+        "tabs标签页",
+        "tab切换",
+        "左右滑动",
+        "标签栏滚动"
+    ],
+	"repository": "https://github.com/chenduyy/yui-tabs-example.git",
+	"engines": {
+		"HBuilderX": "^3.5.1"
+	},
+    "dcloudext": {
+        "sale": {
+			"regular": {
+				"price": "0.00"
+			},
+			"sourcecode": {
+				"price": "0.00"
+			}
+		},
+		"contact": {
+			"qq": "1431948195"
+		},
+		"declaration": {
+			"ads": "无",
+			"data": "无",
+			"permissions": "无"
+		},
+        "npmurl": "",
+        "type": "component-vue"
+	},
+	"uni_modules": {
+		"dependencies": [],
+		"encrypt": [],
+		"platforms": {
+			"cloud": {
+				"tcb": "y",
+				"aliyun": "y"
+			},
+			"client": {
+				"Vue": {
+					"vue2": "y",
+					"vue3": "y"
+				},
+				"App": {
+					"app-vue": "y",
+					"app-nvue": "u"
+				},
+				"H5-mobile": {
+					"Safari": "u",
+					"Android Browser": "y",
+					"微信浏览器(Android)": "y",
+					"QQ浏览器(Android)": "y"
+				},
+				"H5-pc": {
+					"Chrome": "y",
+					"IE": "u",
+					"Edge": "y",
+					"Firefox": "y",
+					"Safari": "y"
+				},
+				"小程序": {
+					"微信": "y",
+					"阿里": "y",
+					"百度": "y",
+					"字节跳动": "y",
+					"QQ": "y",
+					"钉钉": "y",
+					"快手": "u",
+					"飞书": "u",
+                    "京东": "u"
+				},
+				"快应用": {
+					"华为": "u",
+					"联盟": "u"
+				}
+			}
+		}
+	}
+}