feat: 调整项目结构

This commit is contained in:
duanshuwen
2025-09-21 17:25:09 +08:00
parent 0b66462d16
commit 9f23854ad5
410 changed files with 3806 additions and 1668 deletions

View File

@@ -0,0 +1,259 @@
# Tab 切换组件
一个功能完整的 Tab 切换组件,支持动画过渡、自定义内容和响应式设计。
## 功能特性
-**多标签切换**:支持任意数量的标签页切换
-**动画指示器**:选中状态下划线,支持平滑滑动动画
-**自定义内容**:支持插槽自定义标签内容
-**响应式设计**:适配不同屏幕尺寸
-**动态宽度**:下划线宽度根据文字宽度动态调整
-**事件支持**:完整的切换事件和双向绑定
-**主题定制**:支持自定义指示器颜色
-**uniapp 兼容**:使用 uniapp 内置组件开发
## 基础用法
```vue
<template>
<Tab
:tabs="tabList"
:defaultActive="0"
@change="handleTabChange"
/>
</template>
<script setup>
import Tab from './components/Tab/index.vue'
const tabList = ref([
{ label: '全部订单', value: 'all' },
{ label: '服务工单', value: 'service' }
])
const handleTabChange = ({ index, item }) => {
console.log('切换到:', item.label)
}
</script>
```
## 自定义标签内容
使用 `tab-item` 插槽可以完全自定义标签的显示内容:
```vue
<template>
<Tab :tabs="customTabs" @change="handleChange">
<template #tab-item="{ item, index, isActive }">
<view class="custom-tab">
<image v-if="item.icon" :src="item.icon" class="tab-icon" />
<text :class="{ 'active-text': isActive }">{{ item.label }}</text>
<view v-if="item.badge" class="badge">{{ item.badge }}</view>
</view>
</template>
</Tab>
</template>
<script setup>
const customTabs = ref([
{
label: '待处理',
value: 'pending',
icon: '/static/pending.png',
badge: '3'
},
{
label: '已完成',
value: 'completed',
icon: '/static/completed.png'
}
])
</script>
```
## 双向绑定
支持 `v-model` 双向绑定当前选中的索引:
```vue
<template>
<Tab v-model="activeIndex" :tabs="tabList" />
<text>当前选中索引{{ activeIndex }}</text>
</template>
<script setup>
const activeIndex = ref(0)
const tabList = ref([
{ label: '标签1', value: 'tab1' },
{ label: '标签2', value: 'tab2' }
])
</script>
```
## 动态标签
支持动态添加和删除标签:
```vue
<template>
<Tab :tabs="dynamicTabs" @change="handleChange" />
<button @click="addTab">添加标签</button>
<button @click="removeTab">删除标签</button>
</template>
<script setup>
const dynamicTabs = ref([
{ label: '标签1', value: 'tab1' }
])
const addTab = () => {
const newIndex = dynamicTabs.value.length + 1
dynamicTabs.value.push({
label: `标签${newIndex}`,
value: `tab${newIndex}`
})
}
const removeTab = () => {
if (dynamicTabs.value.length > 1) {
dynamicTabs.value.pop()
}
}
</script>
```
## Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| tabs | Array | `[{label:'全部订单',value:'all'},{label:'服务工单',value:'service'}]` | 标签数据数组 |
| defaultActive | Number | `0` | 默认选中的标签索引 |
| indicatorColor | String | `#007AFF` | 指示器颜色 |
| modelValue | Number | - | 当前选中索引(用于 v-model |
### tabs 数组项结构
```typescript
interface TabItem {
label: string // 标签显示文本
value: string // 标签值
[key: string]: any // 其他自定义属性
}
```
## Events
| 事件名 | 说明 | 参数 |
|--------|------|------|
| change | 标签切换时触发 | `{ index: number, item: TabItem }` |
| update:modelValue | 用于 v-model 双向绑定 | `index: number` |
## Slots
| 插槽名 | 说明 | 作用域参数 |
|--------|------|------------|
| tab-item | 自定义标签内容 | `{ item: TabItem, index: number, isActive: boolean }` |
## 方法
通过 `ref` 可以调用组件的方法:
```vue
<template>
<Tab ref="tabRef" :tabs="tabList" />
<button @click="switchToTab(1)">切换到第二个标签</button>
</template>
<script setup>
const tabRef = ref()
const switchToTab = (index) => {
tabRef.value.setActiveIndex(index)
}
</script>
```
| 方法名 | 说明 | 参数 | 返回值 |
|--------|------|------|--------|
| setActiveIndex | 设置当前选中的标签 | `index: number` | - |
| getActiveIndex | 获取当前选中的标签索引 | - | `number` |
| getActiveItem | 获取当前选中的标签项 | - | `TabItem` |
## 样式定制
### CSS 变量
组件支持通过 CSS 变量进行样式定制:
```scss
.tab-container {
--tab-bg-color: #fff; // 背景色
--tab-text-color: #666; // 文字颜色
--tab-active-color: #333; // 选中文字颜色
--tab-indicator-color: #007AFF; // 指示器颜色
--tab-border-color: #f0f0f0; // 边框颜色
}
```
### 自定义主题
```vue
<template>
<Tab
:tabs="tabList"
indicatorColor="#ff4d4f"
class="custom-tab"
/>
</template>
<style>
.custom-tab {
--tab-indicator-color: #ff4d4f;
}
.custom-tab .tab-text-active {
color: #ff4d4f;
}
</style>
```
## 技术实现
- **框架**Vue 3 组合式 API
- **平台**uniapp 跨平台开发
- **动画**CSS3 transition + transform
- **响应式**CSS media queries
- **兼容性**微信小程序、H5、App
## 设计规范
- 遵循微信小程序设计规范
- 支持无障碍访问
- 响应式设计,适配不同设备
- 流畅的动画过渡效果
- 一致的视觉风格
## 兼容性
| 平台 | 支持情况 |
|------|----------|
| 微信小程序 | ✅ 完全支持 |
| H5 | ✅ 完全支持 |
| App | ✅ 完全支持 |
| 支付宝小程序 | ✅ 完全支持 |
| 百度小程序 | ✅ 完全支持 |
## 更新日志
### v1.0.0
- ✨ 初始版本发布
- ✨ 支持基础标签切换功能
- ✨ 支持动画指示器
- ✨ 支持自定义标签内容
- ✨ 支持响应式设计
- ✨ 支持事件和双向绑定
## 备注
仅供学习、交流使用,请勿用于商业用途。

View File

@@ -0,0 +1,275 @@
<template>
<view class="tab-container">
<view class="tab-wrapper">
<view
v-for="(item, index) in tabList"
:key="index"
:class="['tab-item', activeIndex === index && 'tab-item-active']"
@click="handleTabClick(index)"
>
<slot
name="tab-item"
:item="item"
:index="index"
:isActive="activeIndex === index"
>
<text
:class="['tab-text', activeIndex === index && 'tab-text-active']"
>
{{ item.label }}
</text>
</slot>
</view>
</view>
<!-- 下划线指示器 -->
<view
:class="[
'tab-indicator',
indicatorAnimating && 'animating',
indicatorInitialized && 'initialized',
]"
:style="{
left: indicatorLeft + 'px',
width: indicatorWidth + 'px',
}"
></view>
</view>
</template>
<script setup>
import {
ref,
reactive,
onMounted,
nextTick,
watch,
getCurrentInstance,
} from "vue";
// 获取组件实例
const instance = getCurrentInstance();
// Props
const props = defineProps({
tabs: {
type: Array,
default: () => [
{ label: "全部订单", value: "all" },
{ label: "服务工单", value: "service" },
],
},
defaultActive: {
type: Number,
default: 0,
},
indicatorColor: {
type: String,
default: "#007AFF",
},
});
// Emits
const emit = defineEmits(["change", "update:modelValue"]);
// 响应式数据
const activeIndex = ref(props.defaultActive);
const tabList = ref(props.tabs);
const indicatorLeft = ref(0);
const indicatorWidth = ref(0);
const tabItemRects = reactive([]);
const isUpdating = ref(false);
const indicatorAnimating = ref(false);
const indicatorInitialized = ref(false);
// 处理Tab点击
const handleTabClick = (index) => {
if (activeIndex.value === index) return;
activeIndex.value = index;
indicatorAnimating.value = true;
updateIndicator();
// 动画结束后移除动画类
setTimeout(() => {
indicatorAnimating.value = false;
}, 300);
emit("change", {
index,
item: tabList.value[index],
});
emit("update:modelValue", index);
};
// 更新指示器位置
const updateIndicator = async () => {
if (isUpdating.value) return;
isUpdating.value = true;
await nextTick();
// 检查实例是否存在
if (!instance) {
console.warn("Component instance not available");
isUpdating.value = false;
return;
}
// 检查 uni.createSelectorQuery 是否可用
if (!uni || !uni.createSelectorQuery) {
console.warn("uni.createSelectorQuery not available");
isUpdating.value = false;
return;
}
const query = uni.createSelectorQuery().in(instance);
// 同时获取tab项和容器的位置信息
query.selectAll(".tab-item").boundingClientRect();
query.select(".tab-wrapper").boundingClientRect();
query.exec((res) => {
try {
const [tabRects, wrapperRect] = res || [];
if (tabRects && tabRects.length > 0 && wrapperRect) {
tabItemRects.splice(0, tabItemRects.length, ...tabRects);
const activeRect = tabRects[activeIndex.value];
if (activeRect) {
// 计算相对于容器的位置,居中显示
const tabCenter =
activeRect.left - wrapperRect.left + activeRect.width / 2;
indicatorLeft.value = tabCenter - 7.5; // 15px宽度的一半
// 固定宽度15px不动态计算宽度
indicatorWidth.value = 15;
// 标记为已初始化
if (!indicatorInitialized.value) {
indicatorInitialized.value = true;
}
}
} else {
console.warn("Failed to get tab rects or wrapper rect");
}
} catch (error) {
console.error("Error in updateIndicator exec:", error);
} finally {
isUpdating.value = false;
}
});
};
// 监听activeIndex变化
watch(
() => activeIndex.value,
() => {
// 如果是初始化阶段使用initIndicator
if (indicatorLeft.value === 0 && indicatorWidth.value === 0) {
initIndicator();
} else {
updateIndicator();
}
}
);
// 监听tabs变化
watch(
() => props.tabs,
(newTabs) => {
tabList.value = newTabs;
// 重置初始化状态
indicatorInitialized.value = false;
// 重新初始化指示器
initIndicator();
},
{ deep: true }
);
// 监听defaultActive变化
watch(
() => props.defaultActive,
(newActive) => {
if (newActive !== activeIndex.value) {
activeIndex.value = newActive;
// 重置初始化状态
indicatorInitialized.value = false;
initIndicator();
}
}
);
// 初始化指示器
const initIndicator = async (retryCount = 0) => {
// 等待DOM完全渲染
await nextTick();
// 延迟一帧确保布局完成
setTimeout(() => {
// 检查实例是否存在
if (!instance) {
console.warn("Component instance not available in initIndicator");
return;
}
// 检查 uni.createSelectorQuery 是否可用
if (!uni || !uni.createSelectorQuery) {
console.warn("uni.createSelectorQuery not available in initIndicator");
return;
}
const query = uni.createSelectorQuery().in(instance);
query.selectAll(".tab-item").boundingClientRect();
query.select(".tab-wrapper").boundingClientRect();
query.exec((res) => {
try {
const [tabRects, wrapperRect] = res || [];
// 如果DOM元素还未准备好重试
if (
(!tabRects || tabRects.length === 0 || !wrapperRect) &&
retryCount < 3
) {
setTimeout(() => {
initIndicator(retryCount + 1);
}, 100);
return;
}
// 执行正常的更新逻辑
updateIndicator();
} catch (error) {
console.error("Error in initIndicator exec:", error);
// 如果出错且还有重试次数,尝试重试
if (retryCount < 3) {
setTimeout(() => {
initIndicator(retryCount + 1);
}, 100);
}
}
});
}, 50);
};
// 组件挂载后初始化指示器
onMounted(() => {
initIndicator();
});
// 暴露方法
defineExpose({
setActiveIndex: (index) => {
if (index >= 0 && index < tabList.value.length) {
handleTabClick(index);
}
},
getActiveIndex: () => activeIndex.value,
getActiveItem: () => tabList.value[activeIndex.value],
});
</script>
<style scoped lang="scss">
@use "./styles/index.scss";
</style>

