323 lines
7.8 KiB
Plaintext
323 lines
7.8 KiB
Plaintext
<template>
|
||
<view id="pane-tabs-component" class="pane-tabs h-full flex flex-col">
|
||
<!-- Tab 导航栏 -->
|
||
<view
|
||
id="tabs-header"
|
||
class="tabs-header"
|
||
:style="{ height: headerHeight }"
|
||
>
|
||
<view class="tabs-nav">
|
||
<view
|
||
v-for="(tab, index) in tabs"
|
||
:key="index"
|
||
:class="['tab-item', { active: modelValue === tab.key }]"
|
||
@click="handleTabClick(tab.key)"
|
||
>
|
||
<text class="tab-label">{{ translateText(tab.label) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 激活指示器 -->
|
||
<view
|
||
id="tab-indicator"
|
||
class="tab-indicator"
|
||
:style="{
|
||
width: indicatorWidth + 'px',
|
||
left: indicatorLeft + 'px',
|
||
}"
|
||
/>
|
||
</view>
|
||
|
||
<!-- Tab 内容区域 -->
|
||
<view
|
||
class="tabs-content flex-1"
|
||
:style="{ paddingBottom: withBottomPadding ? '90rpx' : '0' }"
|
||
@touchstart="handleTouchStart"
|
||
@touchmove="handleTouchMove"
|
||
@touchend="handleTouchEnd"
|
||
>
|
||
<view
|
||
class="tabs-content-wrapper"
|
||
:style="{
|
||
width: `${tabs.length * 100}%`,
|
||
transform: `translateX(-${(getCurrentIndex() * 100) / tabs.length}%)`,
|
||
transition: isSwiping
|
||
? 'none'
|
||
: animated
|
||
? 'transform 0.3s ease'
|
||
: 'none',
|
||
}"
|
||
>
|
||
<!-- 渲染子组件 -->
|
||
<slot />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { translateText } from "@/utils/i18n";
|
||
// Props定义
|
||
interface TabItem {
|
||
key: string;
|
||
label: string;
|
||
forceRender?: boolean;
|
||
}
|
||
|
||
interface Props {
|
||
modelValue?: string;
|
||
headerHeight?: string;
|
||
animated?: boolean;
|
||
lazyLoad?: boolean;
|
||
withBottomPadding?: boolean; // 是否添加底部内边距,默认90rpx
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
modelValue: "",
|
||
headerHeight: "88rpx",
|
||
animated: true,
|
||
lazyLoad: true,
|
||
withBottomPadding: false,
|
||
});
|
||
|
||
// Emits定义
|
||
const emit = defineEmits<{
|
||
(e: "update:modelValue", value: string): void;
|
||
(e: "change", key: string): void;
|
||
}>();
|
||
|
||
// 存储所有 tab 项
|
||
const tabs = ref<TabItem[]>([]);
|
||
|
||
// 注册 tab 的函数
|
||
const registerTab = (tab: TabItem) => {
|
||
tabs.value.push(tab);
|
||
};
|
||
|
||
// 注销 tab 的函数
|
||
const unregisterTab = (key: string) => {
|
||
const index = tabs.value.findIndex((t) => t.key === key);
|
||
if (index > -1) {
|
||
tabs.value.splice(index, 1);
|
||
}
|
||
};
|
||
|
||
// 通过 provide 提供注册函数给子组件
|
||
provide("paneTabs", {
|
||
registerTab,
|
||
unregisterTab,
|
||
activeKey: computed(() => props.modelValue),
|
||
lazyLoad: computed(() => props.lazyLoad),
|
||
});
|
||
|
||
// 当前激活的 tab 索引
|
||
const currentIndex = ref<number>(0);
|
||
// 指示器宽度
|
||
const indicatorWidth = ref<number>(0);
|
||
// 指示器位置
|
||
const indicatorLeft = ref<number>(0);
|
||
|
||
// 根据 key 获取索引
|
||
const getIndexByKey = (key: string): number => {
|
||
return tabs.value.findIndex((tab) => tab.key === key);
|
||
};
|
||
|
||
// 获取当前索引
|
||
const getCurrentIndex = (): number => {
|
||
return getIndexByKey(props.modelValue);
|
||
};
|
||
|
||
// 监听 modelValue 变化
|
||
watch(
|
||
() => props.modelValue,
|
||
(newVal) => {
|
||
const newIndex = getIndexByKey(newVal);
|
||
if (newIndex !== -1 && newIndex !== currentIndex.value) {
|
||
currentIndex.value = newIndex;
|
||
updateIndicator(newIndex);
|
||
}
|
||
},
|
||
);
|
||
|
||
// 监听 tabs 变化,初始化第一个 tab
|
||
watch(
|
||
tabs,
|
||
(newTabs) => {
|
||
if (newTabs.length > 0 && !props.modelValue) {
|
||
emit("update:modelValue", newTabs[0].key);
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
// 点击 tab
|
||
const handleTabClick = (key: string) => {
|
||
if (props.modelValue === key) return;
|
||
|
||
const index = getIndexByKey(key);
|
||
if (index === -1) return;
|
||
|
||
currentIndex.value = index;
|
||
emit("update:modelValue", key);
|
||
emit("change", key);
|
||
|
||
updateIndicator(index);
|
||
};
|
||
|
||
// 更新指示器位置
|
||
const updateIndicator = async (index: number) => {
|
||
await nextTick();
|
||
|
||
// 使用计算方式而非 DOM 查询(解决微信小程序组件内无法查询元素的问题)
|
||
const systemInfo = uni.getSystemInfoSync();
|
||
const screenWidth = systemInfo.windowWidth || 375;
|
||
|
||
// 每个 tab 的宽度 = 屏幕宽度 / tab 数量
|
||
const tabWidth = screenWidth / tabs.value.length;
|
||
|
||
// 指示器宽度设为 tab 宽度
|
||
indicatorWidth.value = tabWidth;
|
||
|
||
// 指示器位置 = index * tab 宽度
|
||
indicatorLeft.value = index * tabWidth;
|
||
|
||
};
|
||
|
||
// 滑动相关状态
|
||
const touchStartX = ref<number>(0);
|
||
const touchStartY = ref<number>(0);
|
||
const isSwiping = ref<boolean>(false);
|
||
const swipeThreshold = 50; // 最小滑动距离阈值(单位:px)
|
||
|
||
// 触摸开始
|
||
const handleTouchStart = (e: any) => {
|
||
if (e.touches && e.touches.length > 0) {
|
||
touchStartX.value = e.touches[0].pageX;
|
||
touchStartY.value = e.touches[0].pageY;
|
||
isSwiping.value = false;
|
||
}
|
||
};
|
||
|
||
// 触摸移动
|
||
const handleTouchMove = (e: any) => {
|
||
if (!e.touches || e.touches.length === 0) return;
|
||
|
||
const touchCurrentX = e.touches[0].pageX;
|
||
const touchCurrentY = e.touches[0].pageY;
|
||
const diffX = Math.abs(touchCurrentX - touchStartX.value);
|
||
const diffY = Math.abs(touchCurrentY - touchStartY.value);
|
||
|
||
// 只有横向滑动距离大于纵向滑动距离时,才认为是在切换标签
|
||
if (diffX > diffY && diffX > 10) {
|
||
isSwiping.value = true;
|
||
}
|
||
};
|
||
|
||
// 触摸结束
|
||
const handleTouchEnd = (e: any) => {
|
||
if (!isSwiping.value) return;
|
||
|
||
const touchEndX = e.changedTouches[0].pageX;
|
||
const diffX = touchEndX - touchStartX.value;
|
||
|
||
// 判断滑动方向和距离
|
||
if (Math.abs(diffX) > swipeThreshold) {
|
||
const currentIdx = getCurrentIndex();
|
||
|
||
if (diffX > 0) {
|
||
// 向右滑动,切换到前一个标签
|
||
if (currentIdx > 0) {
|
||
const prevTab = tabs.value[currentIdx - 1];
|
||
handleTabClick(prevTab.key);
|
||
}
|
||
} else {
|
||
// 向左滑动,切换到下一个标签
|
||
if (currentIdx < tabs.value.length - 1) {
|
||
const nextTab = tabs.value[currentIdx + 1];
|
||
handleTabClick(nextTab.key);
|
||
}
|
||
}
|
||
}
|
||
|
||
isSwiping.value = false;
|
||
};
|
||
|
||
// 组件挂载后初始化指示器
|
||
onMounted(() => {
|
||
setTimeout(() => {
|
||
const index = getIndexByKey(props.modelValue);
|
||
if (index !== -1) {
|
||
currentIndex.value = index;
|
||
updateIndicator(index);
|
||
}
|
||
}, 100);
|
||
});
|
||
|
||
// 暴露方法
|
||
defineExpose({
|
||
updateIndicator,
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.pane-tabs {
|
||
width: 100%;
|
||
background: #fff;
|
||
|
||
.tabs-header {
|
||
position: relative;
|
||
background: #fff;
|
||
border-bottom: 2rpx solid #f0f0f0;
|
||
|
||
.tabs-nav {
|
||
display: flex;
|
||
flex-direction: row;
|
||
height: 100%;
|
||
|
||
.tab-item {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
|
||
.tab-label {
|
||
font-size: 28rpx;
|
||
color: #666;
|
||
font-weight: 600;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
&.active {
|
||
.tab-label {
|
||
color: #5147ff;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.tab-indicator {
|
||
position: absolute;
|
||
bottom: 0;
|
||
height: 3rpx;
|
||
background: linear-gradient(90deg, #5147ff 0%, #5147ff 100%);
|
||
border-radius: 3rpx;
|
||
transition: all 0.3s ease;
|
||
}
|
||
}
|
||
|
||
.tabs-content {
|
||
position: relative;
|
||
overflow: hidden;
|
||
width: 100%;
|
||
|
||
.tabs-content-wrapper {
|
||
display: flex;
|
||
flex-direction: row;
|
||
height: 100%;
|
||
}
|
||
}
|
||
}
|
||
</style>
|