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,28 @@
/* #ifdef MP */
:host {
flex-shrink: 0;
box-sizing: border-box;
width: 100%;
}
/* #endif */
.l-tab__panel {
width: 100%;
/* #ifdef UNI-APP-X */
flex: 1;
/* #endif */
flex-shrink: 0;
box-sizing: border-box;
/* #ifndef APP-ANDROID || APP-IOS || APP-HARMONY */
overflow-y: auto;
-webkit-overflow-scrolling: touch;
/* #endif */
// &--active {
// height: auto;
// }
// &--inactive {
// height: 0;
// overflow: visible;
// }
}

View File

@@ -0,0 +1,55 @@
<template>
<view class="l-tab__panel" aria-role="tabpanel">
<slot/>
</view>
</template>
<script lang="uts" setup>
/**
* TabPanel 标签页面板组件
* @description 用于构建Tabs组件的单个内容面板必须作为Tabs的子组件使用
* <br>插件类型LTabPanelComponentPublicInstance
* @tutorial https://ext.dcloud.net.cn/plugin?name=lime-tabs
*
* @property {any} badge 徽标配置
* @property {number[]} offset 徽标位置偏移量[x,y]
* @property {boolean} dot 是否显示圆点徽标默认false
* @property {boolean} destroyOnHide 内容隐藏时销毁DOM默认false
* @property {boolean} disabled 禁用当前选项卡默认false
* @property {string} label 选项卡标题内容
* @property {boolean} lazy 启用懒加载默认false
* @property {number} value 选项卡唯一标识
*/
import { TabPanelProps } from './type';
import { TabPanel } from '../l-tabs/type'
const props = withDefaults(defineProps<TabPanelProps>(), {})
const children = inject<LTabPanelComponentPublicInstance[]|null>('LimeTabs', null) as Ref<LTabPanelComponentPublicInstance[]>|null;
const instance = getCurrentInstance()!.proxy!
onMounted(()=>{
if(children == null) return
children.value.push(instance as LTabPanelComponentPublicInstance)
})
// #ifdef APP-ANDROID
// 安卓端数组属性存在BUG 死循环
const innderOffset = ref<any[]>([])
watch((): any[]|null => props.offset, (n: any[]|null) => {
if(innderOffset.value.join('') == n?.join('')) return
innderOffset.value = n ?? []
}, {immediate: true})
defineExpose({
innderOffset
})
// #endif
onUnmounted(()=>{
if(children == null) return
children.value = children.value.filter((it):boolean => it != (instance))
})
</script>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,48 @@
<template>
<view class="l-tab__panel" aria-role="tabpanel">
<slot/>
</view>
</template>
<script lang="ts">
// @ts-nocheck
/**
* TabPanel 标签页面板组件
* @description 用于构建Tabs组件的单个内容面板必须作为Tabs的子组件使用
* <br>插件类型LTabPanelComponentPublicInstance
* @tutorial https://ext.dcloud.net.cn/plugin?name=lime-tabs
*
* @property {any} badge 徽标配置
* @property {number[]} offset 徽标位置偏移量[x,y]
* @property {boolean} dot 是否显示圆点徽标默认false
* @property {boolean} destroyOnHide 内容隐藏时销毁DOM默认false
* @property {boolean} disabled 禁用当前选项卡默认false
* @property {string} label 选项卡标题内容
* @property {boolean} lazy 启用懒加载默认false
* @property {number} value 选项卡唯一标识
*/
import {onMounted, defineComponent, inject, onUnmounted} from '@/uni_modules/lime-shared/vue';
import panelProps from './props';
export default defineComponent({
name: 'l-tab-panel',
props: panelProps,
setup(props) {
const children = inject<LTabPanelComponentPublicInstance[]|null>('LimeTabs', null) as Ref<LTabPanelComponentPublicInstance[]>|null;
onMounted(()=>{
if(!children) return
children.value.push(props)
})
onUnmounted(()=>{
if(!children) return
children.value = children.value.filter((it):boolean => it != props)
})
return {
}
}
})
</script>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,48 @@
export default {
/**
* 透传至 Badge 组件
*/
badge: {
type: [String, Number]
},
offset: {
type: Array
},
dot: {
type: Boolean,
defalut: false
},
/**
* 选项卡内容隐藏时是否销毁
*/
destroyOnHide: {
type: Boolean,
defalut: false
},
/**
* 是否禁用当前选项卡
*/
disabled: {
type: Boolean,
defalut: false
},
/**
* 选项卡名称,可自定义选项卡导航内容
*/
label: {
type: String
},
/**
* 是否启用选项卡懒加载
*/
lazy: {
type: Boolean,
defalut: false
},
/**
* 选项卡的值,唯一标识
*/
value: {
type: [Number, String]
}
}

View File

@@ -0,0 +1,30 @@
// @ts-nocheck
export interface TabPanelProps {
/**
* 透传至 Badge 组件
*/
badge?: any;
offset?: any[];
dot?: boolean;
/**
* 选项卡内容隐藏时是否销毁
*/
destroyOnHide?: boolean;
/**
* 是否禁用当前选项卡
*/
disabled?: boolean;
/**
* 选项卡名称,可自定义选项卡导航内容
*/
label?: string;
/**
* 是否启用选项卡懒加载
*/
lazy?: boolean;
/**
* 选项卡的值,唯一标识
*/
value?: number;
}

View File

