refactor: clean up old Tabs component and update Card routing

- remove unused Tabs component docs, test, style and markdown files
- refactor Tabs component to use inline utility classes and inline keyframes
- update Card component's click handler to use named route "goods"
This commit is contained in:
DEV_DSW
2026-05-29 08:50:13 +08:00
parent ede99463de
commit 503df8a812
6 changed files with 29 additions and 690 deletions

View File

@@ -1,250 +0,0 @@
# 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 }">
<div class="custom-tab">
<img v-if="item.icon" :src="item.icon" class="tab-icon" />
<text :class="{ 'active-text': isActive }">{{ item.label }}</text>
<div v-if="item.badge" class="badge">{{ item.badge }}</div>
</div>
</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

@@ -1,21 +1,11 @@
<template>
<div class="tab-container">
<div class="tab-wrapper">
<div
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"
>
<span
:class="['tab-span', activeIndex === index && 'tab-span-active']"
>
<div class="relative">
<div class="flex items-center justify-center h-[30px]">
<div v-for="(item, index) in tabList" :key="index"
:class="['flex-1 flex items-center justify-center h-full relative transition-all duration-300 ease-out px-2', activeIndex === index && 'text-[#333] text-base font-semibold']"
@click="handleTabClick(index)">
<slot name="tab-item" :item="item" :index="index" :isActive="activeIndex === index">
<span :class="['text-[#333] font-semibold', activeIndex === index && 'text-[#333] text-base font-semibold']">
{{ item.label }}
</span>
</slot>
@@ -23,17 +13,14 @@
</div>
<!-- 下划线指示器 -->
<div
:class="[
'tab-indicator',
indicatorAnimating && 'animating',
indicatorInitialized && 'initialized',
]"
:style="{
left: indicatorLeft + 'px',
width: indicatorWidth + 'px',
}"
></div>
<div :class="[
'absolute bottom-0 h-[3px] min-h-[3px] bg-[#007aff] rounded-[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-10 [transform:translateZ(0)] [will-change:left,width] opacity-0 w-[15px] left-0',
indicatorAnimating && '[animation:tabSwitch_0.3s_ease-out]',
indicatorInitialized && 'opacity-100',
]" :style="{
left: indicatorLeft + 'px',
width: indicatorWidth + 'px',
}"></div>
</div>
</template>
@@ -271,5 +258,15 @@ defineExpose({
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
@keyframes tabSwitch {
0% {
transform: translateZ(0) scaleX(0.8);
opacity: 0.6;
}
100% {
transform: translateZ(0) scaleX(1);
opacity: 1;
}
}
</style>

View File

@@ -1,101 +0,0 @@
.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

@@ -1,308 +0,0 @@
<template>
<div class="test-page">
<div class="test-section">
<span class="test-title">Tab组件测试</span>
<!-- 基础测试 -->
<div class="test-item">
<span class="item-title">基础用法</span>
<Tab :tabs="basicTabs" :defaultActive="0" @change="handleTabChange" />
<div class="result">
<span>当前选中: {{ currentTab.label }}</span>
</div>
</div>
<!-- 多标签测试 -->
<div class="test-item">
<span class="item-title">多标签测试</span>
<Tab
:tabs="multiTabs"
:defaultActive="1"
@change="handleMultiTabChange"
/>
<div class="result">
<span>当前选中: {{ currentMultiTab.label }}</span>
</div>
</div>
<!-- 快速切换测试 -->
<div class="test-item">
<span class="item-title">快速切换测试</span>
<Tab
ref="fastTabRef"
:tabs="fastTabs"
:defaultActive="0"
@change="handleFastTabChange"
/>
<div class="result">
<span>当前选中: {{ currentFastTab.label }}</span>
</div>
<div class="test-buttons">
<button
v-for="(tab, index) in fastTabs"
:key="index"
class="test-btn"
@click="switchToTab(index)"
>
切换到{{ tab.label }}
</button>
</div>
</div>
<!-- 初始化测试 -->
<div class="test-item">
<span class="item-title">初始化测试</span>
<span class="test-desc"
>测试指示器的动态高度和宽度初始化及错误处理</span
>
<Tab
v-if="showInitTest"
:tabs="initTestTabs"
:defaultActive="initActiveIndex"
@change="handleInitTestChange"
/>
<div class="result">
<span>当前选中: {{ currentInitTab.label }}</span>
</div>
<div 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>
</div>
<div class="test-info">
<span>错误处理测试组件现在能够安全处理实例为null的情况</span>
</div>
</div>
</div>
</div>
</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 span {
font-size: 13px;
color: #666;
line-height: 1.4;
}
.result {
margin-top: 15px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}
.result span {
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>

View File

@@ -67,6 +67,7 @@ const navigateToPage = (commodityId, path) => {
router.push({ path, query: { commodityId } })
};
const handleClick = ({ commodityId }) =>
navigateToPage(commodityId, "/pages/goods/index");
const handleClick = ({ commodityId }) => {
router.push({ name: "goods", query: { commodityId } })
}
</script>