View File

@@ -0,0 +1,100 @@
.tab-container {
position: relative;
}
.tab-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 30px;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
position: relative;
transition: all 0.3s ease;
padding: 0 8px;
}
.tab-text {
font-size: 14px;
color: #666;
font-weight: 400;
transition: all 0.3s ease;
white-space: nowrap;
}
.tab-text-active {
color: #333;
font-size: 16px;
font-weight: 600;
}
.tab-item-active {
.tab-text {
color: #333;
font-weight: 600;
}
}
.tab-indicator {
position: absolute;
bottom: 0;
height: 3px;
min-height: 3px; /* 确保最小高度 */
background-color: #007AFF;
border-radius: 10px;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1), width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
transform: translateZ(0); /* 启用硬件加速 */
will-change: left, width; /* 优化动画性能 */
/* 初始状态:未初始化时隐藏 */
opacity: 0;
width: 15px; /* 默认宽度15px */
left: 0;
}
/* 已初始化状态 */
.tab-indicator.initialized {
opacity: 1;
}
/* 点击效果 */
.tab-item:active {
opacity: 0.7;
}
/* 自定义主题色支持 */
.tab-container[data-indicator-color="red"] .tab-indicator {
background-color: #ff4d4f;
}
.tab-container[data-indicator-color="green"] .tab-indicator {
background-color: #52c41a;
}
.tab-container[data-indicator-color="orange"] .tab-indicator {
background-color: #fa8c16;
}
/* 动画增强 */
@keyframes tabSwitch {
0% {
transform: translateZ(0) scaleX(0.8);
opacity: 0.6;
}
100% {
transform: translateZ(0) scaleX(1);
opacity: 1;
}
}
.tab-indicator.animating {
animation: tabSwitch 0.3s ease-out;
}