@@ -0,0 +1,217 @@
@import '@/uni_modules/lime-style/index.scss';
@import '@/uni_modules/lime-style/mixins/hairline.scss';
/* #ifdef uniVersion >= 4.75 */
$use-css-var: true;
/* #endif */
$tabs: l-tabs;
$item: l-tabs__item;
$text-color-disabled: create-var(text-color-disabled, $text-color-4);
$tab-font-size: create-var(tab-font-size, 14px);
$tab-large-font-size: create-var(tab-font-size, 16px);
$tab-nav-bg-color: create-var(tab-nav-bg-color, $bg-color-container);
$tab-content-bg-color: create-var(tab-content-bg-color, $bg-color-container);
$tab-item-height: create-var(tab-item-height, 48px);
$tab-item-padding-x: create-var(tab-item-padding-x, 16px);
$tab-item-padding-y: create-var(tab-item-padding-y, 0);
$tab-item-color: create-var(tab-item-color, $text-color-1);
$tab-item-active-color: create-var(tab-item-active-color, $primary-color);
$tab-item-disabled-color: create-var(tab-item-disabled-color, $text-color-4);
// track
$tab-split-color: create-var(tab-split-color, $border-color-2);
$tab-track-color: create-var(tab-track-color, $primary-color);
$tab-track-height: create-var(tab-track-height, 3px);
$tab-track-width: create-var(tab-track-width, 16px);
$tab-track-radius: create-var(tab-track-radius, 4px);
.#{$tabs} {
position: relative;
/* #ifndef UNI-APP-X & APP */
font-size: $tab-font-size;
/* #endif */
// background-color: $tab-nav-bg-color;
// flex-wrap: wrap;
&__wrap {
/* #ifndef UNI-APP-X */
display: flex;
/* #endif */
flex: 1;
background-color: $tab-nav-bg-color;
flex-direction: row;
}
&__scroll {
position: relative;
flex: 1;
/* #ifdef UNI-APP-X & APP || UNI-APP-X & WEB */
flex-direction: row;
/* #endif */
/* #ifndef UNI-APP-X & APP */
:deep(.uni-scroll-view) {
&::-webkit-scrollbar {
display: none;
}
}
// &::-webkit-scrollbar {
// display: none;
// width: 0 !important;
// height: 0 !important;
// -webkit-appearance: none;
// background: transparent;
// }
/* #endif */
&--split {
/* #ifndef UNI-APP-X & APP */
&:after {
@include hairline-bottom($tab-split-color);
}
/* #endif */
/* #ifdef UNI-APP-X & APP */
// border-bottom: 1rpx solid $tab-split-color;
border-bottom-color: $tab-split-color;
border-bottom-style: solid;
border-bottom-width: 0.7px;
/* #endif */
}
}
&__nav {
/* #ifndef UNI-APP-X */
display: flex;
/* #endif */
/* #ifndef UNI-APP-X & APP */
user-select: none;
min-width: fit-content;
width: 100%;
/* #endif */
flex-direction: row;
position: relative;
// width: 100%;
// align-self: flex-start;
flex-wrap: nowrap;
align-items: center;
}
&__track {
position: absolute;
z-index: 1;
transition-duration: 0.3s;
background-color: $tab-track-color;
left: 0;
bottom: 1rpx;
width: $tab-track-width;
height: $tab-track-height;
// border-radius: $tab-track-radius;
@include border-radius($tab-track-radius);
}
&__content {
width: 100%;
// flex: 1;
overflow: hidden;
background-color: $tab-content-bg-color;
&-inner {
display: flex;
flex-direction: row;
flex: 1;
overflow: visible;
/* #ifdef UNI-APP-X & APP */
// 为了不让首次把所有界面显示 先隐藏一下
opacity: 0;
/* #endif */
}
// &--animated {
// display: flex;
// transition-property: left;
// }
&--animated .l-tabs__content-inner {
position: relative;
width: 100%;
height: 100%;
/* #ifndef UNI-APP-X & APP */
display: flex;
will-change: left;
/* #endif */
transition-property: transform;
}
}
}
.#{$item} {
position: relative;
/* #ifndef UNI-APP-X */
display: flex;
/* #endif */
flex: none;
align-items: center;
justify-content: center;
overflow: visible;
// padding: $tab-item-padding;
@include padding($tab-item-padding-y $tab-item-padding-x);
box-sizing: border-box;
height: $tab-item-height;
/* #ifndef UNI-APP-X & APP */
font-weight: 400;
color: $tab-item-color;
white-space: nowrap;
/* #endif */
&-text {
font-weight: 400;
font-size: $tab-font-size;
white-space: nowrap;
transition: color 300ms;
color: $tab-item-color;
&--large {
font-size: $tab-large-font-size;
/* #ifndef UNI-APP-X */
.l-tabs__item-text {
font-size: inherit;
}
/* #endif */
}
&--active {
font-weight: 700;
color: $tab-item-active-color;
}
&--disabled {
color: $tab-item-disabled-color;
}
/* #ifdef UNI-APP-X & APP */
/* #endif */
}
&--evenly {
flex: 1;
}
/* #ifndef UNI-APP-X & APP */
&--active {
font-weight: 700;
color: $tab-item-active-color;
/* #ifndef UNI-APP-X */
.l-tabs__item-text {
font-weight: inherit;
color: inherit
}
/* #endif */
}
&--disabled {
color: $tab-item-disabled-color;
/* #ifndef UNI-APP-X */
.l-tabs__item-text {
color: inherit
}
/* #endif */
}
/* #endif */
}

View File

