版本发布

This commit is contained in:
icssoa
2025-07-21 16:47:04 +08:00
parent 1abed7a2e1
commit 6d8193880a
307 changed files with 41718 additions and 0 deletions

View File

@@ -0,0 +1,485 @@
<template>
<view
class="cl-tabs"
:class="[
{
'cl-tabs--line': showLine,
'cl-tabs--slider': showSlider,
'cl-tabs--fill': fill,
'cl-tabs--disabled': disabled,
'is-dark': isDark
},
pt.className
]"
:style="{
height: parseRpx(height!)
}"
>
<scroll-view
class="cl-tabs__scrollbar"
:scroll-with-animation="true"
:scroll-x="true"
direction="horizontal"
:scroll-left="scrollLeft"
:show-scrollbar="false"
>
<view class="cl-tabs__inner">
<view
class="cl-tabs__item"
v-for="(item, index) in list"
:key="index"
:class="[pt.item?.className]"
:style="{
padding: `0 ${parseRpx(gutter)}`
}"
@tap="change(index)"
>
<slot name="item" :item="item" :active="item.isActive">
<text
class="cl-tabs__item-label"
:class="[
{
'is-active': item.isActive,
'is-disabled': item.disabled,
'is-dark': isDark,
'is-fill': fill
},
pt.text?.className
]"
:style="getTextStyle(item)"
>{{ item.label }}</text
>
</slot>
</view>
<template v-if="lineLeft > 0">
<view
class="cl-tabs__line"
:class="[pt.line?.className]"
:style="lineStyle"
v-if="showLine"
></view>
<view
class="cl-tabs__slider"
:class="[pt.slider?.className]"
:style="sliderStyle"
v-if="showSlider"
></view>
</template>
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { type PropType, computed, getCurrentInstance, nextTick, onMounted, ref, watch } from "vue";
import { isDark, isEmpty, isHarmony, isNull, parsePt, parseRpx, rpx2px } from "@/cool";
import type { ClTabsItem, PassThroughProps } from "../../types";
// 定义标签类型
type Item = {
label: string;
value: string | number;
disabled: boolean;
isActive: boolean;
};
defineOptions({
name: "cl-tabs"
});
defineSlots<{
item(props: { item: Item; active: boolean }): any;
}>();
const props = defineProps({
// 透传属性对象,允许外部自定义样式和属性
pt: {
type: Object,
default: () => ({})
},
// v-model绑定值表示当前选中的tab
modelValue: {
type: [String, Number] as PropType<string | number>,
default: ""
},
// 标签高度
height: {
type: [String, Number] as PropType<string | number>,
default: 80
},
// 标签列表
list: {
type: Array as PropType<ClTabsItem[]>,
default: () => []
},
// 是否填充标签
fill: {
type: Boolean,
default: false
},
// 标签间隔
gutter: {
type: Number,
default: 30
},
// 选中标签的颜色
color: {
type: String,
default: ""
},
// 未选中标签的颜色
unColor: {
type: String,
default: ""
},
// 是否显示下划线
showLine: {
type: Boolean,
default: true
},
// 是否显示滑块
showSlider: {
type: Boolean,
default: false
},
// 是否禁用
disabled: {
type: Boolean,
default: false
}
});
// 定义事件发射器
const emit = defineEmits(["update:modelValue", "change"]);
// 获取当前组件实例的proxy对象
const { proxy } = getCurrentInstance()!;
// 定义透传类型,便于类型推断和扩展
type PassThrough = {
// 额外类名
className?: string;
// 文本的透传属性
text?: PassThroughProps;
// 单个item的透传属性
item?: PassThroughProps;
// 下划线的透传属性
line?: PassThroughProps;
// 滑块的透传属性
slider?: PassThroughProps;
};
// 计算透传属性,便于样式和属性扩展
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 当前选中的标签值
const active = ref(props.modelValue);
// 计算标签列表增加isActive和disabled属性便于渲染和状态判断
const list = computed(() =>
props.list.map((e) => {
return {
label: e.label,
value: e.value,
// 如果未传disabled则默认为false
disabled: e.disabled ?? false,
// 判断当前标签是否为激活状态
isActive: e.value == active.value
} as Item;
})
);
// 切换标签时触发,参数为索引
async function change(index: number) {
// 如果整个Tabs被禁用则不响应点击
if (props.disabled) {
return false;
}
// 获取当前点击标签的值
const { value, disabled } = list.value[index];
// 如果标签被禁用,则不响应点击
if (disabled) {
return false;
}
// 触发v-model的更新
emit("update:modelValue", value);
// 触发change事件
emit("change", value);
}
// 获取当前选中标签的下标未找到则返回0
function getIndex() {
const index = list.value.findIndex((e) => e.isActive);
return index == -1 ? 0 : index;
}
// 根据激活状态获取标签颜色
function getColor(isActive: boolean) {
let color: string;
// 选中时取props.color否则取props.unColor
if (isActive) {
color = props.color;
} else {
color = props.unColor;
}
return isEmpty(color) ? null : color;
}
// tab区域宽度
const tabWidth = ref(0);
// tab区域左侧偏移
const tabLeft = ref(0);
// 下划线左侧偏移
const lineLeft = ref(0);
// 滑块左侧偏移
const sliderLeft = ref(0);
// 滑块宽度
const sliderWidth = ref(0);
// 滚动条左侧偏移
const scrollLeft = ref(0);
// 单个标签的位置信息类型包含left和width
type ItemRect = {
left: number;
width: number;
};
// 所有标签的位置信息,响应式数组
const itemRects = ref<ItemRect[]>([]);
// 计算下划线样式
const lineStyle = computed(() => {
const style = new Map<string, string>();
style.set("transform", `translateX(${lineLeft.value}px)`);
// 获取选中颜色
const bgColor = getColor(true);
if (bgColor != null) {
style.set("backgroundColor", bgColor);
}
return style;
});
// 计算滑块样式
const sliderStyle = computed(() => {
const style = new Map<string, string>();
style.set("transform", `translateX(${sliderLeft.value}px)`);
style.set("width", sliderWidth.value + "px");
// 获取选中颜色
const bgColor = getColor(true);
if (bgColor != null) {
style.set("backgroundColor", bgColor);
}
return style;
});
// 获取文本样式
function getTextStyle(item: Item) {
const style = new Map<string, string>();
// 获取选中颜色
const color = getColor(item.isActive);
if (color != null) {
style.set("color", color);
}
return style;
}
// 更新下划线、滑块、滚动条等位置
function updatePosition() {
nextTick(() => {
if (!isEmpty(itemRects.value)) {
// 获取当前选中标签的位置信息
const item = itemRects.value[getIndex()];
// 如果标签存在
if (!isNull(item)) {
// 计算滚动条偏移,使选中项居中
let x = item.left - (tabWidth.value - item.width) / 2 - tabLeft.value;
// 防止滚动条偏移为负
if (x < 0) {
x = 0;
}
// 设置滚动条偏移
scrollLeft.value = x;
// 设置下划线偏移,使下划线居中于选中项
lineLeft.value = item.left + item.width / 2 - rpx2px(16) - tabLeft.value;
// 设置滑块左侧偏移
sliderLeft.value = item.left - tabLeft.value;
// 设置滑块宽度
sliderWidth.value = item.width;
}
}
});
}
// 获取所有标签的位置信息,便于后续计算
function getRects() {
// 创建选择器查询
uni.createSelectorQuery()
// 作用域限定为当前组件
.in(proxy)
// 选择所有标签元素
.selectAll(".cl-tabs__item")
// 获取rect和size信息
.fields({ rect: true, size: true }, () => {})
// 执行查询
.exec((nodes) => {
// 解析查询结果生成ItemRect数组
itemRects.value = (nodes[0] as NodeInfo[]).map((e) => {
return {
left: e.left ?? 0,
width: e.width ?? 0
} as ItemRect;
});
// 更新下划线、滑块等位置
updatePosition();
});
}
// 刷新tab区域的宽度和位置信息
function refresh() {
setTimeout(
() => {
// 创建选择器查询
uni.createSelectorQuery()
// 作用域限定为当前组件
.in(proxy)
// 选择tab容器
.select(".cl-tabs")
// 获取容器的left和width
.boundingClientRect((node) => {
// 设置tab左侧偏移
tabLeft.value = (node as NodeInfo).left ?? 0;
// 设置tab宽度
tabWidth.value = (node as NodeInfo).width ?? 0;
// 获取所有标签的位置信息
getRects();
})
.exec();
},
isHarmony() ? 50 : 0
);
}
// 监听modelValue变化更新active和位置
watch(
computed(() => props.modelValue!),
(val: string | number) => {
// 更新当前选中标签
active.value = val;
// 更新下划线、滑块等位置
updatePosition();
},
{
// 立即执行一次
immediate: true
}
);
// 监听标签列表变化,刷新布局
watch(
computed(() => props.list),
() => {
nextTick(() => {
refresh();
});
}
);
// 组件挂载时刷新布局,确保初始渲染正确
onMounted(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.cl-tabs {
&__scrollbar {
@apply flex flex-row w-full h-full;
}
&__inner {
@apply flex flex-row relative;
}
&__item {
@apply flex flex-row items-center justify-center h-full relative z-10;
&-label {
@apply text-surface-700 text-md;
&.is-dark {
@apply text-white;
}
&.is-active {
@apply text-primary-500;
}
&.is-disabled {
@apply text-surface-400;
}
}
}
&__line {
@apply bg-primary-500 rounded-md absolute;
height: 4rpx;
width: 16px;
bottom: 2rpx;
left: 0;
transition-property: transform;
transition-duration: 0.3s;
}
&__slider {
@apply bg-primary-500 rounded-lg absolute h-full w-full;
top: 0;
left: 0;
transition-property: transform;
transition-duration: 0.3s;
}
&--slider {
@apply bg-surface-50 rounded-lg;
.cl-tabs__item-label {
&.is-active {
@apply text-white;
}
}
&.is-dark {
@apply bg-surface-700;
}
}
&--fill {
.cl-tabs__inner {
@apply w-full;
}
.cl-tabs__item {
flex: 1;
}
.cl-tabs__item-label {
@apply text-center;
}
}
&--disabled {
@apply opacity-50;
}
}
</style>