View File

@@ -0,0 +1,309 @@
<template>
<view class="test-page">
<view class="test-section">
<text class="test-title">Tab组件测试</text>
<!-- 基础测试 -->
<view class="test-item">
<text class="item-title">基础用法</text>
<Tab
:tabs="basicTabs"
:defaultActive="0"
@change="handleTabChange"
/>
<view class="result">
<text>当前选中: {{ currentTab.label }}</text>
</view>
</view>
<!-- 多标签测试 -->
<view class="test-item">
<text class="item-title">多标签测试</text>
<Tab
:tabs="multiTabs"
:defaultActive="1"
@change="handleMultiTabChange"
/>
<view class="result">
<text>当前选中: {{ currentMultiTab.label }}</text>
</view>
</view>
<!-- 快速切换测试 -->
<view class="test-item">
<text class="item-title">快速切换测试</text>
<Tab
ref="fastTabRef"
:tabs="fastTabs"
:defaultActive="0"
@change="handleFastTabChange"
/>
<view class="result">
<text>当前选中: {{ currentFastTab.label }}</text>
</view>
<view class="test-buttons">
<button
v-for="(tab, index) in fastTabs"
:key="index"
class="test-btn"
@click="switchToTab(index)"
>
切换到{{ tab.label }}
</button>
</view>
</view>
<!-- 初始化测试 -->
<view class="test-item">
<text class="item-title">初始化测试</text>
<text class="test-desc">测试指示器的动态高度和宽度初始化及错误处理</text>
<Tab
v-if="showInitTest"
:tabs="initTestTabs"
:defaultActive="initActiveIndex"
@change="handleInitTestChange"
/>
<view class="result">
<text>当前选中: {{ currentInitTab.label }}</text>
</view>
<view class="test-buttons">
<button class="test-btn" @click="toggleInitTest">
{{ showInitTest ? '隐藏' : '显示' }}组件
</button>
<button class="test-btn" @click="changeInitActive">
切换默认激活项 (当前: {{ initActiveIndex }})
</button>
<button class="test-btn" @click="addInitTab">
添加Tab
</button>
<button class="test-btn" @click="removeInitTab">
移除Tab
</button>
<button class="test-btn" @click="rapidToggle">
快速切换测试
</button>
</view>
<view class="test-info">
<text>错误处理测试组件现在能够安全处理实例为null的情况</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import Tab from './index.vue'
// 基础标签
const basicTabs = ref([
{ label: '全部订单', value: 'all' },
{ label: '服务工单', value: 'service' }
])
// 多标签
const multiTabs = ref([
{ label: '全部', value: 'all' },
{ label: '待支付', value: 'unpaid' },
{ label: '待确认', value: 'unconfirmed' },
{ label: '进行中', value: 'processing' },
{ label: '已完成', value: 'completed' }
])
// 快速切换测试
const fastTabs = ref([
{ label: '标签A', value: 'a' },
{ label: '标签B', value: 'b' },
{ label: '标签C', value: 'c' },
{ label: '标签D', value: 'd' }
])
// 初始化测试
const initTestTabs = ref([
{ label: '初始1', value: 'init1' },
{ label: '初始2', value: 'init2' },
{ label: '初始3', value: 'init3' }
])
// 当前选中索引
const currentTabIndex = ref(0)
const currentMultiTabIndex = ref(1)
const currentFastTabIndex = ref(0)
const currentInitTabIndex = ref(1)
// 初始化测试状态
const showInitTest = ref(true)
const initActiveIndex = ref(1)
// 计算当前选中项
const currentTab = computed(() => basicTabs.value[currentTabIndex.value] || {})
const currentMultiTab = computed(() => multiTabs.value[currentMultiTabIndex.value] || {})
const currentFastTab = computed(() => fastTabs.value[currentFastTabIndex.value] || {})
const currentInitTab = computed(() => initTestTabs.value[currentInitTabIndex.value] || {})
// Tab引用
const fastTabRef = ref(null)
// 事件处理
const handleTabChange = ({ index, item }) => {
currentTabIndex.value = index
console.log('基础Tab切换:', item)
}
const handleMultiTabChange = ({ index, item }) => {
currentMultiTabIndex.value = index
console.log('多标签Tab切换:', item)
}
const handleFastTabChange = ({ index, item }) => {
currentFastTabIndex.value = index
console.log('快速Tab切换:', item)
}
const handleInitTestChange = ({ index, item }) => {
currentInitTabIndex.value = index
console.log('初始化Tab切换:', item)
}
// 程序化切换
const switchToTab = (index) => {
currentFastTabIndex.value = index
if (fastTabRef.value) {
fastTabRef.value.setActiveIndex(index)
}
}
// 初始化测试方法
const toggleInitTest = () => {
showInitTest.value = !showInitTest.value
}
const changeInitActive = () => {
initActiveIndex.value = (initActiveIndex.value + 1) % initTestTabs.value.length
}
const addInitTab = () => {
const newIndex = initTestTabs.value.length + 1
initTestTabs.value.push({
label: `初始${newIndex}`,
value: `init${newIndex}`
})
}
const removeInitTab = () => {
if (initTestTabs.value.length > 1) {
initTestTabs.value.pop()
if (initActiveIndex.value >= initTestTabs.value.length) {
initActiveIndex.value = initTestTabs.value.length - 1
}
}
}
const rapidToggle = () => {
// 快速切换测试模拟可能导致实例为null的场景
let count = 0
const interval = setInterval(() => {
showInitTest.value = !showInitTest.value
count++
if (count >= 6) {
clearInterval(interval)
showInitTest.value = true
}
}, 200)
}
</script>
<style scoped>
.test-page {
padding: 20px;
background-color: #f5f5f5;
min-height: 100vh;
}
.test-section {
background-color: #fff;
border-radius: 8px;
padding: 20px;
}
.test-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: block;
}
.test-item {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
}
.test-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
.item-title {
font-size: 16px;
font-weight: 500;
color: #666;
margin-bottom: 15px;
display: block;
}
.test-desc {
font-size: 14px;
color: #999;
margin-bottom: 10px;
display: block;
}
.test-info {
margin-top: 15px;
padding: 10px;
background-color: #f0f9ff;
border-radius: 8px;
border-left: 4px solid #007AFF;
}
.test-info text {
font-size: 13px;
color: #666;
line-height: 1.4;
}
.result {
margin-top: 15px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}
.result text {
font-size: 14px;
color: #666;
}
.test-buttons {
margin-top: 15px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.test-btn {
padding: 8px 16px;
font-size: 14px;
background-color: #007AFF;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.test-btn:active {
opacity: 0.7;
}
</style>