@@ -0,0 +1,612 @@
<template>
<view class="l-tabs">
<view class="l-tabs__wrap" :style="[styles, lStyle]">
<slot name="left"></slot>
<scroll-view
class="l-tabs__scroll"
:class="{'l-tabs__scroll--split': split}"
<!-- #ifdef APP || WEB -->
ref="scrollRef"
<!-- #endif -->
:scroll-left="scrollLeft"
direction="horizontal"
:scroll-x="true"
:scroll-with-animation="true"
:show-scrollbar="false"
:enhanced="true"
@scroll="onScroll">
<!-- #ifndef APP -->
<view ref="navRef" class="l-tabs__nav">
<!-- #endif -->
<view class="l-tabs__item" v-for="(item, index) in tabs" :key="index" :style="[itemStyle]" :class="{
'l-tabs__item--active': index == currentIndex,
'l-tabs__item--evenly': spaceEvenly,
'l-tabs__item--disabled': item.disabled}" @click="onClick(index, item)">
<slot name="label" :item="item" :active="index == currentIndex" :disabled="item.disabled">
<l-badge v-if="item.dot == true || item.badge != null" :dot="item.dot" :offset="item.offset"
:content="item.badge">
<text :style="[
textStyles,
!item.disabled && color != null && index != currentIndex ? ('color:' + color) : '',
!item.disabled && activeColor != null && index == currentIndex ? 'color:' + activeColor : '',
]" class="l-tabs__item-text" :class="[
'l-tabs__item-text--' + size,
{
'l-tabs__item-text--disabled': item.disabled,
'l-tabs__item-text--active': index == currentIndex
}
]">{{item.label}}</text>
</l-badge>
<text v-else :style="[
textStyles,
!item.disabled && color != null && index != currentIndex ? ('color:' + color) : '',
!item.disabled && activeColor != null && index == currentIndex ? 'color:' + activeColor : '',
]" class="l-tabs__item-text" :class="[
'l-tabs__item-text--' + size,
{
'l-tabs__item-text--disabled': item.disabled,
'l-tabs__item-text--active': index == currentIndex
}
]">{{item.label}}</text>
</slot>
</view>
<view ref="trackRef" :style="[trackStyle]" class="l-tabs__track"></view>
<!-- #ifndef APP -->
</view>
<!-- #endif -->
</scroll-view>
<slot name="right"></slot>
</view>
<view class="l-tabs__content" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd"
@touchcancel="onTouchEnd" :class="{'l-tabs__content--animated': animated}" v-if="$slots['default'] != null">
<!-- #ifdef APP || WEB -->
<view class="l-tabs__content-inner" :style="[innerStyle]" ref="innerRef">
<slot></slot>
</view>
<!-- #endif -->
<!-- #ifndef APP || WEB -->
<view class="l-tabs__content-inner" :style="[innerStyle, {width: tabs.length * 100 + '%'}]" ref="innerRef">
<slot></slot>
</view>
<!-- #endif -->
</view>
</view>
</template>
<script lang="uts" setup>
/**
* Tabs 标签页组件
* @description 用于内容分类展示和切换,支持多种样式和交互效果
* <br>插件类型LTabsComponentPublicInstance
* @tutorial https://ext.dcloud.net.cn/plugin?name=lime-tabs
*
* @property {TabPanel[]} list 选项卡配置数组
* @property {boolean} ellipsis 是否省略超长文本默认true
* @property {boolean} animated 是否启用切换动画默认false
* @property {number} duration 动画时长ms默认300
* @property {boolean} showLine 显示底部激活线条默认true
* @property {'medium' | 'large'} size 组件尺寸(默认:"medium"
* @value medium
* @value large
* @property {boolean} spaceEvenly 均分选项卡宽度默认false
* @property {boolean} swipeable 支持滑动切换默认true
* @property {number} value 当前激活选项卡索引支持v-model
* @property {string} color 默认文字颜色(默认:"#333"
* @property {string} activeColor 激活状态颜色(默认:主题色)
* @property {string} lineColor 激活线条颜色默认跟随activeColor
* @property {string} lineWidth 激活线条宽度(默认:"20px"
* @property {string} lineHeight 激活线条高度(默认:"3px"
* @property {string} bgColor 背景颜色
* @property {string} fontSize 文字大小支持CSS单位
* @property {string} padding 内边距支持CSS简写
* @property {boolean} split 显示分隔线默认false
* @property {boolean} visible 是否显示组件默认true
* @property {number} swiperProgress 与swiper联动传入[-1, 1] 范围的滚动进度
* @property {boolean} syncSwiper 是否启用与swiper的同步联动
* @event {Function} change 选项卡切换时触发(返回激活索引)
* @event {Function} click 点击时触发(返回激活索引)
*/
import { TabsProps, TabPanel } from './type';
import { TabPanelProps } from '../l-tab-panel/type';
import { calcScrollOffset, ease, calculateTabPositions } from './utils';
import { clamp } from '@/uni_modules/lime-shared/clamp';
// import { getRect } from '@/uni_modules/lime-shared/getRect';
import { getRect, getAllRect } from '@/uni_modules/lime-shared/getRect'
import { useTouch } from './touch'
const emits = defineEmits(['change', 'click', 'total-width'])
// const slots = defineSlots<{
// label : {
// item : TabPanel,
// active : boolean,
// disabled : boolean,
// }
// }>()
const slots = defineSlots<{
label(props : { item : TabPanel, active : boolean, disabled : boolean }) : any,
default(props : {}) : any,
left(props : {}) : any,
right(props : {}) : any,
}>()
const props = withDefaults(defineProps<TabsProps>(), {
showLine: true,
visible: false,
ellipsis: false,
size: 'medium',
spaceEvenly: true,
swipeable: false,
duration: 0.3,
animated: false,
split: true,
swiperProgress: 0,
syncSwiper: false
})
const children = ref<LTabPanelComponentPublicInstance[]>([])
const scrollLeft = ref(0);
const lastLeft = ref(0);
// #ifndef WEB || APP
const context = getCurrentInstance().proxy!;
const trackDistance = ref(0)
const innerStyle = reactive({
width: '',
'transition-duration': ``,
'transform': ``,
})
// #endif
// #ifdef WEB || APP
const innerStyle = {
}
// #endif
const modelValue = defineModel({ type: Number })
const currentValue = computed({
set(value : number) {
modelValue.value = value;
emits('change', value)
},
get() : number {
return props.value ?? modelValue.value
}
} as WritableComputedOptions<number>);
const styles = computed(() : Map<string, any> => {
const style = new Map<string, any>();
// #ifdef APP
if (props.bgColor != null) {
style.set('background-color', props.bgColor!)
}
// #endif
// #ifndef APP
if (props.bgColor != null) {
style.set('--l-tab-nav-bg-color', props.bgColor!)
}
if (!['medium', 'large'].includes(props.size)) {
style.set('--l-tab-item-height', props.size)
}
if (props.padding != null) {
style.set('--l-tab-item-padding', props.padding!)
}
if (props.fontSize != null) {
style.set('--l-tab-font-size', props.fontSize!)
}
// #endif
return style
})
const trackStyle = computed(() : Map<string, any> => {
const style = new Map<string, any>([
// #ifndef APP || WEB
['-webkit-transform', `translateX(${trackDistance.value}px)`],
['transform', `translateX(${trackDistance.value}px)`],
// #endif
]);
if (props.lineColor != null) {
style.set('background', props.lineColor!)
}
if (props.lineWidth != null) {
style.set('width', props.lineWidth!)
}
if (props.lineHeight != null) {
style.set('height', props.lineHeight!)
}
return style
})
const itemStyle = computed(() : Map<string, any> => {
const style = new Map<string, any>()
// #ifdef APP
if (!['medium', 'large'].includes(props.size)) {
style.set('height', props.size)
}
if (props.padding != null) {
style.set('padding', props.padding!)
}
// #endif
return style
})
const textStyles = computed(() : Map<string, any> => {
const style = new Map<string, any>()
// #ifdef APP
if (props.fontSize != null) {
style.set('font-size', props.fontSize!)
}
// #endif
return style
})
const tabs = computed(() : TabPanel[] => {
if (props.list != null && props.list!.length > 0) {
return props.list!.map((item) : TabPanel => {
return {
badge: item.get('badge'),
dot: item.getBoolean('dot') ?? false,
disabled: item.getBoolean('disabled') ?? false,
label: item.getString('label'),
offset: item.getArray('offset') as any[] | null,
value: item.getNumber('value'),
node: item
} as TabPanel
})
}
return children.value.map((item) : TabPanel => {
// 安卓端数组属性存在BUG 死循环
// #ifdef APP-ANDROID
const offset = item.innderOffset
// #endif
// #ifndef APP-ANDROID
const offset = item.offset ?? []
// #endif
return {
badge: item.badge,
dot: item.dot ?? false,
disabled: item.disabled ?? false,
label: item.label,
offset: offset,
value: item.value,
node: {
badge: item.badge,
dot: item.dot ?? false,
disabled: item.disabled ?? false,
label: item.label,
offset: offset,
value: item.value,
}
} as TabPanel
})
})
const currentIndex = computed(() : number => {
const index = tabs.value.findIndex((child, index) : boolean => (child.value ?? index) == currentValue.value)
return index > -1 ? index : 0;
})
const scrollRef = ref<UniScrollViewElement | null>(null)
const trackRef = ref<UniElement | null>(null)
const navRef = ref<UniElement | null>(null)
const innerRef = ref<UniElement | null>(null)
const tabRects = ref<DOMRect[]>([]) // 存储每个tab的尺寸位置
const containerWidth = ref(0) // 容器宽度
const trackLineWidth = ref(0)
const isInteracting = ref(false) // 是否正在交互
let timer = 0
const totalTabsWidth = computed((): number => {
if (tabRects.value.length == 0) return 0
return tabRects.value.reduce((sum: number, rect: DOMRect):number => sum + rect.width, 0)
})
const measureTabs = () : Promise<void> => {
return new Promise((resolve) => {
// nextTick(() => {
if(tabRects.value.length > 0 && tabRects.value[0].width > 0) {
resolve()
}
// #ifdef APP || WEB
if (scrollRef.value == null) { // navRef.value == null ||
resolve()
return
};
// #ifdef WEB
const elements = Array.from(navRef.value!.children)
// #endif
// #ifndef WEB
const elements = scrollRef.value!.children;
// #endif
const tabsRects:DOMRect[] = []
elements.forEach((el)=>{
if(el.tagName == 'VIEW') {
tabsRects.push(el.getBoundingClientRect())
}
})
tabRects.value = tabsRects
containerWidth.value = scrollRef.value?.offsetWidth ?? 0
trackLineWidth.value = trackRef.value?.offsetWidth ?? 0
resolve()
// #endif
// #ifndef APP || WEB
Promise.all([
getRect(`.l-tabs__scroll`, context),
getRect(`.l-tabs__track`, context),
getAllRect(`.l-tabs__item`, context)
]).then(([scrollRect, trackRect, tabsRects]) => {
containerWidth.value = scrollRect.width
tabRects.value = tabsRects
trackLineWidth.value = trackRect.width
resolve()
})
// #endif
})
// })
}
const moveToActiveTab = () => {
measureTabs().then(() => {
const index = currentIndex.value
if (tabRects.value.length <= index) return
const tabRect = tabRects.value[index]
let offset = (tabRect.width - trackLineWidth.value) / 2
let [distance] = calculateTabPositions(tabRects.value, index)
if (totalTabsWidth.value == 0) return
// #ifdef APP || WEB
trackRef.value?.style.setProperty('transform', `translateX(${distance + offset}px)`)
// #endif
// #ifndef APP || WEB
trackDistance.value = distance + offset
// #endif
// 计算scroll-view需要滚动的距离
const scrollOffset = calcScrollOffset(
containerWidth.value,
tabRect.left,
tabRect.width,
lastLeft.value
)
const maxOffset = totalTabsWidth.value - containerWidth.value;
scrollLeft.value = clamp(scrollOffset, 0, maxOffset)
// isInteracting.value = false
})
}
const updateInnerStyle = (offset : number) => {
nextTick(() => {
// #ifdef WEB || APP
if (innerRef.value == null) return;
const width = innerRef.value!.parentElement?.offsetWidth ?? 0;
innerRef.value!.style.setProperty('width', `${tabs.value.length * width}px`)
innerRef.value!.style.setProperty('opacity', `1`)
const left = -width * currentIndex.value + offset;
if (offset != 0) {
innerRef.value!.style.setProperty('transition-duration', `0s`)
innerRef.value!.style.setProperty('transform', `translateX(${left}px)`)
} else {
if (props.animated) {
innerRef.value!.style.setProperty('transition-duration', offset != 0 || !props.animated ? '0s' : `${props.duration}s`)
}
nextTick(() => {
innerRef.value!.style.setProperty('transform', `translateX(${left}px)`)
})
}
// #endif
// #ifndef WEB || APP
// const width = innerRef.value!.parentElement?.offsetWidth ?? 0;
Promise.all(
[
getRect('.l-tabs__content', context),
getRect('.l-tabs__content-inner', context)
]).then(([parent, inner]) => {
const left = -parent.width * currentIndex.value + offset;
if (offset != 0) {
innerStyle.transform = `translateX(${left}px)`
innerStyle['transition-duration'] = `0s`
} else {
if (props.animated) {
innerStyle['transition-duration'] = offset != 0 || !props.animated ? '0s' : `${props.duration}s`
}
innerStyle.transform = `translateX(${left}px)`
}
}).catch(err => {
})
// #endif
})
}
const onScroll = (e : UniScrollEvent) => {
lastLeft.value = e.detail.scrollLeft;
}
const updateDuration = (duration: number) => {
// #ifdef APP || WEB
trackRef.value?.style.setProperty('transition-duration', `${duration}s`)
// #endif
// #ifndef APP || WEB
innerStyle['transition-duration'] = `${duration}s`
// #endif
}
const onClick = (index : number, item : TabPanel) => {
isInteracting.value = true
const { value = index, disabled, label } = item;
if (disabled || currentValue.value == value) return
currentValue.value = value;
emits('click', value)
updateDuration(0.3)
nextTick(()=> {
moveToActiveTab()
})
clearTimeout(timer)
timer = setTimeout(()=>{
isInteracting.value = false
},500)
}
const getAvailableTabIndex = (deltaX : number) : number => {
const step = deltaX > 0 ? -1 : 1;
const len = tabs.value.length;
for (let i = step; currentIndex.value + step >= 0 && currentIndex.value + step < len; i += step) {
const newIndex = currentIndex.value + i;
if (newIndex >= 0 && newIndex < len && tabs.value.length > newIndex && !tabs.value[newIndex].disabled) {
return newIndex;
}
}
return -1;
}
const touch = useTouch();
const onTouchStart = (event : UniTouchEvent) => {
isInteracting.value = true
if (!props.swipeable) return;
touch.start(event);
updateDuration(0.3)
}
const onTouchMove = (event : UniTouchEvent) => {
if (!props.swipeable) return;
touch.move(event);
const { direction, deltaX, startX } = touch
if (direction.value != 'horizontal') return
if (!props.animated) return
const isAtFirstTab = currentIndex.value == 0;
const isAtLastTab = currentIndex.value == tabs.value.length - 1;
if ((isAtFirstTab && deltaX.value > 0) || (isAtLastTab && deltaX.value < 0)) {
const base = isAtFirstTab ? 1 : -1;
const adjustedDelta = ease(deltaX.value, base);
updateInnerStyle(adjustedDelta);
} else {
updateInnerStyle(deltaX.value);
}
}
const onTouchEnd = () => {
isInteracting.value = false
if (!props.swipeable) return;
const { direction, deltaX, offsetX } = touch
const minSwipeDistance = 50;
if (direction.value == 'horizontal' && offsetX.value >= minSwipeDistance) {
const index = getAvailableTabIndex(deltaX.value);
if (index != -1) {
onClick(index, tabs.value[index])
}
}
updateInnerStyle(0)
}
const stopWatch = watch(tabs, (_v : TabPanel[]) => {
// 鸿蒙增加项时渲染比较慢 需要延时一下
setTimeout(() => {
moveToActiveTab()
}, 50)
})
const stopValueWatch = watch(currentValue, (_v : number) => {
if (tabs.value.length == 0) return
moveToActiveTab()
updateInnerStyle(0)
})
const stopVisibleWatch = watch(() : boolean => props.visible, (v : boolean) => {
if (!v) return
setTimeout(() => {
moveToActiveTab()
updateInnerStyle(0)
}, 100)
})
const updateTrackPosition = (progress: number)=>{
if (!props.syncSwiper || !props.showLine || progress == 0 || isInteracting.value) return
updateDuration(0)
const currentIdx = currentIndex.value
const next = ()=> {
const direction = progress > 0 ? 1 : -1
const nextIdx = currentIdx + direction
// 边界检查
if (nextIdx < 0 || nextIdx >= tabRects.value.length ) return
// 计算插值比例 (0~1)
const ratio = Math.abs(progress)
let currentOffset = (tabRects.value[currentIdx].width - trackLineWidth.value) / 2
let nextOffset = (tabRects.value[nextIdx].width - trackLineWidth.value) / 2
let [currentPos, nextPos] = calculateTabPositions(tabRects.value, currentIdx, nextIdx)
currentPos = currentPos + currentOffset
nextPos = nextPos + nextOffset
// 根据滚动方向计算最终位置
const newPosition = direction > 0
? currentPos + (nextPos - currentPos) * ratio
: currentPos - (currentPos - nextPos) * ratio
// #ifdef APP || WEB
nextTick(()=>{
requestAnimationFrame(()=>{
trackRef.value?.style.setProperty('transform', `translateX(${newPosition}px)`)
})
})
// #endif
// #ifndef APP || WEB
trackDistance.value = newPosition
// #endif
}
// requestAnimationFrame(()=>{
measureTabs().then(next)
// })
}
// 监听swiper进度变化
watch(():number => props.swiperProgress, (progress: number) => {
updateTrackPosition(progress)
})
// 添加监听器当总宽度变化时emit事件
watch(totalTabsWidth, (newWidth: number, oldWidth: number) => {
if (newWidth != oldWidth && newWidth > 0) {
emits('total-width', newWidth)
}
})
onMounted(() => {
// 鸿蒙时间上不确定
nextTick(() => {
setTimeout(() => {
if (tabs.value.length == 0) return
moveToActiveTab()
updateInnerStyle(0)
}, 100)
})
})
onUnmounted(() => {
stopWatch()
stopValueWatch()
stopVisibleWatch()
})
provide('LimeTabs', children)
</script>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,347 @@
<template>
<view class="l-tabs">
<view class="l-tabs__wrap" :style="[styles, lStyle]">
<slot name="left"></slot>
<scroll-view class="l-tabs__scroll" :class="{'l-tabs__scroll--split': split}" enhanced enable-flex
:scroll-left="scrollLeft" scroll-x scroll-anchoring scroll-with-animation enable-passive
:show-scrollbar="false" @scroll="onScroll">
<view class="l-tabs__nav">
<view class="l-tabs__item"
:style="{color: !item.disabled && (index == currentIndex ? activeColor : color)}" :class="[
'l-tabs__item-text--' + size,
{
'l-tabs__item--active': index == currentIndex,
'l-tabs__item--evenly': spaceEvenly,
'l-tabs__item--disabled': item.disabled
}
]" v-for="(item, index) in tabs" :key="index" @tap="onClick(index, item)" aria-role="tab">
<slot name="label" :item="item" :active="index == currentIndex" :disabled="item.disabled">
<l-badge v-if="item.dot == true || item.badge" :dot="item.dot" :offset="item.offset"
:content="item.badge">
<view class="l-tabs__item-text l-ellipsis">
{{item.label}}
</view>
</l-badge>
<view v-else class="l-tabs__item-text l-ellipsis">
{{item.label}}
</view>
</slot>
</view>
<view class="l-tabs__track" :style="[trackStyle]"></view>
</view>
</scroll-view>
<slot name="right"></slot>
</view>
<view class="l-tabs__content" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd"
@touchcancel="onTouchEnd" :class="{'l-tabs__content--animated': animated}" v-if="$slots['default']">
<view class="l-tabs__content-inner" :style="[innerStyle]" ref="innerRef">
<slot></slot>
</view>
</view>
</view>
</template>
<script lang="ts">
// @ts-nocheck
/**
* Tabs 标签页组件
* @description 用于内容分类展示和切换,支持多种样式和交互效果
* <br>插件类型LTabsComponentPublicInstance
* @tutorial https://ext.dcloud.net.cn/plugin?name=lime-tabs
*
* @property {TabPanel[]} list 选项卡配置数组
* @property {boolean} ellipsis 是否省略超长文本默认true
* @property {boolean} animated 是否启用切换动画默认false
* @property {number} duration 动画时长ms默认300
* @property {boolean} showLine 显示底部激活线条默认true
* @property {'medium' | 'large'} size 组件尺寸(默认:"medium"
* @value medium
* @value large
* @property {boolean} spaceEvenly 均分选项卡宽度默认false
* @property {boolean} swipeable 支持滑动切换默认true
* @property {number} value 当前激活选项卡索引支持v-model
* @property {string} color 默认文字颜色(默认:"#333"
* @property {string} activeColor 激活状态颜色(默认:主题色)
* @property {string} lineColor 激活线条颜色默认跟随activeColor
* @property {string} lineWidth 激活线条宽度(默认:"20px"
* @property {string} lineHeight 激活线条高度(默认:"3px"
* @property {string} bgColor 背景颜色
* @property {string} fontSize 文字大小支持CSS单位
* @property {string} padding 内边距支持CSS简写
* @property {boolean} split 显示分隔线默认false
* @property {boolean} visible 是否显示组件默认true
* @event {Function} change 选项卡切换时触发(返回激活索引)
* @event {Function} click 点击时触发(返回激活索引)
*/
import { defineComponent, ref, provide, reactive, getCurrentInstance, watch, watchEffect, computed, type ComputedRef, onUnmounted, nextTick, onMounted } from '@/uni_modules/lime-shared/vue';
import tabsProps from './props'
import { TabPanel } from './type';
import { getRect, getAllRect } from '@/uni_modules/lime-shared/getRect'
import { clamp } from '@/uni_modules/lime-shared/clamp'
import { calcScrollOffset, ease, calculateTabPositions } from './utils'
import { useTouch } from './touch';
// import { onReady } from '@dcloudio/uni-app'
export default defineComponent({
name: 'l-tabs',
props: tabsProps,
options: {
addGlobalClass: true,
virtualHost: true,
},
emits: ['update:modelValue', 'change', 'input', 'click', 'total-width'],
setup(props, { emit }) {
const context = getCurrentInstance().proxy!
const scrollLeft = ref(0)
const lastLeft = ref(0)
const trackDistance = ref(0)
const children = ref([])
const innerStyle = reactive({
width: '',
'transition-duration': ``,
'transform': ``,
})
const defaultValue = ref(props.value ?? props.modelValue ?? 0)
const currentValue = computed({
set(value : number) {
defaultValue.value = value
emit('update:modelValue', value)
emit('change', value)
// #ifdef VUE2
emit('input', value)
// #endif
},
get() : number {
return props.value ?? props.modelValue ?? defaultValue.value
}
} as WritableComputedOptions<number>);
const styles = computed(() => {
const style : Record<string, any> = {};
if (props.bgColor) {
style['--l-tab-nav-bg-color'] = props.bgColor
}
if (!['medium', 'large'].includes(props.size)) {
style['--l-tab-item-height'] = props.size
}
if (props.padding) {
style['--l-tab-item-padding'] = props.padding
}
if (props.fontSize) {
style['--l-tab-font-size'] = props.fontSize
}
return style
})
const trackStyle = computed(() : Map<string, any> => {
const style : Record<string, any> = {
'-webkit-transform': `translateX(${trackDistance.value}px)`,
'transform': `translateX(${trackDistance.value}px)`,
};
if (props.lineColor) {
style['background'] = props.lineColor!
}
if (props.lineWidth) {
style['width'] = props.lineWidth!
}
if (props.lineHeight) {
style['height'] = props.lineHeight!
}
return style
})
const tabs : ComputedRef<TabPanel[]> = computed(() => {
if (props.list) return props.list as TabPanel[]
return children.value
})
const currentIndex = computed(() => {
const index = tabs.value.findIndex((child, index) => (child.value || index) == currentValue.value)
if (index >= 0) return index
return 0
})
const tabRects = ref<DOMRect[]>([]) // 存储每个tab的尺寸位置
const totalTabsWidth = computed(() : number => {
if (tabRects.value.length == 0) return 0
return tabRects.value.reduce((sum : number, rect : DOMRect) : number => sum + rect.width, 0)
})
const moveToActiveTab = () => {
nextTick(() => {
try {
Promise.all([
getRect(`.l-tabs__scroll`, context),
getRect(`.l-tabs__track`, context),
getAllRect(`.l-tabs__item`, context)
]).then(([scrollRect, trackRect, tabsRects]) => {
const index = currentIndex.value
const tabRect = tabsRects[index];
tabRects.value = tabsRects
if (!tabRect) return;
let [distance] = calculateTabPositions(tabsRects, index)
if (totalTabsWidth.value == 0) return
const offset = calcScrollOffset(scrollRect.width, tabRect.left, tabRect.width, lastLeft.value);
const maxOffset = totalTabsWidth.value - scrollRect.width;
scrollLeft.value = clamp(offset, 0, maxOffset)
// distance += (tabRect.width - trackRect.width) / 2;
trackDistance.value = distance + (tabRect.width - trackRect.width) / 2;
})
} catch (e) {
console.error(e)
}
})
}
const updateInnerStyle = (offset : number) => {
if (props.list && props.list.length > 0) return
nextTick(() => {
Promise.all(
[
getRect('.l-tabs__content', context),
getRect('.l-tabs__content-inner', context)
]).then(([parent, inner]) => {
const left = -parent.width * currentIndex.value + offset;
if (offset != 0) {
innerStyle.transform = `translateX(${left}px)`
innerStyle['transition-duration'] = `0s`
} else {
if (props.animated) {
innerStyle['transition-duration'] = offset != 0 || !props.animated ? '0s' : `${props.duration}s`
}
innerStyle.transform = `translateX(${left}px)`
}
})
})
}
const onScroll = (e) => {
lastLeft.value = e.detail.scrollLeft
}
const onClick = (index : number, item : TabPanel) => {
const { value = index, disabled, label } = item;
if (disabled || currentValue.value === value) { return }
currentValue.value = value
emit('click', value)
moveToActiveTab()
}
const getAvailableTabIndex = (deltaX : number) : number => {
const step = deltaX > 0 ? -1 : 1;
const len = tabs.value.length;
for (let i = step; currentIndex.value + step >= 0 && currentIndex.value + step < len; i += step) {
const newIndex = currentIndex.value + i;
if (newIndex >= 0 && newIndex < len && tabs.value.length > newIndex && !tabs.value[newIndex].disabled) {
return newIndex;
}
}
return -1;
}
const touch = useTouch();
const onTouchStart = (event : UniTouchEvent) => {
if (!props.swipeable) return;
touch.start(event);
}
const onTouchMove = (event : UniTouchEvent) => {
if (!props.swipeable) return;
touch.move(event);
const { direction, deltaX, startX } = touch
if (direction.value != 'horizontal') return
if (!props.animated) return
const isAtFirstTab = currentIndex.value == 0;
const isAtLastTab = currentIndex.value == tabs.value.length - 1;
if ((isAtFirstTab && deltaX.value > 0) || (isAtLastTab && deltaX.value < 0)) {
const base = isAtFirstTab ? 1 : -1;
const adjustedDelta = ease(deltaX.value, base);
updateInnerStyle(adjustedDelta);
} else {
updateInnerStyle(deltaX.value);
}
}
const onTouchEnd = () => {
if (!props.swipeable) return;
const { direction, deltaX, offsetX } = touch
const minSwipeDistance = 50;
if (direction.value == 'horizontal' && offsetX.value >= minSwipeDistance) {
const index = getAvailableTabIndex(deltaX.value);
if (index != -1) {
onClick(index, tabs.value[index])
}
}
updateInnerStyle(0)
}
watchEffect(() => {
// console.log('tabs', tabs.value)
if (tabs.value.length == 0) return
moveToActiveTab()
})
// const stopWatch = watch(tabs, (v) => {
// moveToActiveTab()
// })
const stopValueWatch = watch(currentValue, (_v : number) => {
moveToActiveTab()
updateInnerStyle(0)
})
const stopVisibleWatch = watch(() => props.visible, (v) => {
if (!v) return
setTimeout(() => {
moveToActiveTab()
updateInnerStyle(0)
}, 100)
})
// 添加监听器当总宽度变化时emit事件
watch(totalTabsWidth, (newWidth : number, oldWidth : number) => {
if (newWidth !== oldWidth && newWidth > 0) {
emit('total-width', newWidth)
}
})
onMounted(() => {
nextTick(() => {
setTimeout(() => {
if (tabs.value.length == 0) return
moveToActiveTab()
updateInnerStyle(0)
}, 100)
})
})
onUnmounted(() => {
// stopWatch()
stopVisibleWatch()
stopValueWatch()
})
provide('LimeTabs', children)
return {
styles,
tabs,
scrollLeft,
currentValue,
currentIndex,
trackStyle,
innerStyle,
onScroll,
onClick,
onTouchStart,
onTouchMove,
onTouchEnd
}
}
})
</script>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,90 @@
export default {
list: {
type: Array,
},
ellipsis: {
type: Boolean,
default: false
},
animated: {
type: Boolean,
default: false
},
duration: {
type: [Number, String],
default: 0.3,
},
showLine: {
type: Boolean,
default: true,
},
size: {
type: String,
default: 'medium' // large
},
/** 选项卡头部空间是否均分 */
spaceEvenly: {
type: Boolean,
default: true,
},
split: {
type: Boolean,
default: true,
},
/** 是否可以滑动切换 */
swipeable: {
type: Boolean,
default: true,
},
/** 激活的选项卡值 */
value: {
type: [String, Number],
default: undefined,
},
modelValue: {
type: [String, Number],
default: undefined,
},
color: {
type: String,
default: null
},
activeColor: {
type: String,
default: null
},
lineColor: {
type: String,
default: null
},
lineWidth: {
type: String,
default: null
},
lineHeight: {
type: String,
default: null
},
bgColor: {
type: String,
default: null
},
fontSize: {
type: String,
default: null
},
padding: {
type: String,
default: null
},
/**
* 由于小程序popup会一开始就渲染导致下划线位置不对所以增加这个值
*/
visible: {
type: Boolean,
default: false
},
lStyle: {
type: [String, Object],
}
}

