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,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>