chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
# Custom Tabs 组件使用文档
## 组件特性
**高度自适应** - 自动继承父容器高度,内容区域自动撑满
**懒加载** - 支持 tab 内容懒加载,提升性能
**平滑动画** - 切换动画流畅自然
**灵活配置** - 支持自定义头部高度、颜色等
**响应式指示器** - 自动计算指示器位置和宽度
## 基础用法
```vue
<template>
<view class="page">
<custom-tabs v-model="activeTab" :tabs="tabs">
<template #recent>
<view class="tab-content"> 最近使用内容 </view>
</template>
<template #history>
<view class="tab-content"> 会话记录内容 </view>
</template>
</custom-tabs>
</view>
</template>
<script setup lang="uts">
import CustomTabs from "@/components/custom-tabs/custom-tabs.uvue";
const activeTab = ref(0);
const tabs = [
{ label: "最近使用", name: "recent" },
{ label: "会话记录", name: "history" },
];
</script>
<style lang="scss" scoped>
.page {
height: 100vh;
.tab-content {
padding: 32rpx;
}
}
</style>
```
## Props
| 参数 | 说明 | 类型 | 默认值 |
| -------------------- | ------------------- | -------------------------------------- | --------- |
| tabs | Tab 数据列表 | `Array<{label: string, name: string}>` | `[]` |
| modelValue / v-model | 当前激活的 tab 索引 | `number` | `0` |
| headerHeight | Tab 头部高度 | `string` | `'88rpx'` |
| animated | 是否开启切换动画 | `boolean` | `true` |
| lazyLoad | 是否懒加载 tab 内容 | `boolean` | `true` |
## Events
| 事件名 | 说明 | 回调参数 |
| ----------------- | ----------------------- | --------------------------- |
| update:modelValue | 当前激活 tab 改变时触发 | `(index: number)` |
| change | 切换 tab 时触发 | `(index: number, tab: Tab)` |
## Slots
每个 tab 对应一个具名插槽,插槽名称为 `tabs` 数组中的 `name` 字段。
## 完整示例
```vue
<template>
<view class="page-container">
<custom-tabs
v-model="currentTab"
:tabs="tabList"
:header-height="'100rpx'"
:animated="true"
:lazy-load="true"
@change="handleTabChange"
>
<!-- 最近使用 -->
<template #recent>
<scroll-view class="scroll-content" scroll-y>
<view class="content-wrapper">
<text>最近使用的内容</text>
</view>
</scroll-view>
</template>
<!-- 会话记录 -->
<template #history>
<scroll-view class="scroll-content" scroll-y>
<view class="content-wrapper">
<text>会话记录内容</text>
</view>
</scroll-view>
</template>
<!-- 收藏 -->
<template #favorites>
<scroll-view class="scroll-content" scroll-y>
<view class="content-wrapper">
<text>收藏内容</text>
</view>
</scroll-view>
</template>
</custom-tabs>
</view>
</template>
<script setup lang="uts">
import CustomTabs from "@/components/custom-tabs/custom-tabs.uvue";
const currentTab = ref(0);
const tabList = [
{ label: "最近使用", name: "recent" },
{ label: "会话记录", name: "history" },
{ label: "收藏", name: "favorites" },
];
const handleTabChange = (index: number, tab: any) => {
console.log("切换到", tab.label, "索引:", index);
};
</script>
<style lang="scss" scoped>
.page-container {
height: 100vh;
display: flex;
flex-direction: column;
.scroll-content {
flex: 1;
height: 100%;
}
.content-wrapper {
padding: 32rpx;
}
}
</style>
```
## 高度自适应说明
组件使用了 **Flexbox** 布局来实现高度自适应:
1. **父容器约束**:组件根元素使用 `h-full flex flex-col`,确保继承父级高度
2. **内容区域自动撑满**:内容区域使用 `flex-1`,自动填充剩余空间
3. **使用建议**:确保父容器有明确的高度(如 `height: 100vh` 或固定高度)
## 自定义样式
如需自定义样式,可以通过覆盖 CSS 变量或者深度选择器:
```scss
// 自定义激活颜色
::v-deep .custom-tabs {
.tab-item.active .tab-label {
color: #ff6b6b;
}
.tab-indicator {
background: linear-gradient(90deg, #ff6b6b 0%, #ff8787 100%);
}
}
```
## 注意事项
1. 确保传入的 `tabs` 数组中的 `name` 字段与插槽名称一致
2. 父容器需要有明确的高度,否则组件无法正确撑满
3. 懒加载模式下,首次进入的 tab 会立即加载,其他 tab 在首次切换时加载
4. 如果内容需要滚动,请在插槽内使用 `scroll-view` 组件