View File

@@ -0,0 +1,102 @@
// @ts-nocheck
// #ifndef UNI-APP-X
import {type Ref, ref} from '@/uni_modules/lime-shared/vue'
type UniTouchEvent = TouchEvent
// #endif
type Direction = '' | 'vertical' | 'horizontal';
function getDirection(x : number, y : number) : Direction {
if (x > y) {
return 'horizontal';
}
if (y > x) {
return 'vertical';
}
return '';
}
type TouchEventHandler = (event : UniTouchEvent) => void
type BooleanFunction = () => boolean;
type UseTouchResult = {
start : TouchEventHandler,
move : TouchEventHandler,
startX : Ref<number>,
startY : Ref<number>,
deltaX : Ref<number>,
deltaY : Ref<number>,
offsetX : Ref<number>,
offsetY : Ref<number>,
direction : Ref<Direction>,
isVertical : BooleanFunction,
isHorizontal : BooleanFunction,
isTap : Ref<Boolean>,
}
export function useTouch() : UseTouchResult {
const startX = ref<number>(0);
const startY = ref<number>(0);
const deltaX = ref<number>(0);
const deltaY = ref<number>(0);
const offsetX = ref<number>(0);
const offsetY = ref<number>(0);
const direction = ref<Direction>('');
const isTap = ref(true);
const isVertical = () : boolean => direction.value === 'vertical';
const isHorizontal = () : boolean => direction.value === 'horizontal';
const reset = () => {
deltaX.value = 0;
deltaY.value = 0;
offsetX.value = 0;
offsetY.value = 0;
direction.value = '';
isTap.value = true;
};
const start = (event : UniTouchEvent) => {
reset();
startX.value = event.touches[0].clientX;
startY.value = event.touches[0].clientY;
}
const move = (event : UniTouchEvent) => {
const touch = event.touches[0];
// safari back will set clientX to negative number
deltaX.value = (touch.clientX < 0 ? 0 : touch.clientX) - startX.value;
deltaY.value = touch.clientY - startY.value;
offsetX.value = Math.abs(deltaX.value);
offsetY.value = Math.abs(deltaY.value);
// lock direction when distance is greater than a certain value
const LOCK_DIRECTION_DISTANCE = 10;
const TAP_OFFSET = 5;
if (
direction.value == '' ||
(offsetX.value < LOCK_DIRECTION_DISTANCE &&
offsetY.value < LOCK_DIRECTION_DISTANCE)
) {
direction.value = getDirection(offsetX.value, offsetY.value);
}
if (
isTap.value &&
(offsetX.value > TAP_OFFSET || offsetY.value > TAP_OFFSET)
) {
isTap.value = false;
}
}
return {
start,
move,
startX,
startY,
deltaX,
deltaY,
offsetX,
offsetY,
direction,
isVertical,
isHorizontal,
isTap,
} as UseTouchResult
}

View File

@@ -0,0 +1,86 @@
// @ts-nocheck
export type TabPanel = {
/**
* 透传至 Badge 组件
*/
badge ?: any;
dot ?: boolean;
offset ?: any[];
/**
* 选项卡内容隐藏时是否销毁
*/
destroyOnHide ?: boolean;
/**
* 是否禁用当前选项卡
*/
disabled : boolean;
/**
* 选项卡名称,可自定义选项卡导航内容
*/
label ?: string;
/**
* 是否启用选项卡懒加载
*/
lazy ?: boolean;
/**
* 选项卡的值,唯一标识
*/
value ?: number;
ccs?: UTSJSONObject
node: UTSJSONObject
}
export interface TabsProps {
/**
* 选项卡列表
*/
list ?: UTSJSONObject[];
ellipsis : boolean;
/**
* 动画效果设置
*/
animated : boolean;
duration : number
/**
* 是否展示底部激活线条
*/
showLine : boolean;
/**
* 组件尺寸
*/
size : 'medium' | 'large';
/**
* 选项卡头部空间是否均分
*/
spaceEvenly : boolean;
/**
* 是否可以滑动切换
*/
swipeable : boolean;
/**
* 激活的选项卡值
*/
value?: number;
color?: string;
activeColor ?: string;
lineColor ?: string;
lineWidth ?: string;
lineHeight ?: string;
bgColor ?: string;
fontSize ?: string;
padding ?: string;
split: boolean;
visible: boolean;
/**
* 和swiper联动 暂时未实现
*/
swiperProgress : number
syncSwiper: boolean
lStyle ?: string | UTSJSONObject
}