View File

@@ -0,0 +1,114 @@
<template>
<view class="page-example h-full flex flex-col">
<!-- 页面标题 -->
<view class="page-header">
<text class="title">{{ t("Mobile.Common.appName") }} Tab</text>
</view>
<!-- Tab 组件 - 新的子组件方式 -->
<view class="flex-1">
<pane-tabs v-model="activeTab" @change="handleTabChange">
<pane-tab key-value="recent" :tab="t('Mobile.Common.recentUsed')">
<scroll-view class="scroll-content" scroll-y>
<view class="content-wrapper">
<view class="demo-card" v-for="item in 10" :key="item">
<text class="card-title">{{ t("Mobile.Common.recentUsed") }} {{ item }}</text>
<text class="card-desc">{{ t("Mobile.Common.noData") }}</text>
</view>
</view>
</scroll-view>
</pane-tab>
<pane-tab key-value="history" :tab="t('Mobile.Common.conversationHistory')">
<scroll-view class="h-full" scroll-y>
<view class="content-wrapper">
<view class="demo-card" v-for="item in 15" :key="item">
<text class="card-title">{{ t("Mobile.Common.conversationHistory") }} {{ item }}</text>
<text class="card-desc">{{ t("Mobile.Common.noData") }}</text>
</view>
</view>
</scroll-view>
</pane-tab>
<pane-tab key-value="favorites" :tab="t('Mobile.Conversation.favorite')" :force-render="true">
<scroll-view class="h-full" scroll-y>
<view class="content-wrapper">
<view class="demo-card" v-for="item in 8" :key="item">
<text class="card-title">{{ t("Mobile.Conversation.favorite") }} {{ item }}</text>
<text class="card-desc">{{ t("Mobile.Common.noData") }}</text>
</view>
</view>
</scroll-view>
</pane-tab>
</pane-tabs>
</view>
</view>
</template>
<script setup lang="uts">
import PaneTabs from "@/components/pane-tabs/pane-tabs.uvue";
import PaneTab from "@/components/pane-tabs/pane-tab.uvue";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
const activeTab = ref("recent");
const handleTabChange = (key: string) => {
console.log("tab changed:", key);
};
</script>
<style lang="scss" scoped>
.page-example {
background: #f5f5f5;
.page-header {
height: 88rpx;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1rpx solid #f0f0f0;
.title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
}
.h-full {
height: 100%;
}
.scroll-content {
width: 100%;
height: 100%;
}
.content-wrapper {
padding: 24rpx;
.demo-card {
background: #fff;
border-radius: 12rpx;
padding: 32rpx;
margin-bottom: 24rpx;
.card-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 16rpx;
}
.card-desc {
font-size: 24rpx;
color: #999;
display: block;
}
}
}
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<view class="pane-tab">
<slot />
</view>
</template>
<script setup lang="uts">
// Props 定义
interface Props {
tab: string; // 标签文字
keyValue: string; // 唯一标识
forceRender?: boolean; // 是否强制渲染
}
const props = withDefaults(defineProps<Props>(), {
forceRender: false,
});
// 从父组件注入 paneTabs
const paneTabs = inject<any>("paneTabs");
// 组件挂载时注册到父组件
onMounted(() => {
if (paneTabs != null && (paneTabs as any).registerTab != null) {
const registerFn = (paneTabs as any).registerTab as (item : any) => void;
registerFn({
key: props.keyValue,
label: props.tab,
forceRender: props.forceRender,
});
}
});
// 监听标题变化,实时更新父组件中的标签信息
watch(() => props.tab, (newTab : string) => {
if (paneTabs != null && (paneTabs as any).tabs != null) {
const tabsRef = (paneTabs as any).tabs as Ref<any[]>;
tabsRef.value.forEach((t : any) => {
if (t.key == props.keyValue) {
t.label = newTab;
}
});
}
});
// 组件卸载时从父组件注销
onUnmounted(() => {
if (paneTabs && paneTabs.unregisterTab) {
paneTabs.unregisterTab(props.keyValue);
}
});
</script>
<style lang="scss" scoped>
.pane-tab {
flex: 0 0 auto;
width: 100vw; // 占满视口宽度
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>

View File

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