View File

@@ -0,0 +1,73 @@
// @ts-nocheck
/**
* 计算偏移
* @param {number} containerWidth 容器宽度
* @param {number} targetLeft 当前元素left值
* @param {number} targetWidth 当前元素宽度
* @param {number} offset 上一次 scroll-view left值
* @return
*/
export function calcScrollOffset(containerWidth : number, targetLeft : number, targetWidth : number, offset : number) : number {
return offset + targetLeft - (1 / 2) * containerWidth + targetWidth / 2;
}
/**
* 根据提供的移动距离和基数进行缓动处理。
* 如果移动距离的绝对值超过最大阈值,则应用缓动效果。
*
* @param {number} moveX - 水平移动的距离。
* @param {number} base - 应用于缓动效果的基数。
* @returns {number} 缓动处理后的移动距离。
*/
export function ease(moveX : number, base : number) : number{
const absDistance = Math.abs(moveX);
const max = 50
if (absDistance > max) {
return (max + (absDistance - max) * 0.2) * base;
}
return moveX;
};
// export function calculateTabDistances(tabRects: DOMRect[], index: number, offset: number = 0): number[] {
// let count = 0;
// let distance = 0;
// let totalSize = 0;
// tabRects.forEach((item) => {
// if (count < index) {
// distance += item.width;
// count += 1;
// }
// totalSize += item.width;
// });
// return [distance + offset, totalSize]
// }
// 高效版本,只遍历一次到最大索引
export function calculateTabPositions(
tabRects: DOMRect[],
currentIdx: number,
nextIdx: number = 0
): number[] {
let currentDistance = 0;
let nextDistance = 0;
const maxIndex = Math.max(currentIdx, nextIdx);
// 只遍历到最大索引位置
for (let i = 0; i < maxIndex && i < tabRects.length; i++) {
if (i < currentIdx) {
currentDistance += tabRects[i].width;
}
if (i < nextIdx) {
nextDistance += tabRects[i].width;
}
}
return [
currentDistance,
nextDistance
];
}