小程序初始提交

This commit is contained in:
jdc
2025-11-13 10:36:23 +08:00
parent f26b4f9a2f
commit 5db3b180eb
447 changed files with 83351 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
<template>
<cl-popup
v-model="visible"
:show-close="false"
:swipe-close-threshold="50"
:pt="{
inner: {
className: parseClass([[isDark, '!bg-surface-700', '!bg-surface-100']])
}
}"
:mask-closable="config.maskClosable"
:title="config.title"
>
<view class="cl-action-sheet" :class="[pt.className]">
<slot name="prepend"></slot>
<view class="cl-action-sheet__description" v-if="config.description != ''">
<cl-text
:pt="{
className: 'text-surface-400 text-md text-center'
}"
>{{ config.description }}</cl-text
>
</view>
<view class="cl-action-sheet__list" :class="[pt.list?.className]">
<view
class="cl-action-sheet__item"
:class="[`${isDark ? '!bg-surface-800' : 'bg-white'}`, pt.item?.className]"
v-for="(item, index) in config.list"
:key="index"
:hover-class="`${isDark ? '!bg-surface-900' : '!bg-surface-50'}`"
:hover-stay-time="250"
@tap="onItemTap(item)"
>
<slot name="item" :item="item">
<cl-icon
:name="item.icon"
:pt="{
className: 'mr-2'
}"
:color="item.color"
v-if="item.icon != null"
></cl-icon>
<cl-text :color="item.color">{{ item.label }}</cl-text>
</slot>
</view>
</view>
<slot name="append"></slot>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from "vue";
import type { ClActionSheetItem, ClActionSheetOptions, PassThroughProps } from "../../types";
import { t } from "@/locale";
import { isDark, parseClass, parsePt } from "@/cool";
defineOptions({
name: "cl-action-sheet"
});
defineSlots<{
prepend(): any;
append(): any;
item(props: { item: ClActionSheetItem }): any;
}>();
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
}
});
// 透传样式类型定义
type PassThrough = {
className?: string; // 根元素类名
item?: PassThroughProps; // 列表项样式
list?: PassThroughProps; // 列表样式
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 控制弹窗显示状态
const visible = ref(false);
// 操作表配置数据
const config = reactive<ClActionSheetOptions>({
title: "", // 标题
list: [] // 操作列表
});
/**
* 关闭操作表
* 设置visible为false隐藏弹窗
*/
function close() {
visible.value = false;
}
/**
* 打开操作表
* @param options 操作表配置选项
*/
function open(options: ClActionSheetOptions) {
// 显示弹窗
visible.value = true;
// 更新标题
config.title = options.title;
// 更新描述
config.description = options.description ?? "";
// 更新操作列表
config.list = [...options.list] as ClActionSheetItem[];
// 取消按钮文本
config.cancelText = options.cancelText ?? t("取消");
// 是否显示取消按钮
config.showCancel = options.showCancel ?? true;
// 是否可以点击遮罩关闭
config.maskClosable = options.maskClosable ?? true;
// 如果需要显示取消按钮,添加到列表末尾
if (config.showCancel!) {
config.list.push({
label: config.cancelText!,
callback() {
close();
}
} as ClActionSheetItem);
}
}
/**
* 点击列表项事件处理
* @param item 被点击的操作项
*/
function onItemTap(item: ClActionSheetItem) {
// 如果存在回调函数则执行
if (item.callback != null) {
item.callback!();
}
}
// 暴露组件方法供外部调用
defineExpose({
open,
close
});
</script>
<style scoped lang="scss">
.cl-action-sheet {
&__description {
@apply flex flex-row items-center justify-center;
margin-bottom: 30rpx;
}
&__list {
padding: 0 20rpx;
}
&__item {
@apply flex flex-row items-center justify-center rounded-lg;
padding: 20rpx;
margin-bottom: 20rpx;
}
}
</style>

View File

@@ -0,0 +1,12 @@
import type { ClActionSheetItem, ClActionSheetOptions, PassThroughProps } from "../../types";
export type ClActionSheetPassThrough = {
className?: string;
item?: PassThroughProps;
list?: PassThroughProps;
};
export type ClActionSheetProps = {
className?: string;
pt?: ClActionSheetPassThrough;
};

View File

@@ -0,0 +1,67 @@
<template>
<cl-image
:src="src"
:height="size"
:width="size"
:pt="{
className: parseClass([
'cl-avatar',
{
'!rounded-full': rounded
},
pt.className
])
}"
>
<template #loading>
<cl-icon
:name="pt.icon?.name ?? 'user-smile-fill'"
:size="pt.icon?.size ?? 40"
:pt="{
className: parseClass([
[isDark, '!text-surface-50', '!text-surface-400'],
pt.icon?.className
])
}"
></cl-icon>
</template>
<slot></slot>
</cl-image>
</template>
<script setup lang="ts">
import { isDark, parseClass, parsePt } from "@/cool";
import { computed } from "vue";
import type { ClIconProps } from "../cl-icon/props";
defineOptions({
name: "cl-avatar"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
src: {
type: String,
default: ""
},
size: {
type: [String, Number],
default: 80
},
rounded: {
type: Boolean,
default: false
}
});
type PassThrough = {
className?: string;
icon?: ClIconProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
</script>

View File

@@ -0,0 +1,14 @@
import type { ClIconProps } from "../cl-icon/props";
export type ClAvatarPassThrough = {
className?: string;
icon?: ClIconProps;
};
export type ClAvatarProps = {
className?: string;
pt?: ClAvatarPassThrough;
src?: string;
size?: any;
rounded?: boolean;
};

View File

@@ -0,0 +1,112 @@
<template>
<view class="cl-back-top-wrapper" :style="{ bottom }" @tap="toTop">
<view
class="cl-back-top"
:class="{
'is-show': visible
}"
>
<cl-icon name="skip-up-line" color="white" size="25px"></cl-icon>
</view>
</view>
</template>
<script setup lang="ts">
import { getTabBarHeight, hasCustomTabBar, scroller } from "@/cool";
import { computed, onMounted, onUnmounted, ref, watch, type PropType } from "vue";
import { usePage } from "../../hooks";
import { clFooterOffset } from "../cl-footer/offset";
defineOptions({
name: "cl-back-top"
});
const props = defineProps({
top: {
type: Number as PropType<number | null>,
default: null
}
});
const emit = defineEmits(["backTop"]);
const { screenHeight } = uni.getWindowInfo();
// cl-page 上下文
const { scrollToTop, onScroll, offScroll } = usePage();
// 是否显示回到顶部按钮
const visible = ref(false);
// 底部距离
const bottom = computed(() => {
let h = 20;
if (hasCustomTabBar()) {
h += getTabBarHeight();
} else {
h += clFooterOffset.get();
}
return h + "px";
});
// 是否页面滚动
const isPage = computed(() => props.top == null);
// 控制是否显示
function onVisible(top: number) {
visible.value = top > screenHeight - 100;
}
// 回到顶部
function toTop() {
if (isPage.value) {
scrollToTop();
}
emit("backTop");
}
onMounted(() => {
if (isPage.value) {
// 监听页面滚动
onScroll(onVisible);
} else {
// 监听参数变化
watch(
computed(() => props.top!),
(top: number) => {
onVisible(top);
},
{
immediate: true
}
);
}
});
onUnmounted(() => {
if (isPage.value) {
offScroll(onVisible);
}
});
</script>
<style lang="scss" scoped>
.cl-back-top {
@apply flex flex-row items-center justify-center bg-primary-500 rounded-full duration-300;
width: 40px;
height: 40px;
transition-property: transform;
transform: translateX(160rpx);
&.is-show {
transform: translateX(-20px);
}
&-wrapper {
@apply fixed right-0 z-50 overflow-visible;
}
}
</style>

View File

@@ -0,0 +1,4 @@
export type ClBackTopProps = {
className?: string;
top?: number | any;
};

View File

@@ -0,0 +1,109 @@
<template>
<view
class="cl-badge"
:class="[
{
'bg-primary-500': type == 'primary',
'bg-green-500': type == 'success',
'bg-yellow-500': type == 'warn',
'bg-red-500': type == 'error',
'bg-surface-500': type == 'info',
'cl-badge--dot': dot,
'cl-badge--position': position
},
pt.className
]"
:style="badgeStyle"
>
<cl-text
:pt="{
className: parseClass(['cl-badge__text text-white text-xs', pt.text?.className])
}"
v-if="!dot"
>
{{ value }}
</cl-text>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { PropType } from "vue";
import type { PassThroughProps, Type } from "../../types";
import { parseClass, parsePt } from "@/cool";
import { useSize } from "../../hooks";
defineOptions({
name: "cl-badge"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
type: {
type: String as PropType<Type>,
default: "error"
},
dot: {
type: Boolean,
default: false
},
value: {
type: [Number, String],
default: 0
},
position: {
type: Boolean,
default: false
}
});
const { getRpx } = useSize();
type PassThrough = {
className?: string;
text?: PassThroughProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
const badgeStyle = computed(() => {
const style = {};
if (props.dot) {
style["height"] = getRpx(10);
style["width"] = getRpx(10);
style["minWidth"] = getRpx(10);
style["padding"] = 0;
} else {
style["height"] = getRpx(30);
style["minWidth"] = getRpx(30);
style["padding"] = `0 ${getRpx(6)}`;
}
if (props.position) {
style["transform"] = "translate(50%, -50%)";
if (props.dot) {
style["transform"] = `translate(-${getRpx(5)}, ${getRpx(5)})`;
}
}
return style;
});
</script>
<style lang="scss" scoped>
.cl-badge {
@apply flex flex-row items-center justify-center;
@apply rounded-full;
&--position {
@apply absolute z-10 right-0 top-0;
}
}
</style>

View File

@@ -0,0 +1,15 @@
import type { PassThroughProps, Type } from "../../types";
export type ClBadgePassThrough = {
className?: string;
text?: PassThroughProps;
};
export type ClBadgeProps = {
className?: string;
pt?: ClBadgePassThrough;
type?: Type;
dot?: boolean;
value?: any;
position?: boolean;
};

View File

@@ -0,0 +1,485 @@
<template>
<view
class="cl-banner"
:class="[pt.className]"
:style="{
height: parseRpx(height)
}"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view
class="cl-banner__list"
:style="{
transform: `translateX(${slideOffset}px)`,
transitionDuration: isAnimating ? '0.3s' : '0s'
}"
>
<view
class="cl-banner__item"
v-for="(item, index) in list"
:key="index"
:class="[
pt.item?.className,
`${item.isActive ? `${pt.itemActive?.className}` : ''}`
]"
:style="{
width: `${getSlideWidth(index)}px`
}"
@tap="handleSlideClick(index)"
>
<slot :item="item" :index="index">
<image
:src="item.url"
:mode="imageMode"
class="cl-banner__item-image"
:class="[pt.image?.className]"
></image>
</slot>
</view>
</view>
<view class="cl-banner__dots" :class="[pt.dots?.className]" v-if="showDots">
<view
class="cl-banner__dots-item"
v-for="(item, index) in list"
:key="index"
:class="[
{
'is-active': item.isActive
},
pt.dot?.className,
`${item.isActive ? `${pt.dotActive?.className}` : ''}`
]"
></view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, watch, getCurrentInstance, type PropType } from "vue";
import { parsePt, parseRpx } from "@/cool";
import type { PassThroughProps } from "../../types";
type Item = {
url: string;
isActive: boolean;
};
defineOptions({
name: "cl-banner"
});
defineSlots<{
item(props: { item: Item; index: number }): any;
}>();
const props = defineProps({
// 透传属性
pt: {
type: Object,
default: () => ({})
},
// 轮播项列表
list: {
type: Array as PropType<string[]>,
default: () => []
},
// 上一个轮播项的左边距
previousMargin: {
type: Number,
default: 0
},
// 下一个轮播项的右边距
nextMargin: {
type: Number,
default: 0
},
// 是否自动轮播
autoplay: {
type: Boolean,
default: true
},
// 自动轮播间隔时间(ms)
interval: {
type: Number,
default: 5000
},
// 是否显示指示器
showDots: {
type: Boolean,
default: true
},
// 是否禁用触摸
disableTouch: {
type: Boolean,
default: false
},
// 高度
height: {
type: [Number, String],
default: 300
},
// 图片模式
imageMode: {
type: String,
default: "aspectFill"
}
});
const emit = defineEmits(["change", "item-tap"]);
const { proxy } = getCurrentInstance()!;
// 透传属性类型定义
type PassThrough = {
className?: string;
item?: PassThroughProps;
itemActive?: PassThroughProps;
image?: PassThroughProps;
dots?: PassThroughProps;
dot?: PassThroughProps;
dotActive?: PassThroughProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
/** 当前激活的轮播项索引 */
const activeIndex = ref(0);
/** 轮播项列表 */
const list = computed<Item[]>(() => {
return props.list.map((e, i) => {
return {
url: e,
isActive: i == activeIndex.value
} as Item;
});
});
/** 轮播容器的水平偏移量(px) */
const slideOffset = ref(0);
/** 是否正在执行动画过渡 */
const isAnimating = ref(false);
/** 轮播容器的总宽度(px) */
const bannerWidth = ref(0);
/** 单个轮播项的宽度(px) - 用于缓存计算结果 */
const slideWidth = ref(0);
/** 触摸开始时的X坐标 */
const touchStartPoint = ref(0);
/** 触摸开始时的时间戳 */
const touchStartTimestamp = ref(0);
/** 触摸开始时的初始偏移量 */
const initialOffset = ref(0);
/** 是否正在触摸中 */
const isTouching = ref(false);
/** 位置更新防抖定时器 */
let positionUpdateTimer: number = 0;
/**
* 更新轮播容器的位置
* 根据当前激活索引计算并设置容器的偏移量
*/
function updateSlidePosition() {
if (bannerWidth.value == 0) return;
// 防抖处理,避免频繁更新
if (positionUpdateTimer != 0) {
clearTimeout(positionUpdateTimer);
}
// @ts-ignore
positionUpdateTimer = setTimeout(() => {
// 计算累积偏移量,考虑每个位置的动态边距
let totalOffset = 0;
// 遍历当前索引之前的所有项,累加它们的宽度
for (let i = 0; i < activeIndex.value; i++) {
const itemPreviousMargin = i == 0 ? 0 : props.previousMargin;
const itemNextMargin = i == props.list.length - 1 ? 0 : props.nextMargin;
const itemWidthAtIndex = bannerWidth.value - itemPreviousMargin - itemNextMargin;
totalOffset += itemWidthAtIndex;
}
// 当前项的左边距
const currentPreviousMargin = activeIndex.value == 0 ? 0 : props.previousMargin;
// 设置最终的偏移量:负方向移动累积宽度,然后加上当前项的左边距
slideOffset.value = -totalOffset + currentPreviousMargin;
positionUpdateTimer = 0;
}, 10);
}
/**
* 获取指定索引轮播项的宽度
* @param index 轮播项索引
* @returns 轮播项宽度(px)
*/
function getSlideWidth(index: number): number {
// 动态计算每个项的宽度,考虑边距
const itemPreviousMargin = index == 0 ? 0 : props.previousMargin;
const itemNextMargin = index == props.list.length - 1 ? 0 : props.nextMargin;
return bannerWidth.value - itemPreviousMargin - itemNextMargin;
}
/**
* 计算并缓存轮播项宽度
* 使用固定的基础宽度计算,避免动态变化导致的性能问题
*/
function calculateSlideWidth() {
const baseWidth = bannerWidth.value - props.previousMargin - props.nextMargin;
slideWidth.value = baseWidth;
}
/**
* 测量轮播容器的尺寸信息
* 获取容器宽度并初始化相关计算
*/
function getRect() {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-banner")
.boundingClientRect((node) => {
bannerWidth.value = (node as NodeInfo).width ?? 0;
// 重新计算宽度和位置
calculateSlideWidth();
updateSlidePosition();
})
.exec();
}
/** 自动轮播定时器 */
let autoplayTimer: number = 0;
/**
* 清除自动轮播定时器
*/
function clearAutoplay() {
if (autoplayTimer != 0) {
clearInterval(autoplayTimer);
autoplayTimer = 0;
}
}
/**
* 启动自动轮播
*/
function startAutoplay() {
if (props.list.length <= 1) return;
if (props.autoplay) {
clearAutoplay();
// 只有在非触摸状态下才启动自动轮播
if (!isTouching.value) {
isAnimating.value = true;
// @ts-ignore
autoplayTimer = setInterval(() => {
// 再次检查是否在触摸中,避免触摸时自动切换
if (!isTouching.value) {
if (activeIndex.value >= props.list.length - 1) {
activeIndex.value = 0;
} else {
activeIndex.value++;
}
}
}, props.interval);
}
}
}
// 触摸起始Y坐标
let touchStartY = 0;
// 横向滑动参数
let touchHorizontal = 0;
/**
* 处理触摸开始事件
* 记录触摸起始状态,准备手势识别
* @param e 触摸事件对象
*/
function onTouchStart(e: TouchEvent) {
// 如果禁用触摸,则不进行任何操作
if (props.disableTouch) return;
// 单项或空列表不支持滑动
if (props.list.length <= 1) return;
// 设置触摸状态
isTouching.value = true;
// 清除自动轮播
clearAutoplay();
// 禁用动画,开始手势跟踪
isAnimating.value = false;
touchStartPoint.value = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchHorizontal = 0;
touchStartTimestamp.value = Date.now();
initialOffset.value = slideOffset.value;
}
/**
* 处理触摸移动事件
* 实时更新容器位置,实现跟手效果
* @param e 触摸事件对象
*/
function onTouchMove(e: TouchEvent) {
if (props.list.length <= 1 || props.disableTouch || !isTouching.value) return;
const x = touchStartPoint.value - e.touches[0].clientX;
if (touchHorizontal == 0) {
// 只在horizontal=0时判断一次
const y = touchStartY - e.touches[0].clientY;
if (Math.abs(x) > Math.abs(y)) {
// 如果x轴移动距离大于y轴移动距离则表明是横向移动手势
touchHorizontal = 1;
}
if (touchHorizontal == 1) {
// 如果是横向移动手势,则阻止默认行为(防止页面滚动)
e.preventDefault();
}
}
// 横向移动时才处理
if (touchHorizontal != 1) {
return;
}
// 计算手指移动距离,实时更新偏移量
const deltaX = e.touches[0].clientX - touchStartPoint.value;
slideOffset.value = initialOffset.value + deltaX;
}
/**
* 处理触摸结束事件
* 根据滑动距离和速度判断是否切换轮播项
*/
function onTouchEnd() {
if (props.list.length <= 1 || !isTouching.value) return;
touchStartY = 0;
touchHorizontal = 0;
// 重置触摸状态
isTouching.value = false;
// 恢复动画效果
isAnimating.value = true;
// 计算滑动距离、时间和速度
const deltaX = slideOffset.value - initialOffset.value;
const deltaTime = Date.now() - touchStartTimestamp.value;
const velocity = deltaTime > 0 ? Math.abs(deltaX) / deltaTime : 0; // px/ms
let newIndex = activeIndex.value;
// 使用当前项的实际宽度进行滑动判断
const currentSlideWidth = getSlideWidth(activeIndex.value);
// 判断是否需要切换滑动距离超过30%或速度够快
if (Math.abs(deltaX) > currentSlideWidth * 0.3 || velocity > 0.3) {
// 向右滑动且不是第一项 -> 上一项
if (deltaX > 0 && activeIndex.value > 0) {
newIndex = activeIndex.value - 1;
}
// 向左滑动且不是最后一项 -> 下一项
else if (deltaX < 0 && activeIndex.value < props.list.length - 1) {
newIndex = activeIndex.value + 1;
}
}
// 更新索引 - 如果索引没有变化,需要手动恢复位置
if (newIndex == activeIndex.value) {
// 索引未变化,恢复到正确位置
updateSlidePosition();
} else {
// 索引变化watch会自动调用updateSlidePosition
activeIndex.value = newIndex;
}
// 恢复自动轮播
setTimeout(() => {
startAutoplay();
}, 300);
}
/**
* 处理轮播项点击事件
* @param index 被点击的轮播项索引
*/
function handleSlideClick(index: number) {
emit("item-tap", index);
}
/** 监听激活索引变化 */
watch(activeIndex, (val: number) => {
updateSlidePosition();
emit("change", val);
});
onMounted(() => {
getRect();
startAutoplay();
});
// 将触摸事件暴露给父组件支持控制其它view将做touch代理
defineExpose({
onTouchStart,
onTouchMove,
onTouchEnd
});
</script>
<style lang="scss" scoped>
.cl-banner {
@apply relative z-10 rounded-xl;
&__list {
@apply flex flex-row h-full w-full overflow-visible;
// HBuilderX 4.8.2 bug临时处理
width: 100000px;
transition-property: transform;
}
&__item {
@apply relative duration-200;
transition-property: transform;
&-image {
@apply w-full h-full rounded-xl;
}
}
&__dots {
@apply flex flex-row items-center justify-center;
@apply absolute bottom-3 left-0 w-full;
&-item {
@apply w-2 h-2 rounded-full mx-1 border border-solid border-surface-500;
background-color: rgba(255, 255, 255, 0.3);
transition-property: width, background-color;
transition-duration: 0.3s;
&.is-active {
@apply bg-white w-5;
}
}
}
}
</style>

View File

@@ -0,0 +1,25 @@
import type { PassThroughProps } from "../../types";
export type ClBannerPassThrough = {
className?: string;
item?: PassThroughProps;
itemActive?: PassThroughProps;
image?: PassThroughProps;
dots?: PassThroughProps;
dot?: PassThroughProps;
dotActive?: PassThroughProps;
};
export type ClBannerProps = {
className?: string;
pt?: ClBannerPassThrough;
list?: string[];
previousMargin?: number;
nextMargin?: number;
autoplay?: boolean;
interval?: number;
showDots?: boolean;
disableTouch?: boolean;
height?: any;
imageMode?: string;
};

View File

@@ -0,0 +1,673 @@
<template>
<view
class="cl-button"
:class="[
`cl-button--${size}`,
`cl-button--${type} `,
{
'cl-button--loading': loading,
'cl-button--disabled': disabled,
'cl-button--text': text,
'cl-button--border': border,
'cl-button--rounded': rounded,
'cl-button--icon': isIcon,
'cl-button--hover': isHover,
'is-dark': isDark
},
isHover ? hoverClass : '',
pt.className
]"
:key="cache.key"
:style="buttonStyle"
@tap.stop="onTap"
>
<button
class="cl-button__clicker"
:disabled="isDisabled"
:hover-class="hoverClass"
:hover-stop-propagation="hoverStopPropagation"
:hover-start-time="hoverStartTime"
:hover-stay-time="hoverStayTime"
:form-type="formType"
:open-type="openType"
:lang="lang"
:session-from="sessionFrom"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
:app-parameter="appParameter"
:group-id="groupId"
:guild-id="guildId"
:public-id="publicId"
:phone-number-no-quota-toast="phoneNumberNoQuotaToast"
:createliveactivity="createliveactivity"
@getuserinfo="onGetUserInfo"
@contact="onContact"
@getphonenumber="onGetPhoneNumber"
@error="onError"
@opensetting="onOpenSetting"
@launchapp="onLaunchApp"
@chooseavatar="onChooseAvatar"
@chooseaddress="onChooseAddress"
@chooseinvoicetitle="onChooseInvoiceTitle"
@addgroupapp="onAddGroupApp"
@subscribe="onSubscribe"
@login="onLogin"
@getrealtimephonenumber="onGetRealtimePhoneNumber"
@agreeprivacyauthorization="onAgreePrivacyAuthorization"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@touchcancel="onTouchCancel"
></button>
<cl-loading
:color="loadingIcon.color"
:size="loadingIcon.size"
:pt="{
className: parseClass(['mr-[10rpx]', pt.loading?.className])
}"
v-if="loading && !disabled"
></cl-loading>
<cl-icon
:name="icon"
:color="leftIcon.color"
:size="leftIcon.size"
:pt="{
className: parseClass([
{
'mr-[8rpx]': !isIcon
},
pt.icon?.className
])
}"
v-if="icon"
></cl-icon>
<template v-if="!isIcon">
<cl-text
:color="textColor"
:pt="{
className: parseClass([
'cl-button__label',
{
'text-sm': size == 'small'
},
pt.label?.className
])
}"
>
<slot></slot>
</cl-text>
<slot name="content"></slot>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref, useSlots, type PropType } from "vue";
import { get, isDark, parseClass, parsePt, useCache } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import type { ClButtonType, PassThroughProps, Size } from "../../types";
import type { ClLoadingProps } from "../cl-loading/props";
defineOptions({
name: "cl-button"
});
// 组件属性定义
const props = defineProps({
// 样式穿透
pt: {
type: Object,
default: () => ({})
},
// 按钮类型
type: {
type: String as PropType<ClButtonType>,
default: "primary"
},
// 字体、图标颜色
color: {
type: String,
default: ""
},
// 图标
icon: {
type: String,
default: ""
},
// 文本按钮
text: {
type: Boolean,
default: false
},
// 圆角按钮
rounded: {
type: Boolean,
default: false
},
// 边框按钮
border: {
type: Boolean,
default: false
},
// 加载状态
loading: {
type: Boolean,
default: false
},
// 禁用状态
disabled: {
type: Boolean,
default: false
},
// 按钮尺寸
size: {
type: String as PropType<Size>,
default: "normal"
},
// 按钮点击态样式类
hoverClass: {
type: String,
default: ""
},
// 是否阻止点击态冒泡
hoverStopPropagation: {
type: Boolean,
default: false
},
// 按住后多久出现点击态
hoverStartTime: {
type: Number,
default: 20
},
// 手指松开后点击态保留时间
hoverStayTime: {
type: Number,
default: 70
},
// 表单提交类型
formType: {
type: String as PropType<"submit" | "reset">,
default: ""
},
// 开放能力类型
openType: {
type: String as PropType<
| "agreePrivacyAuthorization"
| "feedback"
| "share"
| "getUserInfo"
| "contact"
| "getPhoneNumber"
| "launchApp"
| "openSetting"
| "chooseAvatar"
| "getAuthorize"
| "lifestyle"
| "contactShare"
| "openGroupProfile"
| "openGuildProfile"
| "openPublicProfile"
| "shareMessageToFriend"
| "addFriend"
| "addColorSign"
| "addGroupApp"
| "addToFavorites"
| "chooseAddress"
| "chooseInvoiceTitle"
| "login"
| "subscribe"
| "favorite"
| "watchLater"
| "openProfile"
| "liveActivity"
| "getRealtimePhoneNumber"
>,
default: ""
},
// 语言
lang: {
type: String as PropType<"en" | "zh_CN" | "zh_TW">,
default: "zh_CN"
},
// 会话来源
sessionFrom: {
type: String,
default: ""
},
// 会话标题
sendMessageTitle: {
type: String,
default: ""
},
// 会话路径
sendMessagePath: {
type: String,
default: ""
},
// 会话图片
sendMessageImg: {
type: String,
default: ""
},
// 显示会话卡片
showMessageCard: {
type: Boolean,
default: false
},
// 打开 APP 时,向 APP 传递的参数
appParameter: {
type: String,
default: ""
},
// 群ID
groupId: {
type: String,
default: ""
},
// 公会ID
guildId: {
type: String,
default: ""
},
// 公众号ID
publicId: {
type: String,
default: ""
},
// 手机号获取失败时是否弹出错误提示
phoneNumberNoQuotaToast: {
type: Boolean,
default: false
},
// 是否创建直播活动
createliveactivity: {
type: Boolean,
default: false
}
});
// 事件定义
const emit = defineEmits([
"click",
"tap",
"getuserinfo",
"contact",
"getphonenumber",
"error",
"opensetting",
"launchapp",
"chooseavatar",
"chooseaddress",
"chooseinvoicetitle",
"addgroupapp",
"subscribe",
"login",
"getrealtimephonenumber",
"agreeprivacyauthorization"
]);
const slots = useSlots();
const { cache } = useCache(() => [
props.type,
props.text,
props.disabled,
props.loading,
props.color
]);
// 样式穿透类型
type PassThrough = {
className?: string;
label?: PassThroughProps;
icon?: ClIconProps;
loading?: ClLoadingProps;
};
// 样式穿透计算
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 是否是图标按钮
const isIcon = computed(() => get(slots, "default") == null && get(slots, "content") == null);
// 文本颜色
const textColor = computed(() => {
if (props.color != "") {
return props.color;
}
let color = "light";
if (props.text) {
color = props.type;
if (props.disabled) {
color = "disabled";
}
}
if (props.type == "light") {
if (!isDark.value) {
color = "dark";
}
}
return color;
});
// 图标信息
const leftIcon = computed<ClIconProps>(() => {
let color = textColor.value;
let size: number | string;
switch (props.size) {
case "small":
size = 26;
break;
default:
size = 32;
break;
}
const ptIcon = pt.value.icon;
if (ptIcon != null) {
color = ptIcon.color ?? color;
size = ptIcon.size ?? size;
}
return {
size,
color
};
});
// 加载图标信息
const loadingIcon = computed<ClLoadingProps>(() => {
let color = textColor.value;
let size: number | string;
switch (props.size) {
case "small":
size = 22;
break;
default:
size = 24;
break;
}
const ptIcon = pt.value.loading;
if (ptIcon != null) {
color = ptIcon.color ?? color;
size = ptIcon.size ?? size;
}
return {
size,
color
};
});
// 按钮样式
const buttonStyle = computed(() => {
const style = {};
if (props.color != "") {
style["border-color"] = props.color;
}
return style;
});
// 是否禁用状态
const isDisabled = computed(() => props.disabled || props.loading);
// 点击事件处理
function onTap(e: UniPointerEvent) {
if (isDisabled.value) return;
emit("click", e);
emit("tap", e);
}
// 获取用户信息事件处理
function onGetUserInfo(e: UniEvent) {
emit("getuserinfo", e);
}
// 客服消息事件处理
function onContact(e: UniEvent) {
emit("contact", e);
}
// 获取手机号事件处理
function onGetPhoneNumber(e: UniEvent) {
emit("getphonenumber", e);
}
// 错误事件处理
function onError(e: UniEvent) {
emit("error", e);
}
// 打开设置事件处理
function onOpenSetting(e: UniEvent) {
emit("opensetting", e);
}
// 打开APP事件处理
function onLaunchApp(e: UniEvent) {
emit("launchapp", e);
}
// 选择头像事件处理
function onChooseAvatar(e: UniEvent) {
emit("chooseavatar", e);
}
// 选择收货地址事件处理
function onChooseAddress(e: UniEvent) {
emit("chooseaddress", e);
}
// 选择发票抬头事件处理
function onChooseInvoiceTitle(e: UniEvent) {
emit("chooseinvoicetitle", e);
}
// 添加群应用事件处理
function onAddGroupApp(e: UniEvent) {
emit("addgroupapp", e);
}
// 订阅消息事件处理
function onSubscribe(e: UniEvent) {
emit("subscribe", e);
}
// 登录事件处理
function onLogin(e: UniEvent) {
emit("login", e);
}
// 获取实时手机号事件处理
function onGetRealtimePhoneNumber(e: UniEvent) {
emit("getrealtimephonenumber", e);
}
// 同意隐私授权事件处理
function onAgreePrivacyAuthorization(e: UniEvent) {
emit("agreeprivacyauthorization", e);
}
// 点击态状态
const isHover = ref(false);
// 触摸开始事件处理
function onTouchStart() {
if (!isDisabled.value) {
isHover.value = true;
}
}
// 触摸结束事件处理
function onTouchEnd() {
isHover.value = false;
}
// 触摸取消事件处理
function onTouchCancel() {
isHover.value = false;
}
</script>
<style lang="scss" scoped>
@mixin button-type($color) {
@apply bg-#{$color}-500;
&.cl-button--hover {
@apply bg-#{$color}-600;
}
&.cl-button--text {
background-color: transparent;
&.cl-button--hover {
@apply bg-transparent opacity-50;
}
}
&.cl-button--border {
@apply border-#{$color}-500;
}
}
.cl-button {
@apply flex flex-row items-center justify-center relative box-border;
@apply border border-transparent border-solid;
overflow: visible;
transition-duration: 0.3s;
transition-property: background-color, border-color, opacity;
&__clicker {
@apply absolute p-0 m-0;
@apply w-full h-full;
@apply opacity-0;
@apply z-10;
}
&--small {
padding: 6rpx 14rpx;
border-radius: 12rpx;
&.cl-button--icon {
padding: 10rpx;
}
}
&--normal {
padding: 10rpx 28rpx;
border-radius: 16rpx;
&.cl-button--icon {
padding: 14rpx;
}
}
&--large {
padding: 14rpx 32rpx;
border-radius: 20rpx;
&.cl-button--icon {
padding: 18rpx;
}
}
&--rounded {
@apply rounded-full;
}
&--primary {
@include button-type("primary");
}
&--warn {
@include button-type("yellow");
}
&--error {
@include button-type("red");
}
&--info {
@include button-type("surface");
}
&--success {
@include button-type("green");
}
&--light {
@apply border-surface-700;
&.cl-button--hover {
@apply bg-surface-100;
}
&.is-dark {
&.cl-button--hover {
@apply bg-surface-700;
}
}
}
&--dark {
@apply bg-surface-700;
&.cl-button--hover {
@apply bg-surface-800;
}
}
&--disabled {
@apply bg-surface-300;
&.cl-button--border {
@apply border-surface-300;
}
}
&--loading {
opacity: 0.6;
}
&.is-dark {
&.cl-button--disabled {
@apply bg-surface-400;
&.cl-button--border {
@apply border-surface-500;
}
}
&.cl-button--text {
@apply bg-transparent;
}
&.cl-button--light {
@apply border-surface-500;
}
}
}
.cl-button {
& + .cl-button {
@apply ml-2;
}
}
</style>

View File

@@ -0,0 +1,42 @@
import type { ClIconProps } from "../cl-icon/props";
import type { ClButtonType, PassThroughProps, Size } from "../../types";
import type { ClLoadingProps } from "../cl-loading/props";
export type ClButtonPassThrough = {
className?: string;
label?: PassThroughProps;
icon?: ClIconProps;
loading?: ClLoadingProps;
};
export type ClButtonProps = {
className?: string;
pt?: ClButtonPassThrough;
type?: ClButtonType;
color?: string;
icon?: string;
text?: boolean;
rounded?: boolean;
border?: boolean;
loading?: boolean;
disabled?: boolean;
size?: Size;
hoverClass?: string;
hoverStopPropagation?: boolean;
hoverStartTime?: number;
hoverStayTime?: number;
formType?: "submit" | "reset";
openType?: "agreePrivacyAuthorization" | "feedback" | "share" | "getUserInfo" | "contact" | "getPhoneNumber" | "launchApp" | "openSetting" | "chooseAvatar" | "getAuthorize" | "lifestyle" | "contactShare" | "openGroupProfile" | "openGuildProfile" | "openPublicProfile" | "shareMessageToFriend" | "addFriend" | "addColorSign" | "addGroupApp" | "addToFavorites" | "chooseAddress" | "chooseInvoiceTitle" | "login" | "subscribe" | "favorite" | "watchLater" | "openProfile" | "liveActivity" | "getRealtimePhoneNumber";
lang?: "en" | "zh_CN" | "zh_TW";
sessionFrom?: string;
sendMessageTitle?: string;
sendMessagePath?: string;
sendMessageImg?: string;
showMessageCard?: boolean;
appParameter?: string;
groupId?: string;
guildId?: string;
publicId?: string;
phoneNumberNoQuotaToast?: boolean;
createliveactivity?: boolean;
};

View File

@@ -0,0 +1,310 @@
<template>
<cl-select-trigger
v-if="showTrigger"
:pt="ptTrigger"
:placeholder="placeholder"
:disabled="disabled"
:focus="popupRef?.isOpen"
:text="text"
@open="open()"
@clear="clear"
></cl-select-trigger>
<cl-popup ref="popupRef" v-model="visible" :title="title" :pt="ptPopup">
<view class="cl-select-popup" @touchmove.stop>
<slot name="prepend"></slot>
<view class="cl-select-popup__picker">
<cl-calendar
v-model="value"
v-model:date="date"
:mode="mode"
:date-config="dateConfig"
:start="start"
:end="end"
@change="onCalendarChange"
></cl-calendar>
</view>
<slot name="append"></slot>
<view class="cl-select-popup__op">
<cl-button
v-if="showCancel"
size="large"
text
border
type="light"
:pt="{
className: 'flex-1 !rounded-xl h-[80rpx]'
}"
@tap="close"
>{{ cancelText }}</cl-button
>
<cl-button
v-if="showConfirm"
size="large"
:pt="{
className: 'flex-1 !rounded-xl h-[80rpx]'
}"
@tap="confirm"
>{{ confirmText }}</cl-button
>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { ref, computed, type PropType } from "vue";
import type { ClCalendarDateConfig, ClCalendarMode } from "../../types";
import { isEmpty, parsePt, parseToObject } from "@/cool";
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
import { t } from "@/locale";
import { useUi } from "../../hooks";
defineOptions({
name: "cl-calendar-select"
});
defineSlots<{
prepend(): any;
append(): any;
}>();
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 选择器的值
modelValue: {
type: String as PropType<string | null>,
default: null
},
// 多个日期
date: {
type: Array as PropType<string[]>,
default: () => []
},
// 日期选择模式
mode: {
type: String as PropType<ClCalendarMode>,
default: "single"
},
// 日期配置
dateConfig: {
type: Array as PropType<ClCalendarDateConfig[]>,
default: () => []
},
// 开始日期,可选日期的开始
start: {
type: String
},
// 结束日期,可选日期的结束
end: {
type: String
},
// 选择器标题
title: {
type: String,
default: () => t("请选择")
},
// 选择器占位符
placeholder: {
type: String,
default: () => t("请选择")
},
// 是否显示选择器触发器
showTrigger: {
type: Boolean,
default: true
},
// 是否禁用选择器
disabled: {
type: Boolean,
default: false
},
// 分隔符
splitor: {
type: String,
default: "、"
},
// 范围分隔符
rangeSplitor: {
type: String,
default: () => t(" 至 ")
},
// 确认按钮文本
confirmText: {
type: String,
default: () => t("确定")
},
// 是否显示确认按钮
showConfirm: {
type: Boolean,
default: true
},
// 取消按钮文本
cancelText: {
type: String,
default: () => t("取消")
},
// 是否显示取消按钮
showCancel: {
type: Boolean,
default: true
}
});
// 定义事件
const emit = defineEmits(["update:modelValue", "update:date", "change", "select"]);
// UI实例
const ui = useUi();
// 弹出层引用
const popupRef = ref<ClPopupComponentPublicInstance | null>(null);
// 透传样式类型定义
type PassThrough = {
trigger?: ClSelectTriggerPassThrough;
popup?: ClPopupPassThrough;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 解析触发器透传样式配置
const ptTrigger = computed(() => parseToObject(pt.value.trigger));
// 解析弹窗透传样式配置
const ptPopup = computed(() => parseToObject(pt.value.popup));
// 当前选中的值
const value = ref<string | null>(null);
// 当前选中的日期
const date = ref<string[]>([]);
// 显示文本
const text = computed(() => {
switch (props.mode) {
case "single":
return props.modelValue ?? "";
case "multiple":
return props.date.join(props.splitor);
case "range":
return props.date.join(props.rangeSplitor);
default:
return "";
}
});
// 选择器显示状态
const visible = ref(false);
// 选择回调函数
let callback: ((value: string | string[]) => void) | null = null;
// 打开选择器
function open(cb: ((value: string | string[]) => void) | null = null) {
visible.value = true;
// 单选日期
value.value = props.modelValue;
// 多个日期
date.value = props.date;
// 回调
callback = cb;
}
// 关闭选择器
function close() {
visible.value = false;
}
// 清空选择器
function clear() {
value.value = null;
date.value = [] as string[];
emit("update:modelValue", value.value);
emit("update:date", date.value);
}
// 确认选择
function confirm() {
if (props.mode == "single") {
if (value.value == null) {
ui.showToast({
message: t("请选择日期")
});
return;
}
emit("update:modelValue", value.value);
emit("change", value.value);
// 触发回调
if (callback != null) {
callback!(value.value);
}
} else {
if (isEmpty(date.value)) {
ui.showToast({
message: t("请选择日期")
});
return;
}
if (date.value.length != 2) {
ui.showToast({
message: t("请选择日期范围")
});
return;
}
emit("update:date", date.value);
emit("change", date.value);
// 触发回调
if (callback != null) {
callback!(date.value);
}
}
// 关闭选择器
close();
}
// 日历变化
function onCalendarChange(date: string[]) {
emit("select", date);
}
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-select {
&-popup {
&__picker {
@apply p-3 pt-0;
}
&__op {
@apply flex flex-row items-center justify-center;
padding: 24rpx;
}
}
}
</style>

View File

@@ -0,0 +1,29 @@
import type { ClCalendarDateConfig, ClCalendarMode } from "../../types";
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
export type ClCalendarSelectPassThrough = {
trigger?: ClSelectTriggerPassThrough;
popup?: ClPopupPassThrough;
};
export type ClCalendarSelectProps = {
className?: string;
pt?: ClCalendarSelectPassThrough;
modelValue?: string | any;
date?: string[];
mode?: ClCalendarMode;
dateConfig?: ClCalendarDateConfig[];
start?: string;
end?: string;
title?: string;
placeholder?: string;
showTrigger?: boolean;
disabled?: boolean;
splitor?: string;
rangeSplitor?: string;
confirmText?: string;
showConfirm?: boolean;
cancelText?: string;
showCancel?: boolean;
};

View File

@@ -0,0 +1,926 @@
<template>
<view class="cl-calendar" :class="[pt.className]">
<!-- 年月选择器弹窗 -->
<calendar-picker
:year="currentYear"
:month="currentMonth"
:ref="refs.set('picker')"
@change="onYearMonthChange"
></calendar-picker>
<!-- 头部导航栏 -->
<view class="cl-calendar__header" v-if="showHeader">
<!-- 上一月按钮 -->
<view
class="cl-calendar__header-prev"
:class="{ 'is-dark': isDark }"
@tap.stop="gotoPrevMonth"
>
<cl-icon name="arrow-left-s-line"></cl-icon>
</view>
<!-- 当前年月显示区域 -->
<view class="cl-calendar__header-date" @tap="refs.open('picker')">
<slot name="current-date">
<cl-text :pt="{ className: 'text-lg' }">{{
$t(`{year}年{month}月`, { year: currentYear, month: currentMonth })
}}</cl-text>
</slot>
</view>
<!-- 下一月按钮 -->
<view
class="cl-calendar__header-next"
:class="{ 'is-dark': isDark }"
@tap.stop="gotoNextMonth"
>
<cl-icon name="arrow-right-s-line"></cl-icon>
</view>
</view>
<!-- 星期标题行 -->
<view class="cl-calendar__weeks" :style="{ gap: `${cellGap}px` }" v-if="showWeeks">
<view class="cl-calendar__weeks-item" v-for="weekName in weekLabels" :key="weekName">
<cl-text>{{ weekName }}</cl-text>
</view>
</view>
<!-- 日期网格容器 -->
<view
class="cl-calendar__view"
ref="viewRef"
:style="{ height: `${viewHeight}px`, gap: `${cellGap}px` }"
@tap="onTap"
>
<!-- #ifndef APP -->
<view
class="cl-calendar__view-row"
:style="{ gap: `${cellGap}px` }"
v-for="(weekRow, rowIndex) in dateMatrix"
:key="rowIndex"
>
<view
class="cl-calendar__view-cell"
v-for="(dateCell, cellIndex) in weekRow"
:key="cellIndex"
:class="{
'is-selected': dateCell.isSelected,
'is-range': dateCell.isRange,
'is-hide': dateCell.isHide,
'is-disabled': dateCell.isDisabled,
'is-today': dateCell.isToday,
'is-other-month': !dateCell.isCurrentMonth
}"
:style="{
height: cellHeight + 'px',
backgroundColor: getCellBgColor(dateCell)
}"
@click.stop="selectDateCell(dateCell)"
>
<!-- 顶部文本 -->
<cl-text
:size="20"
:color="getCellTextColor(dateCell)"
:pt="{
className: 'absolute top-[2px]'
}"
>{{ dateCell.topText }}</cl-text
>
<!-- 主日期数字 -->
<cl-text
:color="getCellTextColor(dateCell)"
:size="`${fontSize}px`"
:pt="{
className: 'font-bold'
}"
>{{ dateCell.date }}</cl-text
>
<!-- 底部文本 -->
<cl-text
:size="20"
:color="getCellTextColor(dateCell)"
:pt="{
className: 'absolute bottom-[2px]'
}"
>{{ dateCell.bottomText }}</cl-text
>
</view>
</view>
<!-- #endif -->
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch, type PropType } from "vue";
import { ctx, dayUts, first, isDark, isHarmony, parsePt, useRefs } from "@/cool";
import CalendarPicker from "./picker.uvue";
import { $t, t } from "@/locale";
import type { ClCalendarDateConfig, ClCalendarMode } from "../../types";
import { useSize } from "../../hooks";
defineOptions({
name: "cl-calendar"
});
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 当前选中的日期值(单选模式)
modelValue: {
type: String as PropType<string | null>,
default: null
},
// 选中的日期数组(多选/范围模式)
date: {
type: Array as PropType<string[]>,
default: () => []
},
// 日期选择模式:单选/多选/范围选择
mode: {
type: String as PropType<ClCalendarMode>,
default: "single"
},
// 日期配置
dateConfig: {
type: Array as PropType<ClCalendarDateConfig[]>,
default: () => []
},
// 开始日期,可选日期的开始
start: {
type: String
},
// 结束日期,可选日期的结束
end: {
type: String
},
// 设置年份
year: {
type: Number
},
// 设置月份
month: {
type: Number
},
// 是否显示其他月份的日期
showOtherMonth: {
type: Boolean,
default: true
},
// 是否显示头部导航栏
showHeader: {
type: Boolean,
default: true
},
// 是否显示星期
showWeeks: {
type: Boolean,
default: true
},
// 单元格高度
cellHeight: {
type: Number,
default: 66
},
// 单元格间距
cellGap: {
type: Number,
default: 0
},
// 主色
color: {
type: String,
default: ""
},
// 当前月份日期颜色
textColor: {
type: String,
default: ""
},
// 其他月份日期颜色
textOtherMonthColor: {
type: String,
default: ""
},
// 禁用日期颜色
textDisabledColor: {
type: String,
default: ""
},
// 今天日期颜色
textTodayColor: {
type: String,
default: "#ff6b6b"
},
// 选中日期颜色
textSelectedColor: {
type: String,
default: "#ffffff"
},
// 选中日期背景颜色
bgSelectedColor: {
type: String,
default: ""
},
// 范围选择背景颜色
bgRangeColor: {
type: String,
default: ""
}
});
// 事件发射器定义
const emit = defineEmits(["update:modelValue", "update:date", "change"]);
// 日期单元格数据结构
type DateCell = {
date: string; // 显示的日期数字
isCurrentMonth: boolean; // 是否属于当前显示月份
isToday: boolean; // 是否为今天
isSelected: boolean; // 是否被选中
isRange: boolean; // 是否在选择范围内
fullDate: string; // 完整日期格式 YYYY-MM-DD
isDisabled: boolean; // 是否被禁用
isHide: boolean; // 是否隐藏显示
topText: string; // 顶部文案
bottomText: string; // 底部文案
color: string; // 颜色
};
// 透传样式属性类型
type PassThrough = {
className?: string;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 字体大小
const { getPxValue } = useSize();
// 主色
const color = computed(() => {
if (props.color != "") {
return props.color;
}
return ctx.color["primary-500"] as string;
});
// 单元格高度
const cellHeight = computed(() => props.cellHeight);
// 单元格间距
const cellGap = computed(() => props.cellGap);
// 字体大小
const fontSize = computed(() => {
// #ifdef APP
return getPxValue("14px");
// #endif
// #ifndef APP
return 14;
// #endif
});
// 当前月份日期颜色
const textColor = computed(() => {
if (props.textColor != "") {
return props.textColor;
}
return isDark.value ? "white" : (ctx.color["surface-700"] as string);
});
// 其他月份日期颜色
const textOtherMonthColor = computed(() => {
if (props.textOtherMonthColor != "") {
return props.textOtherMonthColor;
}
return isDark.value
? (ctx.color["surface-500"] as string)
: (ctx.color["surface-300"] as string);
});
// 禁用日期颜色
const textDisabledColor = computed(() => {
if (props.textDisabledColor != "") {
return props.textDisabledColor;
}
return isDark.value
? (ctx.color["surface-500"] as string)
: (ctx.color["surface-300"] as string);
});
// 今天日期颜色
const textTodayColor = computed(() => props.textTodayColor);
// 选中日期颜色
const textSelectedColor = computed(() => props.textSelectedColor);
// 选中日期背景颜色
const bgSelectedColor = computed(() => {
if (props.bgSelectedColor != "") {
return props.bgSelectedColor;
}
return color.value;
});
// 范围选择背景颜色
const bgRangeColor = computed(() => {
if (props.bgRangeColor != "") {
return props.bgRangeColor;
}
return isHarmony() ? (ctx.color["primary-50"] as string) : color.value + "11";
});
// 组件引用管理器
const refs = useRefs();
// 日历视图DOM元素引用
const viewRef = ref<UniElement | null>(null);
// 当前显示的年份
const currentYear = ref(0);
// 当前显示的月份
const currentMonth = ref(0);
// 视图高度
const viewHeight = computed(() => {
return cellHeight.value * 6;
});
// 单元格宽度
const cellWidth = ref(0);
// 星期标签数组
const weekLabels = computed(() => {
return [t("周日"), t("周一"), t("周二"), t("周三"), t("周四"), t("周五"), t("周六")];
});
// 日历日期矩阵数据6行7列
const dateMatrix = ref<DateCell[][]>([]);
// 当前选中的日期列表
const selectedDates = ref<string[]>([]);
/**
* 获取日历视图元素的位置信息
*/
async function getViewRect(): Promise<DOMRect | null> {
return viewRef.value!.getBoundingClientRectAsync();
}
/**
* 判断指定日期是否被选中
* @param dateStr 日期字符串 YYYY-MM-DD
*/
function isDateSelected(dateStr: string): boolean {
if (props.mode == "single") {
// 单选模式:检查是否为唯一选中日期
return selectedDates.value[0] == dateStr;
} else {
// 多选/范围模式:检查是否在选中列表中
return selectedDates.value.includes(dateStr);
}
}
/**
* 判断指定日期是否被禁用
* @param dateStr 日期字符串 YYYY-MM-DD
*/
function isDateDisabled(dateStr: string): boolean {
// 大于开始日期
if (props.start != null) {
if (dayUts(dateStr).isBefore(dayUts(props.start))) {
return true;
}
}
// 小于结束日期
if (props.end != null) {
if (dayUts(dateStr).isAfter(dayUts(props.end))) {
return true;
}
}
return props.dateConfig.some((config) => config.date == dateStr && config.disabled == true);
}
/**
* 判断指定日期是否在选择范围内(不包括端点)
* @param dateStr 日期字符串 YYYY-MM-DD
*/
function isDateInRange(dateStr: string): boolean {
// 仅范围选择模式且已选择两个端点时才有范围
if (props.mode != "range" || selectedDates.value.length != 2) {
return false;
}
const [startDate, endDate] = selectedDates.value;
const currentDate = dayUts(dateStr);
return currentDate.isAfter(startDate) && currentDate.isBefore(endDate);
}
/**
* 获取单元格字体颜色
* @param dateCell 日期单元格数据
* @returns 字体颜色
*/
function getCellTextColor(dateCell: DateCell): string {
// 选中的日期文字颜色
if (dateCell.isSelected) {
return textSelectedColor.value;
}
if (dateCell.color != "") {
return dateCell.color;
}
// 范围选择日期颜色
if (dateCell.isRange) {
return color.value;
}
// 禁用的日期颜色
if (dateCell.isDisabled) {
return textDisabledColor.value;
}
// 今天日期颜色
if (dateCell.isToday) {
return textTodayColor.value;
}
// 当前月份日期颜色
if (dateCell.isCurrentMonth) {
return textColor.value;
}
// 其他月份日期颜色
return textOtherMonthColor.value;
}
/**
* 获取单元格背景颜色
* @param dateCell 日期单元格数据
* @returns 背景颜色
*/
function getCellBgColor(dateCell: DateCell): string {
if (dateCell.isSelected) {
return bgSelectedColor.value;
}
if (dateCell.isRange) {
return bgRangeColor.value;
}
return "transparent";
}
/**
* 计算并生成日历矩阵数据
* 生成6行7列共42个日期包含上月末尾和下月开头的日期
*/
function calculateDateMatrix() {
const weekRows: DateCell[][] = [];
const todayStr = dayUts().format("YYYY-MM-DD"); // 今天的日期字符串
// 获取当前月第一天
const monthFirstDay = dayUts(`${currentYear.value}-${currentMonth.value}-01`);
const firstDayWeekIndex = monthFirstDay.getDay(); // 第一天是星期几 (0=周日, 6=周六)
// 计算日历显示的起始日期(可能是上个月的日期)
const calendarStartDate = monthFirstDay.subtract(firstDayWeekIndex, "day");
// 生成6周的日期数据6行 × 7列 = 42天
let iterateDate = calendarStartDate;
for (let weekIndex = 0; weekIndex < 6; weekIndex++) {
const weekDates: DateCell[] = [];
for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
const fullDateStr = iterateDate.format("YYYY-MM-DD");
const nativeDate = iterateDate.toDate();
const dayNumber = nativeDate.getDate();
// 判断是否属于当前显示月份
const belongsToCurrentMonth =
nativeDate.getMonth() + 1 == currentMonth.value &&
nativeDate.getFullYear() == currentYear.value;
// 日期配置
const dateConfig = props.dateConfig.find((config) => config.date == fullDateStr);
// 构建日期单元格数据
const dateCell = {
date: `${dayNumber}`,
isCurrentMonth: belongsToCurrentMonth,
isToday: fullDateStr == todayStr,
isSelected: isDateSelected(fullDateStr),
isRange: isDateInRange(fullDateStr),
fullDate: fullDateStr,
isDisabled: isDateDisabled(fullDateStr),
isHide: false,
topText: dateConfig?.topText ?? "",
bottomText: dateConfig?.bottomText ?? "",
color: dateConfig?.color ?? ""
} as DateCell;
// 根据配置决定是否隐藏相邻月份的日期
if (!props.showOtherMonth && !belongsToCurrentMonth) {
dateCell.isHide = true;
}
weekDates.push(dateCell);
iterateDate = iterateDate.add(1, "day"); // 移动到下一天
}
weekRows.push(weekDates);
}
dateMatrix.value = weekRows;
}
/**
* 使用Canvas绘制日历仅APP端
* Web端使用DOM渲染APP端使用Canvas提升性能
*/
async function renderCalendar() {
// #ifdef APP
await nextTick(); // 等待DOM更新完成
const ctx = viewRef.value!.getDrawableContext();
if (ctx == null) return;
ctx!.reset(); // 清空画布
/**
* 绘制单个日期单元格
* @param dateCell 日期单元格数据
* @param colIndex 列索引 (0-6)
* @param rowIndex 行索引 (0-5)
*/
function drawSingleCell(dateCell: DateCell, colIndex: number, rowIndex: number) {
// 计算单元格位置
const cellX = colIndex * cellWidth.value;
const cellY = rowIndex * cellHeight.value;
const centerX = cellX + cellWidth.value / 2;
const centerY = cellY + cellHeight.value / 2;
// 绘制背景(选中状态或范围状态)
if (dateCell.isSelected || dateCell.isRange) {
const padding = cellGap.value; // 使用间距作为内边距
const bgX = cellX + padding;
const bgY = cellY + padding;
const bgWidth = cellWidth.value - padding * 2;
const bgHeight = cellHeight.value - padding * 2;
// 设置背景颜色
if (dateCell.isSelected) {
ctx!.fillStyle = bgSelectedColor.value;
}
if (dateCell.isRange) {
ctx!.fillStyle = bgRangeColor.value;
}
ctx!.fillRect(bgX, bgY, bgWidth, bgHeight); // 绘制背景矩形
}
// 获取单元格文字颜色
const cellTextColor = getCellTextColor(dateCell);
ctx!.textAlign = "center";
// 绘制顶部文本
if (dateCell.topText != "") {
ctx!.font = `${Math.floor(fontSize.value * 0.75)}px sans-serif`;
ctx!.fillStyle = cellTextColor;
const topY = cellY + 16; // 距离顶部
ctx!.fillText(dateCell.topText, centerX, topY);
}
// 绘制主日期数字
ctx!.font = `${fontSize.value}px sans-serif`;
ctx!.fillStyle = cellTextColor;
const textOffsetY = (fontSize.value / 2) * 0.7;
ctx!.fillText(dateCell.date.toString(), centerX, centerY + textOffsetY);
// 绘制底部文本
if (dateCell.bottomText != "") {
ctx!.font = `${Math.floor(fontSize.value * 0.75)}px sans-serif`;
ctx!.fillStyle = cellTextColor;
const bottomY = cellY + cellHeight.value - 8; // 距离底部
ctx!.fillText(dateCell.bottomText, centerX, bottomY);
}
}
// 获取容器尺寸信息
const viewRect = await getViewRect();
if (viewRect == null) {
return;
}
// 计算单元格宽度总宽度除以7列
const cellSize = viewRect.width / 7;
// 更新渲染配置
cellWidth.value = cellSize;
// 遍历日期矩阵进行绘制
for (let rowIndex = 0; rowIndex < dateMatrix.value.length; rowIndex++) {
const weekRow = dateMatrix.value[rowIndex];
for (let colIndex = 0; colIndex < weekRow.length; colIndex++) {
const dateCell = weekRow[colIndex];
if (!dateCell.isHide) {
drawSingleCell(dateCell, colIndex, rowIndex);
}
}
}
ctx!.update(); // 更新画布显示
// #endif
}
/**
* 处理日期单元格选择逻辑
* @param dateCell 被点击的日期单元格
*/
function selectDateCell(dateCell: DateCell) {
// 隐藏或禁用的日期不可选择
if (dateCell.isHide || dateCell.isDisabled) {
return;
}
if (props.mode == "single") {
// 单选模式:直接替换选中日期
selectedDates.value = [dateCell.fullDate];
emit("update:modelValue", dateCell.fullDate);
} else if (props.mode == "multiple") {
// 多选模式:切换选中状态
const existingIndex = selectedDates.value.indexOf(dateCell.fullDate);
if (existingIndex >= 0) {
// 已选中则移除
selectedDates.value.splice(existingIndex, 1);
} else {
// 未选中则添加
selectedDates.value.push(dateCell.fullDate);
}
} else {
// 范围选择模式
if (selectedDates.value.length == 0) {
// 第一次点击:设置起始日期
selectedDates.value = [dateCell.fullDate];
} else if (selectedDates.value.length == 1) {
// 第二次点击:设置结束日期
const startDate = dayUts(selectedDates.value[0]);
const endDate = dayUts(dateCell.fullDate);
if (endDate.isBefore(startDate)) {
// 结束日期早于开始日期时自动交换
selectedDates.value = [dateCell.fullDate, selectedDates.value[0]];
} else {
selectedDates.value = [selectedDates.value[0], dateCell.fullDate];
}
} else {
// 已有范围时重新开始选择
selectedDates.value = [dateCell.fullDate];
}
}
// 发射更新事件
emit("update:date", [...selectedDates.value]);
emit("change", selectedDates.value);
// 重新计算日历数据并重绘
calculateDateMatrix();
renderCalendar();
}
/**
* 处理年月选择器的变化事件
* @param yearMonthArray [年份, 月份] 数组
*/
function onYearMonthChange(yearMonthArray: number[]) {
currentYear.value = yearMonthArray[0];
currentMonth.value = yearMonthArray[1];
// 重新计算日历数据并重绘
calculateDateMatrix();
renderCalendar();
}
/**
* 处理点击事件APP端点击检测
*/
async function onTap(e: UniPointerEvent) {
// 获取容器位置信息
const viewRect = await getViewRect();
if (viewRect == null) {
return;
}
// 计算触摸点相对于容器的坐标
const relativeX = e.clientX - viewRect.left;
const relativeY = e.clientY - viewRect.top;
// 根据坐标计算对应的行列索引
const columnIndex = Math.floor(relativeX / cellWidth.value);
const rowIndex = Math.floor(relativeY / cellHeight.value);
// 边界检查:确保索引在有效范围内
if (
rowIndex < 0 ||
rowIndex >= dateMatrix.value.length ||
columnIndex < 0 ||
columnIndex >= 7
) {
return;
}
const targetDateCell = dateMatrix.value[rowIndex][columnIndex];
selectDateCell(targetDateCell);
}
/**
* 切换到上一个月
*/
function gotoPrevMonth() {
const [newYear, newMonth] = dayUts(`${currentYear.value}-${currentMonth.value}-01`)
.subtract(1, "month")
.toArray();
currentYear.value = newYear;
currentMonth.value = newMonth;
// 重新计算并渲染日历
calculateDateMatrix();
renderCalendar();
}
/**
* 切换到下一个月
*/
function gotoNextMonth() {
const [newYear, newMonth] = dayUts(`${currentYear.value}-${currentMonth.value}-01`)
.add(1, "month")
.toArray();
currentYear.value = newYear;
currentMonth.value = newMonth;
// 重新计算并渲染日历
calculateDateMatrix();
renderCalendar();
}
/**
* 解析选中日期
*/
function parseDate(flag: boolean | null = null) {
// 根据选择模式初始化选中日期
if (props.mode == "single") {
selectedDates.value = props.modelValue != null ? [props.modelValue] : [];
} else {
selectedDates.value = [...props.date];
}
// 获取初始显示日期(优先使用选中日期,否则使用当前日期)
let [year, month] = dayUts(first(selectedDates.value)).toArray();
if (flag == true) {
year = props.year ?? year;
month = props.month ?? month;
}
currentYear.value = year;
currentMonth.value = month;
// 计算初始日历数据
calculateDateMatrix();
// 渲染日历视图
renderCalendar();
}
// 组件挂载时的初始化逻辑
onMounted(() => {
// 解析日期
parseDate(true);
// 监听单选模式的值变化
watch(
computed(() => props.modelValue ?? ""),
(newValue: string) => {
selectedDates.value = [newValue];
parseDate();
}
);
// 监听多选/范围模式的值变化
watch(
computed(() => props.date),
(newDateArray: string[]) => {
selectedDates.value = [...newDateArray];
parseDate();
}
);
// 重新渲染
watch(
computed(() => [props.dateConfig, props.showOtherMonth]),
() => {
calculateDateMatrix();
renderCalendar();
},
{
deep: true
}
);
});
</script>
<style lang="scss" scoped>
/* 日历组件主容器 */
.cl-calendar {
@apply relative;
/* 头部导航栏样式 */
&__header {
@apply flex flex-row items-center justify-between p-3 w-full;
/* 上一月/下一月按钮样式 */
&-prev,
&-next {
@apply flex flex-row items-center justify-center rounded-full bg-surface-100;
width: 60rpx;
height: 60rpx;
/* 暗色模式适配 */
&.is-dark {
@apply bg-surface-700;
}
}
/* 当前年月显示区域 */
&-date {
@apply flex flex-row items-center justify-center;
}
}
/* 星期标题行样式 */
&__weeks {
@apply flex flex-row;
/* 单个星期标题样式 */
&-item {
@apply flex flex-row items-center justify-center flex-1;
height: 80rpx;
}
}
/* 日期网格容器样式 */
&__view {
@apply w-full;
// #ifndef APP
/* 日期行样式 */
&-row {
@apply flex flex-row;
}
/* 日期单元格样式 */
&-cell {
@apply flex-1 flex flex-col items-center justify-center relative;
height: 80rpx;
/* 隐藏状态(相邻月份日期) */
&.is-hide {
opacity: 0;
}
/* 禁用状态 */
&.is-disabled {
@apply opacity-50;
}
}
// #endif
}
}
</style>

View File

@@ -0,0 +1,80 @@
import { appendLocale } from "@/locale";
setTimeout(() => {
appendLocale("zh-cn", [
["周日", "日"],
["周一", "一"],
["周二", "二"],
["周三", "三"],
["周四", "四"],
["周五", "五"],
["周六", "六"],
["{year}年{month}月", "{year}年{month}月"]
]);
appendLocale("zh-tw", [
["周日", "週日"],
["周一", "週一"],
["周二", "週二"],
["周三", "週三"],
["周四", "週四"],
["周五", "週五"],
["周六", "週六"],
["{year}年{month}月", "{year}年{month}月"]
]);
appendLocale("en", [
["周日", "Sun"],
["周一", "Mon"],
["周二", "Tue"],
["周三", "Wed"],
["周四", "Thu"],
["周五", "Fri"],
["周六", "Sat"],
["{year}年{month}月", "{month}/{year}"]
]);
appendLocale("ja", [
["周日", "日曜"],
["周一", "月曜"],
["周二", "火曜"],
["周三", "水曜"],
["周四", "木曜"],
["周五", "金曜"],
["周六", "土曜"],
["{year}年{month}月", "{year}年{month}月"]
]);
appendLocale("ko", [
["周日", "일"],
["周一", "월"],
["周二", "화"],
["周三", "수"],
["周四", "목"],
["周五", "금"],
["周六", "토"],
["{year}年{month}月", "{year}년 {month}월"]
]);
appendLocale("fr", [
["周日", "Dim"],
["周一", "Lun"],
["周二", "Mar"],
["周三", "Mer"],
["周四", "Jeu"],
["周五", "Ven"],
["周六", "Sam"],
["{year}年{month}月", "{month}/{year}"]
]);
appendLocale("es", [
["周日", "Dom"],
["周一", "Lun"],
["周二", "Mar"],
["周三", "Mié"],
["周四", "Jue"],
["周五", "Vie"],
["周六", "Sáb"],
["{year}年{month}月", "{month}/{year}"]
]);
}, 0);

View File

@@ -0,0 +1,273 @@
<template>
<view
class="cl-calendar-picker"
:class="{
'is-dark': isDark
}"
v-if="visible"
>
<view class="cl-calendar-picker__header">
<view
class="cl-calendar-picker__prev"
:class="{
'is-dark': isDark
}"
@tap="prev"
>
<cl-icon name="arrow-left-double-line"></cl-icon>
</view>
<view class="cl-calendar-picker__date" @tap="toMode('year')">
<cl-text
:pt="{
className: 'text-lg'
}"
>
{{ title }}
</cl-text>
</view>
<view
class="cl-calendar-picker__next"
:class="{
'is-dark': isDark
}"
@tap="next"
>
<cl-icon name="arrow-right-double-line"></cl-icon>
</view>
</view>
<view class="cl-calendar-picker__list">
<view
class="cl-calendar-picker__item"
v-for="item in list"
:key="item.value"
@tap="select(item.value)"
>
<cl-text
:pt="{
className: parseClass([[item.value == value, 'text-primary-500']])
}"
>{{ item.label }}</cl-text
>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { first, isDark, last, parseClass } from "@/cool";
import { t } from "@/locale";
import { computed, ref } from "vue";
defineOptions({
name: "cl-calendar-picker"
});
// 定义日历选择器的条目类型
type Item = {
label: string; // 显示的标签,如"1月"、"2024"
value: number; // 对应的数值如1、2024
};
// 定义组件接收的属性年份和月份均为数字类型默认值为0
const props = defineProps({
year: {
type: Number,
default: 0
},
month: {
type: Number,
default: 0
}
});
// 定义组件可触发的事件,这里只定义了"change"事件
const emit = defineEmits(["change"]);
// 当前选择的模式,"year"表示选择年份,"month"表示选择月份,默认是"month"
const mode = ref<"year" | "month">("month");
// 当前选中的年份
const year = ref(0);
// 当前选中的月份
const month = ref(0);
// 当前年份选择面板的起始年份如2020-2029则startYear为2020
const startYear = ref(0);
// 当前选中的值,若为月份模式则为月份,否则为年份
const value = computed(() => {
return mode.value == "month" ? month.value : year.value;
});
// 计算可供选择的列表:
// - 若为月份模式返回1-12月
// - 若为年份模式返回以startYear为起点的连续10年
const list = computed(() => {
if (mode.value == "month") {
return [
{ label: t("1月"), value: 1 },
{ label: t("2月"), value: 2 },
{ label: t("3月"), value: 3 },
{ label: t("4月"), value: 4 },
{ label: t("5月"), value: 5 },
{ label: t("6月"), value: 6 },
{ label: t("7月"), value: 7 },
{ label: t("8月"), value: 8 },
{ label: t("9月"), value: 9 },
{ label: t("10月"), value: 10 },
{ label: t("11月"), value: 11 },
{ label: t("12月"), value: 12 }
] as Item[];
} else {
const years: Item[] = [];
// 生成10个连续年份
for (let i = 0; i < 10; i++) {
years.push({
label: `${startYear.value + i}`,
value: startYear.value + i
});
}
return years;
}
});
// 计算标题内容:
// - 月份模式下显示“xxxx年”
// - 年份模式下显示“起始年 - 结束年”
const title = computed(() => {
return mode.value == "month"
? `${year.value}`
: `${first(list.value)?.label} - ${last(list.value)?.label}`;
});
// 控制选择器弹窗的显示与隐藏
const visible = ref(false);
/**
* 打开选择器,并初始化年份、月份、起始年份
*/
function open() {
visible.value = true;
// 初始化当前年份和月份为传入的props
year.value = props.year;
month.value = props.month;
// 计算当前年份所在的十年区间的起始年份
startYear.value = Math.floor(year.value / 10) * 10;
}
/**
* 关闭选择器
*/
function close() {
visible.value = false;
}
/**
* 切换选择模式(年份/月份)
* @param val "year" 或 "month"
*/
function toMode(val: "year" | "month") {
mode.value = val;
}
/**
* 选择某个值(年份或月份)
* @param val 选中的值
*/
function select(val: number) {
if (mode.value == "month") {
// 选择月份后关闭弹窗并触发change事件
month.value = val;
close();
emit("change", [year.value, month.value]);
} else {
// 选择年份后,切换到月份选择模式
year.value = val;
toMode("month");
}
}
/**
* 切换到上一个区间
* - 月份模式下年份减1
* - 年份模式下起始年份减10
*/
function prev() {
if (mode.value == "month") {
year.value -= 1;
} else {
startYear.value -= 10;
}
}
/**
* 切换到下一个区间
* - 月份模式下年份加1
* - 年份模式下起始年份加10
*/
function next() {
if (mode.value == "month") {
year.value += 1;
} else {
startYear.value += 10;
}
}
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-calendar-picker {
@apply flex flex-col absolute left-0 top-0 w-full h-full bg-white z-10;
&__header {
@apply flex flex-row items-center justify-between w-full p-3;
}
&__prev,
&__next {
@apply flex flex-row items-center justify-center rounded-full bg-surface-100;
width: 60rpx;
height: 60rpx;
&.is-dark {
@apply bg-surface-700;
}
}
&__date {
@apply flex flex-row items-center justify-center;
}
&__list {
@apply flex flex-row flex-wrap;
}
&__item {
@apply flex flex-row items-center justify-center;
height: 100rpx;
width: 25%;
&-bg {
@apply px-4 py-2;
&.is-active {
@apply bg-primary-500 rounded-xl;
}
}
}
&.is-dark {
@apply bg-surface-800 rounded-2xl;
}
}
</style>

View File

@@ -0,0 +1,31 @@
import type { ClCalendarDateConfig, ClCalendarMode } from "../../types";
export type ClCalendarPassThrough = {
className?: string;
};
export type ClCalendarProps = {
className?: string;
pt?: ClCalendarPassThrough;
modelValue?: string | any;
date?: string[];
mode?: ClCalendarMode;
dateConfig?: ClCalendarDateConfig[];
start?: string;
end?: string;
year?: number;
month?: number;
showOtherMonth?: boolean;
showHeader?: boolean;
showWeeks?: boolean;
cellHeight?: number;
cellGap?: number;
color?: string;
textColor?: string;
textOtherMonthColor?: string;
textDisabledColor?: string;
textTodayColor?: string;
textSelectedColor?: string;
bgSelectedColor?: string;
bgRangeColor?: string;
};

View File

@@ -0,0 +1,532 @@
<template>
<cl-select-trigger
v-if="showTrigger"
:pt="ptTrigger"
:placeholder="placeholder"
:disabled="disabled"
:focus="popupRef?.isOpen"
:text="text"
@open="open"
@clear="clear"
></cl-select-trigger>
<cl-popup ref="popupRef" v-model="visible" :title="title" :pt="ptPopup" @closed="onClosed">
<view class="cl-select-popup" @touchmove.stop>
<view class="cl-select-popup__labels">
<cl-tag
v-for="(item, index) in labels"
:key="index"
:type="index != current ? 'info' : 'primary'"
plain
@tap="onLabelTap(index)"
>
{{ item }}
</cl-tag>
</view>
<view
class="cl-select-popup__list"
:style="{
height: parseRpx(height)
}"
>
<swiper
v-if="isMp() ? popupRef?.isOpen : true"
class="h-full bg-transparent"
:current="current"
:disable-touch="disableTouch"
@change="onSwiperChange"
>
<swiper-item
v-for="(data, index) in list"
:key="index"
class="h-full bg-transparent"
>
<cl-list-view
:data="data"
:item-height="45"
:virtual="!isMp()"
@item-tap="onItemTap"
>
<template #item="{ data, item }">
<view
class="flex flex-row items-center justify-between w-full px-[20rpx]"
:class="{
'bg-primary-50': onItemActive(index, data),
'bg-surface-800': isDark && onItemActive(index, data)
}"
:style="{
height: item.height + 'px'
}"
>
<cl-text
:pt="{
className: parseClass({
'text-primary-500': onItemActive(index, data)
})
}"
>{{ data[labelKey] }}</cl-text
>
</view>
</template>
</cl-list-view>
</swiper-item>
</swiper>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { ref, computed, type PropType, nextTick } from "vue";
import {
isDark,
isEmpty,
isMp,
isNull,
parseClass,
parsePt,
parseRpx,
parseToObject
} from "@/cool";
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
import { t } from "@/locale";
import type { ClListViewItem } from "../../types";
defineOptions({
name: "cl-cascader"
});
/**
* 组件属性定义
* 定义级联选择器组件的所有可配置属性
*/
const props = defineProps({
/**
* 透传样式配置
* 用于自定义组件各部分的样式,支持嵌套配置
* 可配置trigger(触发器)、popup(弹窗)等部分的样式
*/
pt: {
type: Object,
default: () => ({})
},
/**
* 选择器的值 - v-model绑定
* 数组形式,按层级顺序存储选中的值
* 例如:["province", "city", "district"] 表示选中了省市区三级
*/
modelValue: {
type: Array as PropType<string[]>,
default: () => []
},
/**
* 选择器弹窗标题
* 显示在弹窗顶部的标题文字
*/
title: {
type: String,
default: () => t("请选择")
},
/**
* 选择器占位符文本
* 当没有选中任何值时显示的提示文字
*/
placeholder: {
type: String,
default: () => t("请选择")
},
/**
* 选项数据源,支持树形结构
* 每个选项需包含 labelKey 和 valueKey 指定的字段
* 如果有子级,需包含 children 字段
*/
options: {
type: Array as PropType<ClListViewItem[]>,
default: () => []
},
/**
* 是否显示选择器触发器
* 设为 false 时可以通过编程方式控制弹窗显示
*/
showTrigger: {
type: Boolean,
default: true
},
/**
* 是否禁用选择器
* 禁用状态下无法点击触发器打开弹窗
*/
disabled: {
type: Boolean,
default: false
},
/**
* 标签显示字段的键名
* 指定从数据项的哪个字段读取显示文字
*/
labelKey: {
type: String,
default: "label"
},
/**
* 值字段的键名
* 指定从数据项的哪个字段读取实际值
*/
valueKey: {
type: String,
default: "label"
},
/**
* 文本分隔符
* 用于连接多级标签的文本
*/
textSeparator: {
type: String,
default: " - "
},
/**
* 列表高度
*/
height: {
type: [String, Number],
default: 800
}
});
/**
* 定义组件事件
* 向父组件发射的事件列表
*/
const emit = defineEmits(["update:modelValue", "change"]);
/**
* 弹出层组件的引用
* 用于调用弹出层的方法,如打开、关闭等
*/
const popupRef = ref<ClPopupComponentPublicInstance | null>(null);
/**
* 透传样式类型定义
* 定义可以透传给子组件的样式配置结构
*/
type PassThrough = {
trigger?: ClSelectTriggerPassThrough; // 触发器样式配置
popup?: ClPopupPassThrough; // 弹窗样式配置
};
/**
* 解析透传样式配置
* 将传入的样式配置按照指定类型进行解析和处理
*/
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 解析触发器透传样式配置
const ptTrigger = computed(() => parseToObject(pt.value.trigger));
// 解析弹窗透传样式配置
const ptPopup = computed(() => parseToObject(pt.value.popup));
/**
* 当前显示的级联层级索引
* 用于控制 swiper 组件显示哪一级的选项列表
*/
const current = ref(0);
/**
* 是否还有下一级可选
* 当选中项没有子级时设为 false表示选择完成
*/
const isNext = ref(true);
/**
* 当前临时选中的值数组
* 存储用户在弹窗中正在选择的值,确认后才会更新到 modelValue
*/
const value = ref<any[]>([]);
/**
* 级联选择的数据列表
* 根据当前选中的值生成多级选项数据数组
* 返回二维数组,第一维是级别,第二维是该级别的选项
*
* 计算逻辑:
* 1. 如果没有选中任何值,返回根级选项
* 2. 根据已选中的值,逐级查找对应的子级选项
* 3. 最终返回所有级别的选项数据
*/
const list = computed<ClListViewItem[][]>(() => {
let data = props.options;
// 如果没有选中任何值,直接返回根级选项
if (isEmpty(value.value)) {
return [data];
}
// 根据选中的值逐级构建选项数据
const arr = value.value.map((v) => {
// 在当前级别中查找选中的项
const item = data.find((e) => e[props.valueKey] == v);
if (item == null) {
return [];
}
// 如果找到的项有子级更新data为子级数据
if (!isNull(item.children)) {
data = item.children ?? [];
}
return data as ClListViewItem[];
});
// 返回根级选项 + 各级子选项
return [props.options, ...arr];
});
/**
* 扁平化的选项数据
* 将树形结构的选项数据转换为一维数组
* 用于根据值快速查找对应的选项信息
*/
const flatOptions = computed(() => {
const data = props.options;
const arr = [] as ClListViewItem[];
/**
* 深度遍历树形数据,将所有节点添加到扁平数组中
* @param list 当前层级的选项列表
*/
function deep(list: ClListViewItem[]) {
list.forEach((e) => {
// 将当前项添加到扁平数组
arr.push(e);
// 如果有子级,递归处理
if (e.children != null) {
deep(e.children!);
}
});
}
// 开始深度遍历
deep(data);
return arr;
});
/**
* 当前选中项的标签数组
* 根据选中的值获取对应的显示标签
* 用于在弹窗顶部显示选择路径
*/
const labels = computed(() => {
const arr = value.value.map((v, i) => {
// 在对应级别的选项中查找匹配的项,返回其标签
return list.value[i].find((e) => e[props.valueKey] == v)?.[props.labelKey] ?? "";
});
if (isNext.value && !isEmpty(flatOptions.value)) {
arr.push(t("请选择"));
}
return arr;
});
/**
* 触发器显示的文本
* 将选中的值转换为对应的标签,用 " - " 连接
* 例如:北京 - 朝阳区 - 三里屯街道
*/
const text = computed(() => {
return props.modelValue
.map((v) => {
// 在扁平化数据中查找对应的选项,获取其标签
return flatOptions.value.find((e) => e[props.valueKey] == v)?.[props.labelKey] ?? "";
})
.join(props.textSeparator);
});
/**
* 选择器弹窗显示状态
* 控制弹窗的打开和关闭
*/
const visible = ref(false);
/**
* 打开选择器弹窗
* 检查禁用状态,如果未禁用则显示弹窗
*/
function open() {
visible.value = true;
}
/**
* 关闭选择器弹窗
* 直接设置弹窗为隐藏状态
*/
function close() {
visible.value = false;
}
/**
* 重置选择器
*/
function reset() {
// 重置当前级别索引
current.value = 0;
// 清空临时选中的值
value.value = [];
// 重置下一级状态
isNext.value = true;
}
/**
* 弹窗关闭完成后的回调
* 重置所有临时状态,为下次打开做准备
*/
function onClosed() {
reset();
}
/**
* 清空选择器的值
* 重置所有状态并触发相关事件
*/
function clear() {
reset();
// 触发值更新事件
emit("update:modelValue", value.value);
emit("change", value.value);
}
/**
* 是否禁用触摸
*/
const disableTouch = ref(false);
/**
* 处理选项点击事件
* 根据点击的选项更新选中状态,如果是叶子节点则完成选择并关闭弹窗
*
* @param item 被点击的选项数据
*/
function onItemTap(item: ClListViewItem) {
if (disableTouch.value == true) {
return;
}
// 处理选项点击事件的主逻辑,防止重复点击,确保级联选择流程正确
disableTouch.value = true;
// 设置新的定时器
setTimeout(() => {
disableTouch.value = false;
}, 300);
// 如果选项没有值,直接返回
if (item[props.valueKey] == null) {
return;
}
// 在当前级别的数据中查找对应的完整选项信息
const data = list.value[current.value].find((e) => e[props.valueKey] == item[props.valueKey]);
// 截取当前级别之前的值,清除后续级别的选择
value.value = value.value.slice(0, current.value);
// 添加当前选中的值
value.value.push(item[props.valueKey]!);
if (data != null) {
// 判断是否为叶子节点(没有子级或子级为空)
if (data.children == null || isEmpty(data.children!)) {
// 关闭弹窗
close();
// 设置下一级状态为不可选
isNext.value = false;
// 选择完成
emit("update:modelValue", value.value);
emit("change", value.value);
} else {
// 还有下一级,继续选择
isNext.value = true;
nextTick(() => {
current.value += 1; // 切换到下一级
});
}
}
}
/**
* 判断选项是否为当前激活状态
* 用于高亮显示当前选中的选项
*
* @param index 当前级别索引
* @param item 选项数据
* @returns 是否为激活状态
*/
function onItemActive(index: number, item: ClListViewItem) {
// 如果没有选中任何值,则没有激活项
if (isEmpty(value.value)) {
return false;
}
// 如果索引超出选中值的长度,说明该级别没有选中项
if (index >= value.value.length) {
return false;
}
// 判断当前级别的选中值是否与该选项的值相匹配
return value.value[index] == item[props.valueKey];
}
/**
* 处理标签点击事件
* 点击标签可以快速跳转到对应的级别
*
* @param index 要跳转到的级别索引
*/
function onLabelTap(index: number) {
current.value = index;
}
/**
* 处理 swiper 组件的切换事件
* 当用户滑动切换级别时同步更新当前级别索引
*
* @param e swiper 切换事件对象
*/
function onSwiperChange(e: UniSwiperChangeEvent) {
current.value = e.detail.current;
}
defineExpose({
open,
close,
reset,
clear
});
</script>
<style lang="scss" scoped>
.cl-select {
&-popup {
&__labels {
@apply flex flex-row mb-3;
padding: 0 20rpx;
}
&__list {
@apply relative;
}
}
}
</style>

View File

@@ -0,0 +1,23 @@
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
import type { ClListViewItem } from "../../types";
export type ClCascaderPassThrough = {
trigger?: ClSelectTriggerPassThrough;
popup?: ClPopupPassThrough;
};
export type ClCascaderProps = {
className?: string;
pt?: ClCascaderPassThrough;
modelValue?: string[];
title?: string;
placeholder?: string;
options?: ClListViewItem[];
showTrigger?: boolean;
disabled?: boolean;
labelKey?: string;
valueKey?: string;
textSeparator?: string;
height?: any;
};

View File

@@ -0,0 +1,177 @@
<template>
<view
class="cl-checkbox"
:class="[
{
'cl-checkbox--disabled': isDisabled,
'cl-checkbox--checked': isChecked
},
pt.className
]"
@tap="onTap"
>
<cl-icon
v-if="showIcon"
:name="iconName"
:size="pt.icon?.size ?? 40"
:pt="{
className: parseClass([
'cl-checkbox__icon mr-1',
{
'text-primary-500': isChecked
},
pt.icon?.className
])
}"
></cl-icon>
<cl-text
:pt="{
className: parseClass([
'cl-checkbox__label',
{
'text-primary-500': isChecked
},
pt.label?.className
])
}"
v-if="showLabel"
>
<slot>{{ label }}</slot>
</cl-text>
</view>
</template>
<script lang="ts" setup>
import { computed, useSlots, type PropType } from "vue";
import type { PassThroughProps } from "../../types";
import { get, parseClass, parsePt, pull } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-checkbox"
});
// 定义组件属性
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 绑定值 - 当前选中的值
modelValue: {
type: [Array, Boolean] as PropType<any[] | boolean>,
default: () => []
},
// 标签文本
label: {
type: String,
default: ""
},
// 选项值 - 该单选框对应的值
value: {
type: null
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 选中时的图标
activeIcon: {
type: String,
default: "checkbox-line"
},
// 未选中时的图标
inactiveIcon: {
type: String,
default: "checkbox-blank-line"
},
// 是否显示图标
showIcon: {
type: Boolean,
default: true
}
});
// 定义组件事件
const emit = defineEmits(["update:modelValue", "change"]);
const slots = useSlots();
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => props.disabled || disabled.value);
// 透传样式类型定义
type PassThrough = {
className?: string;
icon?: ClIconProps;
label?: PassThroughProps;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 是否为选中状态
const isChecked = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.includes(props.value!);
}
if (typeof props.modelValue == "boolean") {
return props.modelValue;
}
return false;
});
// 是否显示标签
const showLabel = computed(() => props.label != "" || get(slots, "default") != null);
// 图标名称
const iconName = computed(() => {
// 选中状态
if (isChecked.value) {
return props.activeIcon;
}
// 默认状态
return props.inactiveIcon;
});
/**
* 点击事件处理函数
* 在非禁用状态下切换选中状态
*/
function onTap() {
if (!isDisabled.value) {
let val = props.modelValue;
if (Array.isArray(val)) {
if (isChecked.value) {
val = pull(val, props.value!);
} else {
val.push(props.value!);
}
} else {
val = !val;
}
emit("update:modelValue", val);
emit("change", val);
}
}
</script>
<style lang="scss" scoped>
.cl-checkbox {
@apply flex flex-row items-center;
&--disabled {
@apply opacity-50;
}
}
</style>

View File

@@ -0,0 +1,20 @@
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClCheckboxPassThrough = {
className?: string;
icon?: ClIconProps;
label?: PassThroughProps;
};
export type ClCheckboxProps = {
className?: string;
pt?: ClCheckboxPassThrough;
modelValue?: any[] | boolean;
label?: string;
value?: any;
disabled?: boolean;
activeIcon?: string;
inactiveIcon?: string;
showIcon?: boolean;
};

View File

@@ -0,0 +1,106 @@
<template>
<view
class="cl-col"
:class="[
`cl-col-${span}`,
`cl-col-offset-${offset}`,
`cl-col-push-${push}`,
`cl-col-pull-${pull}`,
pt.className
]"
:style="{
paddingLeft: padding,
paddingRight: padding
}"
>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { parsePt, parseRpx, useParent } from "@/cool";
defineOptions({
name: "cl-col"
});
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 栅格占据的列数
span: {
type: Number,
default: 24
},
// 栅格左侧的间隔格数
offset: {
type: Number,
default: 0
},
// 栅格向右移动格数
push: {
type: Number,
default: 0
},
// 栅格向左移动格数
pull: {
type: Number,
default: 0
}
});
// 获取父组件实例
const parent = useParent<ClRowComponentPublicInstance>("cl-row");
// 透传类型定义
type PassThrough = {
// 自定义类名
className?: string;
};
// 解析透传配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 计算列的padding,用于实现栅格间隔
const padding = computed(() => (parent == null ? "0" : parseRpx(parent.gutter / 2)));
</script>
<style lang="scss" scoped>
@use "sass:math";
.cl-col {
@apply w-full overflow-visible;
}
@for $i from 1 through 24 {
.cl-col-push-#{$i},
.cl-col-pull-#{$i} {
position: relative;
}
}
@for $i from 1 through 24 {
$w: math.div(100%, 24);
.cl-col-#{$i} {
width: $w * $i;
}
.cl-col-offset-#{$i} {
margin-left: $w * $i;
}
.cl-col-pull-#{$i} {
right: $w * $i;
}
.cl-col-push-#{$i} {
left: $w * $i;
}
}
</style>

View File

@@ -0,0 +1,12 @@
export type ClColPassThrough = {
className?: string;
};
export type ClColProps = {
className?: string;
pt?: ClColPassThrough;
span?: number;
offset?: number;
push?: number;
pull?: number;
};

View File

@@ -0,0 +1,112 @@
<template>
<view class="cl-collapse" :style="{ height: `${height}px` }">
<view class="cl-collapse__content" :class="[pt.className]">
<slot></slot>
</view>
</view>
</template>
<script setup lang="ts">
import { getCurrentInstance, ref, computed, watch } from "vue";
import { parsePt } from "@/cool";
defineOptions({
name: "cl-collapse"
});
// 定义组件属性
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 折叠状态值
modelValue: {
type: Boolean,
default: false
}
});
// 获取组件实例
const { proxy } = getCurrentInstance()!;
// 透传样式类型定义
type PassThrough = {
className?: string;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 折叠展开状态
const isOpened = ref(false);
// 内容高度
const height = ref(0);
/**
* 显示折叠内容
*/
function show() {
isOpened.value = true;
// 获取内容区域高度
uni.createSelectorQuery()
.in(proxy)
.select(".cl-collapse__content")
.boundingClientRect((node) => {
height.value = (node as NodeInfo).height ?? 0;
})
.exec();
}
/**
* 隐藏折叠内容
*/
function hide() {
isOpened.value = false;
height.value = 0;
}
/**
* 切换折叠状态
*/
function toggle() {
if (isOpened.value) {
hide();
} else {
show();
}
}
// 监听折叠状态变化
watch(
computed(() => props.modelValue),
(val: boolean) => {
if (val) {
show();
} else {
hide();
}
}
);
defineExpose({
show,
hide,
toggle
});
</script>
<style lang="scss" scoped>
.cl-collapse {
@apply relative;
transition-property: height;
transition-duration: 0.2s;
&__content {
@apply absolute top-0 left-0 w-full pt-3;
}
}
</style>

View File

@@ -0,0 +1,9 @@
export type ClCollapsePassThrough = {
className?: string;
};
export type ClCollapseProps = {
className?: string;
pt?: ClCollapsePassThrough;
modelValue?: boolean;
};

View File

@@ -0,0 +1,194 @@
<template>
<cl-popup
v-model="visible"
:pt="{
className: '!rounded-[60rpx]'
}"
size="70%"
:show-close="false"
:show-header="false"
:mask-closable="false"
direction="center"
@mask-close="onAction('close')"
@closed="onClosed"
>
<view class="cl-confirm">
<cl-text
:pt="{
className: parseClass(['cl-confirm__title text-lg text-center font-bold mb-2'])
}"
>{{ config.title }}</cl-text
>
<cl-text
:pt="{
className: parseClass(['cl-confirm__message text-md text-center mb-8'])
}"
>{{ config.message }}</cl-text
>
<view class="cl-confirm__actions">
<cl-button
v-if="config.showCancel"
size="large"
text
rounded
border
type="info"
:pt="{
className: 'flex-1 h-[80rpx]'
}"
@tap="onAction('cancel')"
>{{ config.cancelText }}</cl-button
>
<cl-button
v-if="config.showConfirm"
size="large"
rounded
:loading="loading"
:pt="{
className: 'flex-1 h-[80rpx]'
}"
@tap="onAction('confirm')"
>{{ config.confirmText }}</cl-button
>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue";
import type { ClConfirmAction, ClConfirmOptions } from "../../types";
import { t } from "@/locale";
import { parseClass } from "@/cool";
// 控制弹窗显示/隐藏
const visible = ref(false);
// 控制弹窗是否关闭
const closed = ref(true);
// 确认弹窗配置项,包含标题、内容、按钮文本等
const config = reactive<ClConfirmOptions>({
title: "",
message: ""
});
// 控制确认按钮loading状态
const loading = ref(false);
// 显示loading
function showLoading() {
loading.value = true;
}
// 隐藏loading
function hideLoading() {
loading.value = false;
}
// 关闭弹窗
function close() {
visible.value = false;
}
/**
* 打开确认弹窗,并设置相关配置
* @param options ClConfirmOptions 配置项
*/
let timer: number = 0;
function open(options: ClConfirmOptions) {
const next = () => {
// 清除之前的定时器
clearTimeout(timer);
// 设置弹窗状态为打开
closed.value = false;
// 显示弹窗
visible.value = true;
// 设置弹窗标题
config.title = options.title;
// 设置弹窗内容
config.message = options.message;
// 是否显示取消按钮,默认显示
config.showCancel = options.showCancel ?? true;
// 是否显示确认按钮,默认显示
config.showConfirm = options.showConfirm ?? true;
// 取消按钮文本,默认"取消"
config.cancelText = options.cancelText ?? t("取消");
// 确认按钮文本,默认"确定"
config.confirmText = options.confirmText ?? t("确定");
// 显示时长默认0不自动关闭
config.duration = options.duration ?? 0;
// 回调函数
config.callback = options.callback;
// 关闭前钩子
config.beforeClose = options.beforeClose;
// 如果设置了显示时长且不为0则启动自动关闭定时器
if (config.duration != 0) {
// 设置定时器,在指定时长后自动关闭弹窗
// @ts-ignore
timer = setTimeout(() => {
// 调用关闭方法
close();
}, config.duration!);
}
};
if (closed.value) {
next();
} else {
setTimeout(() => {
next();
}, 360);
}
}
// 弹窗关闭后重置loading状态
function onClosed() {
hideLoading();
closed.value = true;
}
/**
* 处理用户操作(确认、取消、关闭)
* @param action ClConfirmAction 操作类型
*/
function onAction(action: ClConfirmAction) {
// 如果没有beforeClose钩子直接关闭并回调
if (config.beforeClose == null) {
visible.value = false;
if (config.callback != null) {
config.callback!(action);
}
} else {
// 有beforeClose钩子时传递操作方法
config.beforeClose!(action, {
close,
showLoading,
hideLoading
});
}
}
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-confirm {
@apply p-4;
&__actions {
@apply flex flex-row items-center justify-center;
}
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<view class="cl-countdown" :class="[pt.className]">
<view
class="cl-countdown__item"
:class="[`${item.isSplitor ? pt.splitor?.className : pt.text?.className}`]"
v-for="(item, index) in list"
:key="index"
>
<slot name="item" :item="item">
<cl-text>{{ item.value }}</cl-text>
</slot>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick, computed, type PropType, onMounted, onUnmounted } from "vue";
import type { PassThroughProps } from "../../types";
import { dayUts, get, has, isEmpty, parsePt } from "@/cool";
type Item = {
value: string;
isSplitor: boolean;
};
defineOptions({
name: "cl-countdown"
});
defineSlots<{
item(props: { item: Item }): any;
}>();
const props = defineProps({
// 样式穿透配置
pt: {
type: Object,
default: () => ({})
},
// 格式化模板,支持 {d}天{h}:{m}:{s} 格式
format: {
type: String,
default: "{h}:{m}:{s}"
},
// 是否隐藏为0的单位
hideZero: {
type: Boolean,
default: false
},
// 倒计时天数
day: {
type: Number,
default: 0
},
// 倒计时小时数
hour: {
type: Number,
default: 0
},
// 倒计时分钟数
minute: {
type: Number,
default: 0
},
// 倒计时秒数
second: {
type: Number,
default: 0
},
// 结束时间可以是Date对象或日期字符串
datetime: {
type: [Date, String] as PropType<Date | string>,
default: null
},
// 是否自动开始倒计时
auto: {
type: Boolean,
default: true
}
});
/**
* 组件事件定义
*/
const emit = defineEmits(["stop", "done", "change"]);
/**
* 样式穿透类型定义
*/
type PassThrough = {
className?: string;
text?: PassThroughProps;
splitor?: PassThroughProps;
};
// 解析样式穿透配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 定时器ID用于清除定时器
let timer: number = 0;
// 当前剩余秒数
const seconds = ref(0);
// 倒计时运行状态
const isRunning = ref(false);
// 显示列表
const list = ref<Item[]>([]);
/**
* 倒计时选项类型定义
*/
type Options = {
day?: number;
hour?: number;
minute?: number;
second?: number;
datetime?: Date | string;
};
/**
* 将时间单位转换为总秒数
* @param options 时间选项,支持天、时、分、秒或具体日期时间
* @returns 总秒数
*/
function toSeconds({ day, hour, minute, second, datetime }: Options) {
if (datetime != null) {
// 如果提供了具体日期时间,计算与当前时间的差值
const diff = dayUts(datetime).diff(dayUts());
return Math.max(0, Math.floor(diff / 1000));
} else {
// 否则将各个时间单位转换为秒数
return Math.max(
0,
(day ?? 0) * 86400 + (hour ?? 0) * 3600 + (minute ?? 0) * 60 + (second ?? 0)
);
}
}
/**
* 执行倒计时逻辑
* 计算剩余时间并格式化显示
*/
function countDown() {
// 计算天、时、分、秒,使用更简洁的计算方式
const totalSeconds = Math.floor(seconds.value);
const day = Math.floor(totalSeconds / 86400); // 86400 = 24 * 60 * 60
const hour = Math.floor((totalSeconds % 86400) / 3600); // 3600 = 60 * 60
const minute = Math.floor((totalSeconds % 3600) / 60);
const second = totalSeconds % 60;
// 格式化时间对象,用于模板替换
const t = {
d: day.toString(),
h: hour.toString().padStart(2, "0"),
m: minute.toString().padStart(2, "0"),
s: second.toString().padStart(2, "0")
};
// 控制是否隐藏零值初始为true表示隐藏
let isHide = true;
// 记录开始隐藏的位置索引,-1表示不隐藏
let start = -1;
// 根据格式模板生成显示列表
list.value = (props.format.split(/[{,}]/) as string[])
.map((e, i) => {
const value = has(t, e) ? (get(t, e) as string) : e;
const isSplitor = /^\D+$/.test(value);
if (props.hideZero) {
if (isHide && !isSplitor) {
if (value == "00" || value == "0" || isEmpty(value)) {
start = i;
isHide = true;
} else {
isHide = false;
}
}
}
return {
value,
isSplitor
} as Item;
})
.filter((e, i) => {
return !isEmpty(e.value) && (start == -1 ? true : start < i);
})
.filter((e, i) => {
if (i == 0 && e.isSplitor) {
return false;
}
return true;
});
// 触发change事件
emit("change", list.value);
}
/**
* 清除定时器并重置状态
*/
function clear() {
clearTimeout(timer);
timer = 0;
isRunning.value = false;
}
/**
* 停止倒计时
*/
function stop() {
clear();
emit("stop");
}
/**
* 倒计时结束处理
*/
function done() {
clear();
emit("done");
}
/**
* 继续倒计时
* 启动定时器循环执行倒计时逻辑
*/
function next() {
// 如果时间已到或正在运行,直接返回
if (seconds.value <= 0 || isRunning.value) {
return;
}
isRunning.value = true;
/**
* 倒计时循环函数
* 每秒执行一次,直到时间结束
*/
function loop() {
countDown();
if (seconds.value <= 0) {
done();
return;
} else {
seconds.value--;
// @ts-ignore
timer = setTimeout(() => loop(), 1000);
}
}
loop();
}
/**
* 开始倒计时
* @param options 可选的倒计时参数不传则使用props中的值
*/
function start(options: Options | null = null) {
nextTick(() => {
// 计算初始秒数
seconds.value = toSeconds({
day: options?.day ?? props.day,
hour: options?.hour ?? props.hour,
minute: options?.minute ?? props.minute,
second: options?.second ?? props.second,
datetime: options?.datetime ?? props.datetime
});
// 开始倒计时
if (props.auto) {
next();
} else {
countDown();
}
});
}
// 组件销毁前停止倒计时
onUnmounted(() => stop());
// 组件挂载前开始倒计时
onMounted(() => {
start();
// 监听时间单位变化,重新开始倒计时
watch(
computed(() => [props.day, props.hour, props.minute, props.second] as number[]),
() => {
start();
}
);
// 监听结束时间变化,重新开始倒计时
watch(
computed(() => props.datetime),
() => {
start();
}
);
});
defineExpose({
start,
stop,
done,
next,
isRunning
});
</script>
<style lang="scss" scoped>
.cl-countdown {
@apply flex flex-row items-center;
&__item {
@apply flex flex-row justify-center items-center;
}
}
</style>

View File

@@ -0,0 +1,20 @@
import type { PassThroughProps } from "../../types";
export type ClCountdownPassThrough = {
className?: string;
text?: PassThroughProps;
splitor?: PassThroughProps;
};
export type ClCountdownProps = {
className?: string;
pt?: ClCountdownPassThrough;
format?: string;
hideZero?: boolean;
day?: number;
hour?: number;
minute?: number;
second?: number;
datetime?: Date | string;
auto?: boolean;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
import type { PassThroughProps } from "../../types";
export type ClCropperPassThrough = {
className?: string;
image?: PassThroughProps;
op?: PassThroughProps;
opItem?: PassThroughProps;
mask?: PassThroughProps;
cropBox?: PassThroughProps;
};
export type ClCropperProps = {
className?: string;
pt?: ClCropperPassThrough;
cropWidth?: number;
cropHeight?: number;
maxScale?: number;
resizable?: boolean;
};

View File

@@ -0,0 +1,697 @@
<template>
<view
class="cl-draggable"
:class="[
{
'cl-draggable--columns': props.columns > 1
},
pt.className
]"
>
<!-- @vue-ignore -->
<view
v-for="(item, index) in list"
:key="getItemKey(item, index)"
class="cl-draggable__item"
:class="[
{
'cl-draggable__item--disabled': disabled,
'cl-draggable__item--dragging': dragging && dragIndex == index,
'cl-draggable__item--animating': dragging && dragIndex != index
}
]"
:style="getItemStyle(index)"
@touchstart="
(event: UniTouchEvent) => {
onTouchStart(event, index, 'touch');
}
"
@longpress="
(event: UniTouchEvent) => {
onTouchStart(event, index, 'longpress');
}
"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<slot
name="item"
:item="item"
:index="index"
:dragging="dragging"
:dragIndex="dragIndex"
:insertIndex="insertIndex"
>
</slot>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, ref, getCurrentInstance, type PropType, watch } from "vue";
import { isNull, parsePt, uuid } from "@/cool";
import type { PassThroughProps } from "../../types";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-draggable"
});
defineSlots<{
item(props: {
item: UTSJSONObject;
index: number;
dragging: boolean;
dragIndex: number;
insertIndex: number;
}): any;
}>();
// 项目位置信息类型定义
type ItemPosition = {
top: number;
left: number;
width: number;
height: number;
};
// 位移偏移量类型定义
type TranslateOffset = {
x: number;
y: number;
};
const props = defineProps({
/** PassThrough 样式配置 */
pt: {
type: Object,
default: () => ({})
},
/** 数据数组,支持双向绑定 */
modelValue: {
type: Array as PropType<UTSJSONObject[]>,
default: () => []
},
/** 是否禁用拖拽功能 */
disabled: {
type: Boolean,
default: false
},
/** 列数1为单列纵向布局>1为多列网格布局 */
columns: {
type: Number,
default: 1
},
// 是否需要长按触发
longPress: {
type: Boolean,
default: true
}
});
const emit = defineEmits(["update:modelValue", "change", "start", "end"]);
const { proxy } = getCurrentInstance()!;
// 透传样式类型定义
type PassThrough = {
className?: string;
ghost?: PassThroughProps;
};
/** PassThrough 样式解析 */
const pt = computed(() => parsePt<PassThrough>(props.pt));
/** 数据列表 */
const list = ref<UTSJSONObject[]>([]);
/** 是否正在拖拽 */
const dragging = ref(false);
/** 当前拖拽元素的原始索引 */
const dragIndex = ref(-1);
/** 预期插入的目标索引 */
const insertIndex = ref(-1);
/** 触摸开始时的Y坐标 */
const startY = ref(0);
/** 触摸开始时的X坐标 */
const startX = ref(0);
/** Y轴偏移量 */
const offsetY = ref(0);
/** X轴偏移量 */
const offsetX = ref(0);
/** 当前拖拽的数据项 */
const dragItem = ref<UTSJSONObject>({});
/** 所有项目的位置信息缓存 */
const itemPositions = ref<ItemPosition[]>([]);
/** 是否处于放下动画状态 */
const dropping = ref(false);
/** 动态计算的项目高度 */
const itemHeight = ref(0);
/** 动态计算的项目宽度 */
const itemWidth = ref(0);
/** 是否已开始排序模拟(防止误触) */
const sortingStarted = ref(false);
/**
* 重置所有拖拽相关的状态
* 在拖拽结束后调用,确保组件回到初始状态
*/
function reset() {
dragging.value = false; // 拖拽状态
dropping.value = false; // 放下动画状态
dragIndex.value = -1; // 拖拽元素索引
insertIndex.value = -1; // 插入位置索引
offsetX.value = 0; // X轴偏移
offsetY.value = 0; // Y轴偏移
dragItem.value = {}; // 拖拽的数据项
itemPositions.value = []; // 位置信息缓存
itemHeight.value = 0; // 动态计算的高度
itemWidth.value = 0; // 动态计算的宽度
sortingStarted.value = false; // 排序模拟状态
}
/**
* 计算网格布局中元素的位移偏移
* @param index 当前元素索引
* @param dragIdx 拖拽元素索引
* @param insertIdx 插入位置索引
* @returns 包含 x 和 y 坐标偏移的对象
*/
function calculateGridOffset(index: number, dragIdx: number, insertIdx: number): TranslateOffset {
const cols = props.columns;
// 计算当前元素在网格中的行列位置
const currentRow = Math.floor(index / cols);
const currentCol = index % cols;
// 计算元素在拖拽后的新位置索引
let newIndex = index;
if (dragIdx < insertIdx) {
// 向后拖拽dragIdx+1 到 insertIdx 之间的元素需要向前移动一位
if (index > dragIdx && index <= insertIdx) {
newIndex = index - 1;
}
} else if (dragIdx > insertIdx) {
// 向前拖拽insertIdx 到 dragIdx-1 之间的元素需要向后移动一位
if (index >= insertIdx && index < dragIdx) {
newIndex = index + 1;
}
}
// 计算新位置的行列坐标
const newRow = Math.floor(newIndex / cols);
const newCol = newIndex % cols;
// 使用动态计算的网格尺寸
const cellWidth = itemWidth.value;
const cellHeight = itemHeight.value;
// 计算实际的像素位移
const offsetX = (newCol - currentCol) * cellWidth;
const offsetY = (newRow - currentRow) * cellHeight;
return { x: offsetX, y: offsetY };
}
/**
* 计算网格布局的插入位置
* @param dragCenterX 拖拽元素中心点X坐标
* @param dragCenterY 拖拽元素中心点Y坐标
* @returns 最佳插入位置索引
*/
function calculateGridInsertIndex(dragCenterX: number, dragCenterY: number): number {
if (itemPositions.value.length == 0) {
return dragIndex.value;
}
let closestIndex = dragIndex.value;
let minDistance = Infinity;
// 使用欧几里得距离找到最近的网格位置(包括原位置)
for (let i = 0; i < itemPositions.value.length; i++) {
const position = itemPositions.value[i];
// 计算到元素中心点的距离
const centerX = position.left + position.width / 2;
const centerY = position.top + position.height / 2;
// 使用欧几里得距离公式
const distance = Math.sqrt(
Math.pow(dragCenterX - centerX, 2) + Math.pow(dragCenterY - centerY, 2)
);
// 更新最近的位置
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
/**
* 计算单列布局的插入位置
* @param clientY Y坐标
* @returns 最佳插入位置索引
*/
function calculateSingleColumnInsertIndex(clientY: number): number {
let closestIndex = dragIndex.value;
let minDistance = Infinity;
// 遍历所有元素,找到距离最近的元素中心
for (let i = 0; i < itemPositions.value.length; i++) {
const position = itemPositions.value[i];
// 计算到元素中心点的距离
const itemCenter = position.top + position.height / 2;
const distance = Math.abs(clientY - itemCenter);
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
/**
* 计算拖拽元素的最佳插入位置
* @param clientPosition 在主轴上的坐标仅用于单列布局的Y轴坐标
* @returns 最佳插入位置的索引
*/
function calculateInsertIndex(clientPosition: number): number {
// 如果没有位置信息,保持原位置
if (itemPositions.value.length == 0) {
return dragIndex.value;
}
// 根据布局类型选择计算方式
if (props.columns > 1) {
// 多列网格布局计算拖拽元素的中心点坐标使用2D坐标计算最近位置
const dragPos = itemPositions.value[dragIndex.value];
const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;
const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;
return calculateGridInsertIndex(dragCenterX, dragCenterY);
} else {
// 单列布局基于Y轴距离计算最近的元素中心
return calculateSingleColumnInsertIndex(clientPosition);
}
}
/**
* 计算单列布局的位移偏移
* @param index 元素索引
* @param dragIdx 拖拽元素索引
* @param insertIdx 插入位置索引
* @returns 位移偏移对象
*/
function calculateSingleColumnOffset(
index: number,
dragIdx: number,
insertIdx: number
): TranslateOffset {
if (dragIdx < insertIdx) {
// 向下拖拽dragIdx+1 到 insertIdx 之间的元素向上移动
if (index > dragIdx && index <= insertIdx) {
return { x: 0, y: -itemHeight.value };
}
} else if (dragIdx > insertIdx) {
// 向上拖拽insertIdx 到 dragIdx-1 之间的元素向下移动
if (index >= insertIdx && index < dragIdx) {
return { x: 0, y: itemHeight.value };
}
}
return { x: 0, y: 0 };
}
/**
* 计算非拖拽元素的位移偏移量
* @param index 元素索引
* @returns 包含 x 和 y 坐标偏移的对象
*/
function getItemTranslateOffset(index: number): TranslateOffset {
// 只在满足所有条件时才计算位移:拖拽中、非放下状态、已开始排序
if (!dragging.value || dropping.value || !sortingStarted.value) {
return { x: 0, y: 0 };
}
const dragIdx = dragIndex.value;
const insertIdx = insertIndex.value;
// 跳过正在拖拽的元素(拖拽元素由位置控制)
if (index == dragIdx) {
return { x: 0, y: 0 };
}
// 没有位置变化时不需要位移(拖回原位置)
if (dragIdx == insertIdx) {
return { x: 0, y: 0 };
}
// 根据布局类型计算位移
if (props.columns > 1) {
// 多列网格布局使用2D位移计算
return calculateGridOffset(index, dragIdx, insertIdx);
} else {
// 单列布局:使用简单的纵向位移
return calculateSingleColumnOffset(index, dragIdx, insertIdx);
}
}
/**
* 计算项目的完整样式对象
* @param index 项目索引
* @returns 样式对象
*/
function getItemStyle(index: number) {
const style = {};
const isCurrent = dragIndex.value == index;
// 多列布局时设置等宽分布
if (props.columns > 1) {
const widthPercent = 100 / props.columns;
style["flex-basis"] = `${widthPercent}%`;
style["width"] = `${widthPercent}%`;
style["box-sizing"] = "border-box";
}
// 放下动画期间,只保留基础样式
if (dropping.value) {
return style;
}
// 拖拽状态下的样式处理
if (dragging.value) {
if (isCurrent) {
// 拖拽元素:跟随移动
style["transform"] = `translate(${offsetX.value}px, ${offsetY.value}px)`;
style["z-index"] = "100";
} else {
// 其他元素:显示排序预览位移
const translateOffset = getItemTranslateOffset(index);
style["transform"] = `translate(${translateOffset.x}px, ${translateOffset.y}px)`;
}
}
return style;
}
/**
* 获取所有项目的位置信息
*/
async function getItemPosition(): Promise<void> {
return new Promise((resolve) => {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-draggable")
.boundingClientRect()
.exec((res) => {
const box = res[0] as NodeInfo;
itemWidth.value = (box.width ?? 0) / props.columns;
uni.createSelectorQuery()
.in(proxy)
.selectAll(".cl-draggable__item")
.boundingClientRect()
.exec((res) => {
const rects = res[0] as NodeInfo[];
const positions: ItemPosition[] = [];
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
if (i == 0) {
itemHeight.value = rect.height ?? 0;
}
positions.push({
top: rect.top ?? 0,
left: rect.left ?? 0,
width: itemWidth.value,
height: itemHeight.value
});
}
itemPositions.value = positions;
resolve();
});
});
});
}
/**
* 获取项目是否禁用
* @param index 项目索引
* @returns 是否禁用
*/
function getItemDisabled(index: number): boolean {
return !isNull(list.value[index]["disabled"]) && (list.value[index]["disabled"] as boolean);
}
/**
* 检查拖拽元素的中心点是否移动到其他元素区域
*/
function checkMovedToOtherElement(): boolean {
// 如果没有位置信息,默认未移出
if (itemPositions.value.length == 0) return false;
const dragIdx = dragIndex.value;
const dragPosition = itemPositions.value[dragIdx];
// 计算拖拽元素当前的中心点位置(考虑拖拽偏移)
const dragCenterX = dragPosition.left + dragPosition.width / 2 + offsetX.value;
const dragCenterY = dragPosition.top + dragPosition.height / 2 + offsetY.value;
// 根据布局类型采用不同的判断策略
if (props.columns > 1) {
// 多列网格布局:检查中心点是否与其他元素区域重叠
for (let i = 0; i < itemPositions.value.length; i++) {
if (i == dragIdx) continue;
const otherPosition = itemPositions.value[i];
const isOverlapping =
dragCenterX >= otherPosition.left &&
dragCenterX <= otherPosition.left + otherPosition.width &&
dragCenterY >= otherPosition.top &&
dragCenterY <= otherPosition.top + otherPosition.height;
if (isOverlapping) {
return true;
}
}
} else {
// 检查是否向上移动超过上一个元素的中线
if (dragIdx > 0) {
const prevPosition = itemPositions.value[dragIdx - 1];
const prevCenterY = prevPosition.top + prevPosition.height / 2;
if (dragCenterY <= prevCenterY) {
return true;
}
}
// 检查是否向下移动超过下一个元素的中线
if (dragIdx < itemPositions.value.length - 1) {
const nextPosition = itemPositions.value[dragIdx + 1];
const nextCenterY = nextPosition.top + nextPosition.height / 2;
if (dragCenterY >= nextCenterY) {
return true;
}
}
}
return false;
}
/**
* 触摸开始事件处理
* @param event 触摸事件对象
* @param index 触摸的项目索引
*/
async function onTouchStart(event: UniTouchEvent, index: number, type: string) {
// 如果是长按触发,但未开启长按功能,则直接返回
if (type == "longpress" && !props.longPress) return;
// 如果是普通触摸触发,但已开启长按功能,则直接返回
if (type == "touch" && props.longPress) return;
// 检查是否禁用或索引无效
if (props.disabled) return;
if (getItemDisabled(index)) return;
if (index < 0 || index >= list.value.length) return;
// 获取触摸点
const touch = event.touches[0];
// 初始化拖拽状态
dragging.value = true;
// 初始化拖拽索引
dragIndex.value = index;
insertIndex.value = index; // 初始插入位置为原位置
startX.value = touch.clientX;
startY.value = touch.clientY;
offsetX.value = 0;
offsetY.value = 0;
// 初始化拖拽数据项
dragItem.value = list.value[index];
// 先获取所有项目的位置信息,为后续计算做准备
await getItemPosition();
// 触发开始事件
emit("start", index);
// 震动
vibrate(1);
// 阻止事件冒泡
event.stopPropagation();
// 阻止默认行为
event.preventDefault();
}
/**
* 触摸移动事件处理
* @param event 触摸事件对象
*/
function onTouchMove(event: TouchEvent): void {
if (!dragging.value) return;
const touch = event.touches[0];
// 更新拖拽偏移量
offsetX.value = touch.clientX - startX.value;
offsetY.value = touch.clientY - startY.value;
// 智能启动排序模拟:只有移出原元素区域才开始
if (!sortingStarted.value) {
if (checkMovedToOtherElement()) {
sortingStarted.value = true;
}
}
// 只有开始排序模拟后才计算插入位置
if (sortingStarted.value) {
// 计算拖拽元素当前的中心点坐标
const dragPos = itemPositions.value[dragIndex.value];
const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;
const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;
// 根据布局类型选择坐标轴网格布局使用X坐标单列布局使用Y坐标
const dragCenter = props.columns > 1 ? dragCenterX : dragCenterY;
// 计算最佳插入位置
const newIndex = calculateInsertIndex(dragCenter);
if (newIndex != insertIndex.value) {
insertIndex.value = newIndex;
}
}
// 阻止默认行为
event.preventDefault();
}
/**
* 触摸结束事件处理
*/
function onTouchEnd(): void {
if (!dragging.value) return;
// 旧索引
const oldIndex = dragIndex.value;
// 新索引
const newIndex = insertIndex.value;
// 如果位置发生变化,立即更新数组
if (oldIndex != newIndex && newIndex >= 0) {
const newList = [...list.value];
const item = newList.splice(oldIndex, 1)[0];
newList.splice(newIndex, 0, item);
list.value = newList;
// 触发变化事件
emit("update:modelValue", list.value);
emit("change", list.value);
}
// 开始放下动画
dropping.value = true;
dragging.value = false;
// 重置所有状态
reset();
// 等待放下动画完成后重置所有状态
emit("end", newIndex >= 0 ? newIndex : oldIndex);
}
/**
* 根据平台选择合适的key
* @param item 数据项
* @param index 索引
* @returns 合适的key
*/
function getItemKey(item: UTSJSONObject, index: number): string {
// #ifdef MP
// 小程序环境使用 index 作为 key避免数据错乱
return `${index}`;
// #endif
// #ifndef MP
// 其他平台使用 uid提供更好的性能
return item["uid"] as string;
// #endif
}
watch(
computed(() => props.modelValue),
(val: UTSJSONObject[]) => {
list.value = val.map((e) => {
return {
uid: e["uid"] ?? uuid(),
...e
};
});
},
{
immediate: true
}
);
</script>
<style lang="scss" scoped>
.cl-draggable {
@apply flex-col relative overflow-visible;
&--columns {
@apply flex-row flex-wrap;
}
&__item {
@apply relative z-10;
// #ifdef APP-IOS
@apply transition-none opacity-100;
// #endif
&--dragging {
@apply opacity-80 z-20;
}
&--disabled {
@apply opacity-60;
}
&--animating {
@apply duration-200;
transition-property: transform;
}
}
}
</style>

View File

@@ -0,0 +1,15 @@
import type { PassThroughProps } from "../../types";
export type ClDraggablePassThrough = {
className?: string;
ghost?: PassThroughProps;
};
export type ClDraggableProps = {
className?: string;
pt?: ClDraggablePassThrough;
modelValue?: UTSJSONObject[];
disabled?: boolean;
columns?: number;
longPress?: boolean;
};

View File

@@ -0,0 +1,102 @@
<template>
<view
class="cl-empty"
:class="[
{
'cl-empty--fixed': fixed
},
pt.className
]"
>
<image
class="cl-empty__icon"
:src="`/static/empty/${icon}.png`"
:style="{
height: parseRpx(iconSize)
}"
mode="aspectFit"
v-if="showIcon"
></image>
<cl-text
:pt="{
className: parseClass([
'cl-empty__text text-sm text-surface-400',
{
'text-surface-100': isDark
}
])
}"
v-if="text"
>{{ text }}</cl-text
>
</view>
</template>
<script lang="ts" setup>
import { t } from "@/locale";
import { isDark, parseClass, parsePt, parseRpx } from "@/cool";
import { computed } from "vue";
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 空状态文本
text: {
type: String,
default: () => t("暂无数据")
},
// 空状态图标名称
icon: {
type: String,
default: "comm"
},
// 图标尺寸
iconSize: {
type: [Number, String],
default: 120
},
// 是否显示图标
showIcon: {
type: Boolean,
default: true
},
// 是否固定定位
fixed: {
type: Boolean,
default: true
}
});
// 透传样式类型定义
type PassThrough = {
className?: string; // 根元素类名
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
</script>
<style lang="scss" scoped>
.cl-empty {
@apply flex flex-col items-center justify-center w-full h-full;
pointer-events: none;
&--fixed {
@apply fixed top-0 left-0;
z-index: -1;
// #ifdef H5
z-index: 0;
// #endif
}
&__icon {
margin-bottom: 20rpx;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<view class="cl-filter-bar">
<slot></slot>
</view>
</template>
<script lang="ts" setup>
defineOptions({
name: "cl-filter-bar"
});
</script>
<style lang="scss" scoped>
.cl-filter-bar {
@apply flex flex-row;
}
</style>

View File

@@ -0,0 +1,3 @@
export type ClFilterBarProps = {
className?: string;
};

View File

@@ -0,0 +1,169 @@
<template>
<view class="cl-filter-item" :class="[pt.className]" @tap="onTap">
<slot>
<cl-text
:pt="{
className: parseClass([
[isActive, 'text-primary-500'],
'text-center',
pt.label?.className
])
}"
>{{ text }}</cl-text
>
<!-- 排序 -->
<cl-icon
v-if="type == 'sort' && sort != 'none'"
:name="`sort-${sort}`"
:pt="{
className: 'ml-1'
}"
></cl-icon>
<!-- 下拉框 -->
<cl-icon
v-if="type == 'select'"
name="arrow-down-s-line"
:pt="{
className: 'ml-1'
}"
></cl-icon>
</slot>
</view>
<cl-select
v-model="selectValue"
ref="selectRef"
:show-trigger="false"
:options="options"
></cl-select>
</template>
<script lang="ts" setup>
import { parsePt, parseClass } from "@/cool";
import { computed, onMounted, ref, watch, type PropType } from "vue";
import type { PassThroughProps, ClFilterItemType, ClSelectOption } from "../../types";
defineOptions({
name: "cl-filter-item"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
label: {
type: String,
default: ""
},
value: {
type: [String, Number, Boolean, Array] as PropType<any>,
required: true
},
type: {
type: String as PropType<ClFilterItemType>,
default: "switch"
},
options: {
type: Array as PropType<ClSelectOption[]>,
default: () => []
}
});
const emit = defineEmits(["change"]);
// 透传样式类型
type PassThrough = {
className?: string;
label?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// select组件的ref引用用于调用select的方法
const selectRef = ref<ClSelectComponentPublicInstance | null>(null);
// switch类型的激活状态
const isActive = ref(false);
// sort类型的排序状态可为"asc"、"desc"、"none"
const sort = ref("none");
// select类型的当前选中值
const selectValue = ref<any | null>(null);
// 根据类型动态计算显示文本
const text = computed(() => {
// 如果是select类型显示选中项的label
if (props.type == "select") {
return props.options.find((e) => e.value == selectValue.value)?.label ?? "";
} else {
// 其他类型直接显示label
return props.label;
}
});
// 点击事件,根据不同类型处理
function onTap() {
// 排序类型,切换排序状态
if (props.type == "sort") {
if (sort.value == "asc") {
sort.value = "desc";
} else if (sort.value == "desc") {
sort.value = "none";
} else {
sort.value = "asc";
}
emit("change", sort.value);
}
// 开关类型,切换激活状态
if (props.type == "switch") {
isActive.value = !isActive.value;
emit("change", isActive.value);
}
// 选择类型打开select组件
if (props.type == "select") {
// 打开select弹窗选择后回调
selectRef.value!.open((val) => {
emit("change", val);
});
}
}
// 组件挂载时监听props.value变化并同步到本地状态
onMounted(() => {
watch(
computed(() => props.value!),
(val: any) => {
switch (props.type) {
case "select":
// select类型同步选中值
selectValue.value = val as any;
break;
case "switch":
// switch类型同步激活状态
isActive.value = val as boolean;
break;
case "sort":
// sort类型同步排序状态
sort.value = val as string;
break;
}
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-filter-item {
@apply flex flex-row flex-1 justify-center items-center h-[72rpx];
}
</style>

View File

@@ -0,0 +1,15 @@
import type { PassThroughProps, ClFilterItemType, ClSelectOption } from "../../types";
export type ClFilterItemPassThrough = {
className?: string;
label?: PassThroughProps;
};
export type ClFilterItemProps = {
className?: string;
pt?: ClFilterItemPassThrough;
label?: string;
value: any;
type?: ClFilterItemType;
options?: ClSelectOption[];
};

View File

@@ -0,0 +1,287 @@
<template>
<view
class="cl-float-view"
:class="{
'no-dragging': !position.isDragging
}"
:style="viewStyle"
@touchstart="onTouchStart"
@touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { getSafeAreaHeight, getTabBarHeight, hasCustomTabBar, router } from "@/cool";
import { computed, reactive } from "vue";
import { clFooterOffset } from "../cl-footer/offset";
defineOptions({
name: "cl-float-view"
});
const props = defineProps({
// 图层
zIndex: {
type: Number,
default: 500
},
// 尺寸
size: {
type: Number,
default: 40
},
// 左边距
left: {
type: Number,
default: 10
},
// 底部距离
bottom: {
type: Number,
default: 10
},
// 距离边缘的间距
gap: {
type: Number,
default: 10
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 不吸附边缘
noSnapping: {
type: Boolean,
default: false
}
});
// 获取设备屏幕信息
const { screenWidth, statusBarHeight, screenHeight } = uni.getWindowInfo();
/**
* 悬浮按钮位置状态类型定义
*/
type Position = {
x: number; // 水平位置(左边距)
y: number; // 垂直位置(相对底部的距离)
isDragging: boolean; // 是否正在拖拽中
};
/**
* 悬浮按钮位置状态管理
* 控制按钮在屏幕上的位置和拖拽状态
*/
const position = reactive<Position>({
x: props.left, // 初始左边距10px
y: props.bottom, // 初始距离底部10px
isDragging: false // 初始状态为非拖拽
});
/**
* 拖拽操作状态类型定义
*/
type DragState = {
startX: number; // 拖拽开始时的X坐标
startY: number; // 拖拽开始时的Y坐标
};
/**
* 拖拽操作状态管理
* 记录拖拽过程中的关键信息
*/
const dragState = reactive<DragState>({
startX: 0,
startY: 0
});
/**
* 动态位置样式计算
* 根据当前位置和拖拽状态计算组件的CSS样式
*/
const viewStyle = computed(() => {
const style = {};
// 额外的底部偏移
let bottomOffset = 0;
// 标签页需要额外减去标签栏高度和安全区域
if (hasCustomTabBar()) {
bottomOffset += getTabBarHeight();
} else {
// 获取其他组件注入的底部偏移
bottomOffset += clFooterOffset.get();
}
// 设置水平位置
style["left"] = `${position.x}px`;
// 设置垂直位置(从底部计算)
style["bottom"] = `${bottomOffset + position.y}px`;
// 设置z-index
style["z-index"] = props.zIndex;
// 设置尺寸
style["width"] = `${props.size}px`;
// 设置高度
style["height"] = `${props.size}px`;
return style;
});
/**
* 计算垂直方向的边界限制
* @returns 返回最大Y坐标值距离底部的最大距离
*/
function calculateMaxY(): number {
let maxY = screenHeight - props.size;
// 根据导航栏状态调整顶部边界
if (router.isCustomNavbarPage()) {
// 自定义导航栏页面,只需减去状态栏高度
maxY -= statusBarHeight;
} else {
// 默认导航栏页面,减去导航栏高度(44px)和状态栏高度
maxY -= 44 + statusBarHeight;
}
// 标签页需要额外减去标签栏高度和安全区域
if (router.isTabPage()) {
maxY -= getTabBarHeight();
}
return maxY;
}
// 计算垂直边界
const maxY = calculateMaxY();
/**
* 触摸开始事件处理
* 初始化拖拽状态,记录起始位置和时间
*/
function onTouchStart(e: TouchEvent) {
// 如果禁用,直接返回
if (props.disabled) return;
// 确保有触摸点存在
if (e.touches.length > 0) {
const touch = e.touches[0];
// 记录拖拽开始的位置
dragState.startX = touch.clientX;
dragState.startY = touch.clientY;
// 标记为拖拽状态,关闭过渡动画
position.isDragging = true;
}
}
/**
* 触摸移动事件处理
* 实时更新按钮位置,实现拖拽效果
*/
function onTouchMove(e: TouchEvent) {
// 如果不在拖拽状态或没有触摸点,直接返回
if (!position.isDragging || e.touches.length == 0) return;
// 阻止默认的滚动行为
e.preventDefault();
const touch = e.touches[0];
// 计算相对于起始位置的偏移量
const deltaX = touch.clientX - dragState.startX;
const deltaY = dragState.startY - touch.clientY; // Y轴方向相反屏幕坐标系向下为正我们的bottom向上为正
// 计算新的位置
let newX = position.x + deltaX;
let newY = position.y + deltaY;
// 水平方向边界限制:确保按钮不超出屏幕左右边界
newX = Math.max(0, Math.min(screenWidth - props.size, newX));
// 垂直方向边界限制
let minY = 0;
// 非标签页时,底部需要考虑安全区域
if (!router.isTabPage()) {
minY += getSafeAreaHeight("bottom");
}
// 确保按钮不超出屏幕上下边界
newY = Math.max(minY, Math.min(maxY, newY));
// 更新按钮位置
position.x = newX;
position.y = newY;
// 更新拖拽起始点,为下次移动计算做准备
dragState.startX = touch.clientX;
dragState.startY = touch.clientY;
}
/**
* 执行边缘吸附逻辑
* 拖拽结束后自动将按钮吸附到屏幕边缘
*/
function performEdgeSnapping() {
const edgeThreshold = 60; // 吸附触发阈值(像素)
const edgePadding = props.gap; // 距离边缘的间距
// 判断按钮当前更靠近左边还是右边
const centerX = screenWidth / 2;
const isLeftSide = position.x < centerX;
// 水平方向吸附逻辑
if (position.x < edgeThreshold) {
// 距离左边缘很近,吸附到左边
position.x = edgePadding;
} else if (position.x > screenWidth - props.size - edgeThreshold) {
// 距离右边缘很近,吸附到右边
position.x = screenWidth - props.size - edgePadding;
} else if (isLeftSide) {
// 在左半屏且不在边缘阈值内,吸附到左边
position.x = edgePadding;
} else {
// 在右半屏且不在边缘阈值内,吸附到右边
position.x = screenWidth - props.size - edgePadding;
}
// 垂直方向边界修正
const verticalPadding = props.gap;
if (position.y > maxY - verticalPadding) {
position.y = maxY - verticalPadding;
}
if (position.y < verticalPadding) {
position.y = verticalPadding;
}
}
/**
* 触摸结束事件处理
* 结束拖拽状态并执行边缘吸附
*/
function onTouchEnd() {
// 如果不在拖拽状态,直接返回
if (!position.isDragging) return;
// 结束拖拽状态,恢复过渡动画
position.isDragging = false;
// 执行边缘吸附逻辑
if (!props.noSnapping) {
performEdgeSnapping();
}
}
</script>
<style lang="scss" scoped>
.cl-float-view {
@apply fixed transition-none;
&.no-dragging {
@apply duration-300;
transition-property: left, bottom;
}
}
</style>

View File

@@ -0,0 +1,10 @@
export type ClFloatViewProps = {
className?: string;
zIndex?: number;
size?: number;
left?: number;
bottom?: number;
gap?: number;
disabled?: boolean;
noSnapping?: boolean;
};

View File

@@ -0,0 +1,124 @@
<template>
<view class="cl-footer-placeholder" :style="{ height: height + 'px' }" v-if="visible"> </view>
<view class="cl-footer-wrapper" :class="[pt.wrapper?.className]">
<view
class="cl-footer"
:class="[
{
'is-dark': isDark
},
pt.className
]"
v-if="visible"
>
<view class="cl-footer__content" :class="[pt.content?.className]">
<slot></slot>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { getSafeAreaHeight, isDark, isHarmony, parsePt } from "@/cool";
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from "vue";
import type { PassThroughProps } from "../../types";
import { clFooterOffset } from "./offset";
defineOptions({
name: "cl-footer"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
// 最小高度,小于该高度时,不显示
minHeight: {
type: Number,
default: 30
},
// 监听值,触发更新
vt: {
type: Number,
default: 0
}
});
const { proxy } = getCurrentInstance()!;
type PassThrough = {
className?: string;
content?: PassThroughProps;
wrapper?: PassThroughProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 内容高度
const height = ref(0);
// 是否显示
const visible = ref(true);
// 获取内容高度
function getHeight() {
nextTick(() => {
setTimeout(
() => {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-footer")
.boundingClientRect((res) => {
// 获取内容高度
const h = Math.floor((res as NodeInfo).height ?? 0);
// 设置高度
height.value = h;
// 如果内容高度大于最小高度,则显示
visible.value = h > props.minHeight + getSafeAreaHeight("bottom");
// 隔离高度
clFooterOffset.set(visible.value ? h : 0);
})
.exec();
},
isHarmony() ? 50 : 0
);
});
}
onMounted(() => {
watch(
computed(() => props.vt),
() => {
visible.value = true;
getHeight();
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-footer {
@apply bg-white overflow-visible;
padding-bottom: env(safe-area-inset-bottom);
&.is-dark {
@apply bg-surface-900;
}
&__content {
@apply px-3 py-3 overflow-visible;
}
&-wrapper {
@apply fixed bottom-0 left-0 w-full overflow-visible;
}
}
</style>

View File

@@ -0,0 +1,16 @@
import { router } from "@/cool";
import { reactive } from "vue";
export class ClFooterOffset {
private data = reactive({});
set(value: number): void {
this.data[router.path()] = value;
}
get(): number {
return (this.data[router.path()] as number | null) ?? 0;
}
}
export const clFooterOffset = new ClFooterOffset();

View File

@@ -0,0 +1,14 @@
import type { PassThroughProps } from "../../types";
export type ClFooterPassThrough = {
className?: string;
content?: PassThroughProps;
wrapper?: PassThroughProps;
};
export type ClFooterProps = {
className?: string;
pt?: ClFooterPassThrough;
minHeight?: number;
vt?: number;
};

View File

@@ -0,0 +1,269 @@
<template>
<view
class="cl-form-item"
:class="[
{
'cl-form-item--error': isError
},
pt.className
]"
:id="`cl-form-item-${prop}`"
>
<view class="cl-form-item__inner" :class="[`is-${labelPosition}`, pt.inner?.className]">
<view
class="cl-form-item__label"
:class="[`is-${labelPosition}`, pt.label?.className]"
:style="{
width: labelPosition != 'top' ? labelWidth : 'auto'
}"
v-if="label != ''"
>
<cl-text>{{ label }}</cl-text>
<cl-text
color="error"
:pt="{
className: 'ml-1'
}"
v-if="showAsterisk"
>
*
</cl-text>
</view>
<view class="cl-form-item__content" :class="[pt.content?.className]">
<slot></slot>
</view>
</view>
<view class="cl-form-item__error" v-if="isError && showMessage">
<slot name="error" :error="errorText">
<cl-text
color="error"
:pt="{
className: parseClass(['mt-2 text-sm', pt.error?.className])
}"
>
{{ errorText }}
</cl-text>
</slot>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch, type PropType } from "vue";
import { isEqual, parseClass, parsePt } from "@/cool";
import type { ClFormLabelPosition, ClFormRule, PassThroughProps } from "../../types";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-form-item"
});
defineSlots<{
error(props: { error: string }): any;
}>();
// 组件属性定义
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 字段标签
label: {
type: String,
default: ""
},
// 字段名称
prop: {
type: String,
default: ""
},
// 字段验证规则
rules: {
type: Array as PropType<ClFormRule[]>,
default: () => []
},
// 标签位置
labelPosition: {
type: String as PropType<ClFormLabelPosition>,
default: null
},
// 标签宽度
labelWidth: {
type: String as PropType<string | null>,
default: null
},
// 是否显示必填星号
showAsterisk: {
type: Boolean as PropType<boolean | null>,
default: null
},
// 是否显示错误信息
showMessage: {
type: Boolean as PropType<boolean | null>,
default: null
},
// 是否必填
required: {
type: Boolean,
default: false
}
});
// cl-form 上下文
const { formRef, getError, getValue, validateField, addField, removeField, setRule } = useForm();
// 透传样式类型
type PassThrough = {
className?: string;
inner?: PassThroughProps;
label?: PassThroughProps;
content?: PassThroughProps;
error?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 当前错误信息
const errorText = computed<string>(() => {
return getError(props.prop);
});
// 是否有错误
const isError = computed<boolean>(() => {
return errorText.value != "";
});
// 当前标签位置
const labelPosition = computed<ClFormLabelPosition>(() => {
return props.labelPosition ?? formRef.value?.labelPosition ?? "left";
});
// 标签宽度
const labelWidth = computed<string>(() => {
return props.labelWidth ?? formRef.value?.labelWidth ?? "120rpx";
});
// 是否显示必填星号
const showAsterisk = computed<boolean>(() => {
if (!props.required) {
return false;
}
return props.showAsterisk ?? formRef.value?.showAsterisk ?? true;
});
// 是否显示错误信息
const showMessage = computed<boolean>(() => {
if (!props.required) {
return false;
}
return props.showMessage ?? formRef.value?.showMessage ?? true;
});
watch(
computed(() => props.required),
(val: boolean) => {
if (val) {
addField(props.prop, props.rules);
} else {
removeField(props.prop);
}
},
{
immediate: true
}
);
onMounted(() => {
// 监听字段值变化
watch(
computed(() => {
const value = getValue(props.prop);
if (value == null) {
return "";
}
return value;
}),
(a: any, b: any) => {
if (props.required) {
if (!isEqual(a, b)) {
validateField(props.prop);
}
}
},
{
deep: true
}
);
// 监听规则变化
watch(
computed(() => props.rules),
(val: ClFormRule[]) => {
setRule(props.prop, val);
},
{
deep: true
}
);
});
onUnmounted(() => {
removeField(props.prop);
});
defineExpose({
prop: props.prop
});
</script>
<style lang="scss" scoped>
.cl-form-item {
@apply w-full mb-6;
&__inner {
@apply w-full;
&.is-top {
@apply flex flex-col;
}
&.is-left {
@apply flex flex-row;
}
&.is-right {
@apply flex flex-row;
}
}
&__label {
@apply flex flex-row items-center;
&.is-top {
@apply w-full mb-2;
}
&.is-left {
@apply mr-3;
}
&.is-right {
@apply mr-3 justify-end;
}
}
&__content {
@apply relative flex-1 w-full;
}
}
</style>

View File

@@ -0,0 +1,22 @@
import type { ClFormLabelPosition, ClFormRule, PassThroughProps } from "../../types";
export type ClFormItemPassThrough = {
className?: string;
inner?: PassThroughProps;
label?: PassThroughProps;
content?: PassThroughProps;
error?: PassThroughProps;
};
export type ClFormItemProps = {
className?: string;
pt?: ClFormItemPassThrough;
label?: string;
prop?: string;
rules?: ClFormRule[];
labelPosition?: ClFormLabelPosition;
labelWidth?: string | any;
showAsterisk?: boolean | any;
showMessage?: boolean | any;
required?: boolean;
};

View File

@@ -0,0 +1,455 @@
<template>
<view
class="cl-form"
:class="[
`cl-form--label-${labelPosition}`,
{
'cl-form--disabled': disabled
},
pt.className
]"
>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, ref, watch, type PropType } from "vue";
import { get, isEmpty, isNull, isString, parsePt, parseToObject } from "@/cool";
import type { ClFormLabelPosition, ClFormRule, ClFormValidateError } from "../../types";
import { $t, t } from "@/locale";
import { usePage } from "../../hooks";
defineOptions({
name: "cl-form"
});
// 组件属性定义
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 表单数据模型
modelValue: {
type: Object as PropType<any>,
default: () => ({})
},
// 表单规则
rules: {
type: Object as PropType<Map<string, ClFormRule[]>>,
default: () => new Map<string, ClFormRule[]>()
},
// 标签位置
labelPosition: {
type: String as PropType<ClFormLabelPosition>,
default: "top"
},
// 标签宽度
labelWidth: {
type: String,
default: "140rpx"
},
// 是否显示必填星号
showAsterisk: {
type: Boolean,
default: true
},
// 是否显示错误信息
showMessage: {
type: Boolean,
default: true
},
// 是否禁用整个表单
disabled: {
type: Boolean,
default: false
},
// 滚动到第一个错误位置
scrollToError: {
type: Boolean,
default: true
}
});
const { proxy } = getCurrentInstance()!;
// cl-page 上下文
const page = usePage();
// 透传样式类型
type PassThrough = {
className?: string;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 表单数据
const data = ref({} as UTSJSONObject);
// 表单字段错误信息
const errors = ref(new Map<string, string>());
// 表单字段集合
const fields = ref(new Set<string>([]));
// 标签位置
const labelPosition = computed(() => props.labelPosition);
// 标签宽度
const labelWidth = computed(() => props.labelWidth);
// 是否显示必填星号
const showAsterisk = computed(() => props.showAsterisk);
// 是否显示错误信息
const showMessage = computed(() => props.showMessage);
// 是否禁用整个表单
const disabled = computed(() => props.disabled);
// 错误信息锁定
const errorLock = ref(false);
// 设置字段错误信息
function setError(prop: string, error: string) {
if (errorLock.value) {
return;
}
if (prop != "") {
errors.value.set(prop, error);
}
}
// 移除字段错误信息
function removeError(prop: string) {
if (prop != "") {
errors.value.delete(prop);
}
}
// 获取字段错误信息
function getError(prop: string): string {
if (prop != "") {
return errors.value.get(prop) ?? "";
}
return "";
}
// 获得错误信息,并滚动到第一个错误位置
async function getErrors(): Promise<ClFormValidateError[]> {
return new Promise((resolve) => {
// 错误信息
const errs = [] as ClFormValidateError[];
// 错误信息位置
const tops = new Map<string, number>();
// 完成回调,将错误信息添加到数组中
function done() {
tops.forEach((top, prop) => {
errs.push({
field: prop,
message: getError(prop)
});
});
// 滚动到第一个错误位置
if (props.scrollToError && errs.length > 0) {
page.scrollTo((tops.get(errs[0].field) ?? 0) + page.getScrollTop());
}
resolve(errs);
}
// 如果错误信息为空,直接返回
if (errors.value.size == 0) {
done();
return;
}
nextTick(() => {
let component = proxy;
// #ifdef MP
let num = 0; // 记录已处理的表单项数量
// 并查找其错误节点的位置
const deep = (el: any, index: number) => {
// 遍历当前节点的所有子节点
el?.$children.map((e: any) => {
// 限制递归深度,防止死循环
if (index < 5) {
// 判断是否为 cl-form-item 组件且 prop 存在
if (e.prop != null && e.$options.name == "cl-form-item") {
// 如果该字段已注册到 fields 中,则计数加一
if (fields.value.has(e.prop)) {
num += 1;
}
// 查询该 cl-form-item 下是否有错误节点,并获取其位置信息
uni.createSelectorQuery()
.in(e)
.select(".cl-form-item--error")
.boundingClientRect((res) => {
// 如果未获取到节点信息,直接返回
if (res == null) {
return;
}
// 记录该字段的错误节点 top 值
tops.set(e.prop, (res as NodeInfo).top!);
// 如果已处理的表单项数量达到总数,执行 done 回调
if (num >= fields.value.size) {
done();
}
})
.exec();
}
// 递归查找子节点
deep(e, index + 1);
}
});
};
deep(component, 0);
// #endif
// #ifndef MP
uni.createSelectorQuery()
.in(component)
.selectAll(".cl-form-item--error")
.boundingClientRect((res) => {
(res as NodeInfo[]).map((e) => {
tops.set((e.id ?? "").replace("cl-form-item-", ""), e.top ?? 0);
});
done();
})
.exec();
// #endif
});
});
}
// 清除所有错误信息
function clearErrors() {
errors.value.clear();
}
// 获取字段值
function getValue(prop: string): any | null {
if (prop != "") {
return get(data.value, prop, null);
}
return null;
}
// 获取字段规则
function getRule(prop: string): ClFormRule[] {
return props.rules.get(prop) ?? ([] as ClFormRule[]);
}
// 设置字段规则
function setRule(prop: string, rules: ClFormRule[]) {
if (prop != "" && !isEmpty(rules)) {
props.rules.set(prop, rules);
}
}
// 移除字段规则
function removeRule(prop: string) {
if (prop != "") {
props.rules.delete(prop);
}
}
// 注册表单字段
function addField(prop: string, rules: ClFormRule[]) {
if (prop != "") {
fields.value.add(prop);
setRule(prop, rules);
}
}
// 注销表单字段
function removeField(prop: string) {
if (prop != "") {
fields.value.delete(prop);
removeRule(prop);
removeError(prop);
}
}
// 验证单个规则
function validateRule(value: any | null, rule: ClFormRule): null | string {
// 必填验证
if (rule.required == true) {
if (
value == null ||
(value == "" && isString(value)) ||
(Array.isArray(value) && value.length == 0)
) {
return rule.message ?? t("此字段为必填项");
}
}
// 如果值为空且不是必填,直接通过
if ((value == null || (value == "" && isString(value))) && rule.required != true) {
return null;
}
// 最小长度验证
if (rule.min != null) {
if (typeof value == "number") {
if ((value as number) < rule.min) {
return rule.message ?? $t(`最小值为{min}`, { min: rule.min });
}
} else {
const len = Array.isArray(value) ? value.length : `${value}`.length;
if (len < rule.min) {
return rule.message ?? $t(`最少需要{min}个字符`, { min: rule.min });
}
}
}
// 最大长度验证
if (rule.max != null) {
if (typeof value == "number") {
if (value > rule.max) {
return rule.message ?? $t(`最大值为{max}`, { max: rule.max });
}
} else {
const len = Array.isArray(value) ? value.length : `${value}`.length;
if (len > rule.max) {
return rule.message ?? $t(`最多允许{max}个字符`, { max: rule.max });
}
}
}
// 正则验证
if (rule.pattern != null) {
if (!rule.pattern.test(`${value}`)) {
return rule.message ?? t("格式不正确");
}
}
// 自定义验证
if (rule.validator != null) {
const result = rule.validator(value);
if (result != true) {
return typeof result == "string" ? result : (rule.message ?? t("验证失败"));
}
}
return null;
}
// 清除所有验证
function clearValidate() {
errorLock.value = true;
nextTick(() => {
clearErrors();
errorLock.value = false;
});
}
// 验证单个字段
function validateField(prop: string): string | null {
let error = null as string | null;
if (prop != "") {
const value = getValue(prop);
const rules = getRule(prop);
if (!isEmpty(rules)) {
// 逐个验证规则
rules.find((rule) => {
const msg = validateRule(value, rule);
if (msg != null) {
error = msg;
return true;
}
return false;
});
}
// 移除错误信息
removeError(prop);
}
if (error != null) {
setError(prop, error!);
}
return error;
}
// 验证整个表单
async function validate(callback: (valid: boolean, errors: ClFormValidateError[]) => void) {
// 验证所有字段
fields.value.forEach((prop) => {
validateField(prop);
});
// 获取所有错误信息,并滚动到第一个错误位置
const errs = await getErrors();
// 回调
callback(errs.length == 0, errs);
}
watch(
computed(() => parseToObject(props.modelValue)),
(val: UTSJSONObject) => {
data.value = val;
},
{
immediate: true,
deep: true
}
);
defineExpose({
labelPosition,
labelWidth,
showAsterisk,
showMessage,
disabled,
data,
errors,
fields,
addField,
removeField,
getValue,
setError,
getError,
getErrors,
removeError,
clearErrors,
getRule,
setRule,
removeRule,
validateRule,
clearValidate,
validateField,
validate
});
</script>
<style lang="scss" scoped>
.cl-form {
@apply w-full;
}
</style>

View File

@@ -0,0 +1,18 @@
import type { ClFormLabelPosition, ClFormRule, ClFormValidateError } from "../../types";
export type ClFormPassThrough = {
className?: string;
};
export type ClFormProps = {
className?: string;
pt?: ClFormPassThrough;
modelValue?: any;
rules?: Map<string, ClFormRule[]>;
labelPosition?: ClFormLabelPosition;
labelWidth?: string;
showAsterisk?: boolean;
showMessage?: boolean;
disabled?: boolean;
scrollToError?: boolean;
};

View File

@@ -0,0 +1,162 @@
<template>
<text class="cl-icon" :class="[ptClassName]" :style="iconStyle" :key="cache.key">
{{ icon.text }}
</text>
</template>
<script setup lang="ts">
import { computed, type PropType } from "vue";
import {
forInObject,
get,
has,
parsePt,
useCache,
isDark,
ctx,
hasTextColor,
isNull
} from "@/cool";
import { icons } from "@/icons";
import { useSize } from "../../hooks";
defineOptions({
name: "cl-icon"
});
// 定义组件属性
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 图标名称
name: {
type: String,
default: ""
},
// 图标大小
size: {
type: [String, Number] as PropType<string | number>,
default: 32
},
// 图标高度
height: {
type: [String, Number] as PropType<string | number>,
default: null
},
// 图标宽度
width: {
type: [String, Number] as PropType<string | number>,
default: null
},
// 图标颜色
color: {
type: String,
default: ""
}
});
// 透传样式类型定义
type PassThrough = {
className?: string;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 缓存
const { cache } = useCache(() => [props.color]);
// 字号
const { getRpx, ptClassName } = useSize(() => pt.value.className ?? "");
// 图标类型定义
type Icon = {
font: string; // 字体名称
text: string; // 图标文本
};
// 图标信息
const icon = computed<Icon>(() => {
let font = "";
let text = "";
try {
let code = "";
// 遍历字体库查找对应图标
forInObject(icons, (value, key) => {
if (has(value, props.name)) {
font = key;
code = get(value, props.name) as string;
}
});
text = String.fromCharCode(parseInt(code, 16));
} catch (e) {
console.error(`图标 ${props.name} 不存在`, e);
}
return {
font,
text
};
});
// 图标颜色
const color = computed(() => {
if (props.color != "" && !isNull(props.color)) {
switch (props.color) {
case "primary":
return ctx.color["primary-500"] as string;
case "success":
return "#22c55e";
case "warn":
return "#eab308";
case "error":
return "#ef4444";
case "info":
return ctx.color["surface-500"] as string;
case "dark":
return ctx.color["surface-700"] as string;
case "light":
return ctx.color["surface-50"] as string;
case "disabled":
return ctx.color["surface-300"] as string;
default:
return props.color;
}
}
return isDark.value ? "white" : (ctx.color["surface-700"] as string);
});
// 图标样式
const iconStyle = computed(() => {
const style = {};
// 判断是不是有颜色样式
if (!hasTextColor(ptClassName.value)) {
style["color"] = color.value;
}
// 设置字体
if (icon.value.font != "") {
style["fontFamily"] = icon.value.font;
}
// 设置字体大小
style["fontSize"] = getRpx(props.size!);
// 设置高度
style["height"] = getRpx(props.height ?? props.size!);
style["lineHeight"] = getRpx(props.size!);
// 设置宽度
style["width"] = getRpx(props.width ?? props.size!);
return style;
});
</script>

View File

@@ -0,0 +1,13 @@
export type ClIconPassThrough = {
className?: string;
};
export type ClIconProps = {
className?: string;
pt?: ClIconPassThrough;
name?: string;
size?: string | number;
height?: string | number;
width?: string | number;
color?: string;
};

View File

@@ -0,0 +1,221 @@
<template>
<view
class="cl-image"
:class="[pt.className]"
:style="{
width: parseRpx(width!),
height: parseRpx(height!)
}"
>
<view
class="cl-image__error"
:class="[
{
'is-dark': isDark
},
pt.error?.className
]"
v-if="isError"
>
<slot name="error">
<cl-icon
:name="pt.error?.name ?? 'close-line'"
:size="pt.error?.size ?? 40"
:pt="{
className: parseClass(['!text-surface-400', pt.error?.className])
}"
></cl-icon>
</slot>
</view>
<view
class="cl-image__loading"
:class="[
{
'is-dark': isDark
},
pt.loading?.className
]"
v-else-if="isLoading && showLoading"
>
<slot name="loading">
<cl-loading :loading="true"></cl-loading>
</slot>
</view>
<image
class="cl-image__inner"
:class="[pt.inner?.className]"
:src="src"
:mode="mode"
:lazy-load="lazyLoad"
:webp="webp"
:show-menu-by-longpress="showMenuByLongpress"
@load="onLoad"
@error="onError"
@tap="onTap"
/>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed, ref, type PropType } from "vue";
import type { PassThroughProps } from "../../types";
import { isDark, isEmpty, parseClass, parsePt, parseRpx } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
defineOptions({
name: "cl-image"
});
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 图片源
src: {
type: String,
default: ""
},
// 图片裁剪、缩放的模式
mode: {
type: String as PropType<
| "scaleToFill"
| "aspectFit"
| "aspectFill"
| "widthFix"
| "heightFix"
| "top"
| "bottom"
| "center"
| "left"
| "right"
| "top left"
| "top right"
| "bottom left"
| "bottom right"
>,
default: "aspectFill"
},
// 是否显示边框
border: {
type: Boolean,
default: false
},
// 是否预览
preview: {
type: Boolean,
default: false
},
// 预览图片列表
previewList: {
type: Array as PropType<string[]>,
default: () => []
},
// 图片高度
height: {
type: [String, Number] as PropType<string | number>,
default: 120
},
// 图片宽度
width: {
type: [String, Number] as PropType<string | number>,
default: 120
},
// 是否显示加载状态
showLoading: {
type: Boolean,
default: true
},
// 是否懒加载
lazyLoad: {
type: Boolean,
default: false
},
// 图片显示动画效果
fadeShow: {
type: Boolean,
default: false
},
// 是否解码webp格式
webp: {
type: Boolean,
default: false
},
// 是否长按显示菜单
showMenuByLongpress: {
type: Boolean,
default: false
}
});
// 事件定义
const emit = defineEmits(["load", "error"]);
// 透传样式类型
type PassThrough = {
className?: string;
inner?: PassThroughProps;
error?: ClIconProps;
loading?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 加载状态
const isLoading = ref(true);
// 加载失败状态
const isError = ref(false);
// 图片加载成功
function onLoad(e: UniEvent) {
isLoading.value = false;
isError.value = false;
emit("load", e);
}
// 图片加载失败
function onError(e: UniEvent) {
isLoading.value = false;
isError.value = true;
emit("error", e);
}
// 图片点击
function onTap() {
if (props.preview) {
const urls = isEmpty(props.previewList) ? [props.src] : props.previewList;
uni.previewImage({
urls,
current: props.src
});
}
}
</script>
<style lang="scss" scoped>
.cl-image {
@apply relative flex flex-row items-center justify-center;
&__inner {
@apply w-full h-full rounded-xl;
}
&__loading,
&__error {
@apply absolute h-full w-full bg-surface-200 rounded-xl;
@apply flex flex-col items-center justify-center;
&.is-dark {
@apply bg-surface-700;
}
}
}
</style>

View File

@@ -0,0 +1,26 @@
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClImagePassThrough = {
className?: string;
inner?: PassThroughProps;
error?: ClIconProps;
loading?: PassThroughProps;
};
export type ClImageProps = {
className?: string;
pt?: ClImagePassThrough;
src?: string;
mode?: "scaleToFill" | "aspectFit" | "aspectFill" | "widthFix" | "heightFix" | "top" | "bottom" | "center" | "left" | "right" | "top left" | "top right" | "bottom left" | "bottom right";
border?: boolean;
preview?: boolean;
previewList?: string[];
height?: string | number;
width?: string | number;
showLoading?: boolean;
lazyLoad?: boolean;
fadeShow?: boolean;
webp?: boolean;
showMenuByLongpress?: boolean;
};

View File

@@ -0,0 +1,317 @@
<template>
<view class="cl-index-bar" :class="[pt.className]">
<view
class="cl-index-bar__list"
@touchstart="onTouchStart"
@touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd"
>
<view class="cl-index-bar__item" v-for="(item, index) in list" :key="index">
<view
class="cl-index-bar__item-inner"
:class="{
'is-active': activeIndex == index
}"
>
<text
class="cl-index-bar__item-text"
:class="{
'is-active': activeIndex == index || isDark
}"
>{{ item }}</text
>
</view>
</view>
</view>
</view>
<view class="cl-index-bar__alert" v-show="showAlert">
<view class="cl-index-bar__alert-icon dark:!bg-surface-800">
<view class="cl-index-bar__alert-arrow dark:!bg-surface-800"></view>
<text class="cl-index-bar__alert-text dark:!text-white">{{ alertText }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch, type PropType } from "vue";
import { isDark, isEmpty, parsePt } from "@/cool";
defineOptions({
name: "cl-index-bar"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
modelValue: {
type: Number,
default: 0
},
list: {
type: Array as PropType<string[]>,
default: () => []
}
});
const emit = defineEmits(["update:modelValue", "change"]);
const { proxy } = getCurrentInstance()!;
type PassThrough = {
className?: string;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 存储索引条整体的位置信息
const barRect = ref({
height: 0,
width: 0,
left: 0,
top: 0
} as NodeInfo);
// 存储所有索引项的位置信息数组
const itemsRect = ref<NodeInfo[]>([]);
// 是否正在触摸
const isTouching = ref(false);
// 是否显示提示弹窗
const showAlert = ref(false);
// 当前提示弹窗显示的文本
const alertText = ref("");
// 当前触摸过程中的临时索引
const activeIndex = ref(-1);
/**
* 获取索引条及其所有子项的位置信息
* 用于后续触摸时判断手指所在的索引项
*/
function getRect() {
nextTick(() => {
setTimeout(() => {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-index-bar")
.boundingClientRect()
.exec((bar) => {
if (isEmpty(bar)) {
return;
}
// 获取索引条整体的位置信息
barRect.value = bar[0] as NodeInfo;
// 获取所有索引项的位置信息
uni.createSelectorQuery()
.in(proxy)
.selectAll(".cl-index-bar__item")
.boundingClientRect()
.exec((items) => {
if (isEmpty(items)) {
getRect();
return;
}
itemsRect.value = items[0] as NodeInfo[];
});
});
}, 350);
});
}
/**
* 根据触摸点的Y坐标计算出最接近的索引项下标
* @param clientY 触摸点的Y坐标相对于屏幕
* @returns 最接近的索引项下标
*/
function getIndex(clientY: number): number {
if (itemsRect.value.length == 0) {
// 没有索引项时默认返回0
return 0;
}
// 初始化最接近的索引和最小距离
let closestIndex = 0;
let minDistance = Number.MAX_VALUE;
// 遍历所有索引项,找到距离触摸点最近的项
for (let i = 0; i < itemsRect.value.length; i++) {
const item = itemsRect.value[i];
// 计算每个item的中心点Y坐标
const itemCenterY = (item.top ?? 0) + (item.height ?? 0) / 2;
// 计算触摸点到中心点的距离
const distance = Math.abs(clientY - itemCenterY);
// 更新最小距离和索引
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
// 边界处理,防止越界
if (closestIndex < 0) {
closestIndex = 0;
} else if (closestIndex >= props.list.length) {
closestIndex = props.list.length - 1;
}
return closestIndex;
}
/**
* 更新触摸过程中的显示状态
* @param index 新的索引
*/
function updateActive(index: number) {
// 更新当前触摸索引
activeIndex.value = index;
// 更新弹窗提示文本
alertText.value = props.list[index];
}
/**
* 触摸开始事件处理
* @param e 触摸事件对象
*/
function onTouchStart(e: TouchEvent) {
// 标记为正在触摸
isTouching.value = true;
// 显示提示弹窗
showAlert.value = true;
// 获取第一个触摸点
const touch = e.touches[0];
// 计算对应的索引
const index = getIndex(touch.clientY);
// 更新显示状态
updateActive(index);
}
/**
* 触摸移动事件处理
* @param e 触摸事件对象
*/
function onTouchMove(e: TouchEvent) {
// 未处于触摸状态时不处理
if (!isTouching.value) return;
// 获取第一个触摸点
const touch = e.touches[0];
// 计算对应的索引
const index = getIndex(touch.clientY);
// 更新显示状态
updateActive(index);
}
/**
* 触摸结束事件处理
* 结束后延迟隐藏提示弹窗,并确认最终选中的索引
*/
function onTouchEnd() {
isTouching.value = false; // 标记为未触摸
// 更新值
if (props.modelValue != activeIndex.value) {
emit("update:modelValue", activeIndex.value);
emit("change", activeIndex.value);
}
// 延迟500ms后隐藏提示弹窗提升用户体验
setTimeout(() => {
showAlert.value = false;
}, 500);
}
watch(
computed(() => props.modelValue),
(val: number) => {
activeIndex.value = val;
},
{
immediate: true
}
);
onMounted(() => {
watch(
computed(() => props.list),
() => {
getRect();
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-index-bar {
@apply flex flex-col items-center justify-center;
@apply absolute bottom-0 right-0 h-full;
z-index: 110;
&__item {
@apply flex flex-col items-center justify-center;
width: 50rpx;
height: 34rpx;
&-inner {
@apply rounded-full flex flex-row items-center justify-center;
width: 30rpx;
height: 30rpx;
&.is-active {
@apply bg-primary-500;
}
}
&-text {
@apply text-xs text-surface-500;
&.is-active {
@apply text-white;
}
}
}
}
.cl-index-bar__alert {
@apply absolute bottom-0 right-8 h-full flex flex-col items-center justify-center;
width: 120rpx;
z-index: 110;
&-icon {
@apply rounded-full flex flex-row items-center justify-center;
@apply bg-surface-300;
height: 80rpx;
width: 80rpx;
overflow: visible;
}
&-arrow {
@apply bg-surface-300 absolute;
right: -8rpx;
height: 40rpx;
width: 40rpx;
transform: rotate(45deg);
}
&-text {
@apply text-white text-2xl;
}
}
</style>

View File

@@ -0,0 +1,10 @@
export type ClIndexBarPassThrough = {
className?: string;
};
export type ClIndexBarProps = {
className?: string;
pt?: ClIndexBarPassThrough;
modelValue?: number;
list?: string[];
};

View File

@@ -0,0 +1,333 @@
<template>
<view
class="cl-input-number"
:class="[
{
'cl-input-number--disabled': isDisabled
},
pt.className
]"
>
<view
class="cl-input-number__minus"
:class="[
{
'is-disabled': !isMinus
},
pt.op?.className,
pt.op?.minus?.className
]"
hover-class="!bg-surface-200"
:hover-stay-time="250"
:style="{
height: parseRpx(size!),
width: parseRpx(size!)
}"
@touchstart="onMinus"
@touchend="longPress.stop"
@touchcancel="longPress.stop"
>
<cl-icon
name="subtract-line"
:size="pt.op?.icon?.size ?? 36"
:color="pt.op?.icon?.color ?? 'info'"
:pt="{
className: pt.op?.icon?.className
}"
></cl-icon>
</view>
<view class="cl-input-number__value">
<cl-input
:model-value="`${value}`"
:type="inputType"
:disabled="isDisabled"
:clearable="false"
:readonly="inputable == false"
:placeholder="placeholder"
:hold-keyboard="false"
:pt="{
className: `!h-full w-[120rpx] ${pt.value?.className}`,
inner: {
className: `text-center ${pt.value?.input?.className}`
}
}"
@blur="onBlur"
></cl-input>
</view>
<view
class="cl-input-number__plus"
:class="[
{
'is-disabled': !isPlus
},
pt.op?.className,
pt.op?.plus?.className
]"
hover-class="!bg-primary-600"
:hover-stay-time="250"
:style="{
height: parseRpx(size!),
width: parseRpx(size!)
}"
@touchstart="onPlus"
@touchend="longPress.stop"
@touchcancel="longPress.stop"
>
<cl-icon
name="add-line"
:size="pt.op?.icon?.size ?? 36"
:color="pt.op?.icon?.color ?? 'white'"
:pt="{
className: pt.op?.icon?.className
}"
></cl-icon>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch, type PropType } from "vue";
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
import { useLongPress, parsePt, parseRpx } from "@/cool";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-input-number"
});
// 定义组件属性
const props = defineProps({
modelValue: {
type: Number,
default: 0
},
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 占位符 - 输入框为空时显示的提示文本
placeholder: {
type: String,
default: ""
},
// 步进值 - 点击加减按钮时改变的数值
step: {
type: Number,
default: 1
},
// 最大值 - 允许输入的最大数值
max: {
type: Number,
default: 100
},
// 最小值 - 允许输入的最小数值
min: {
type: Number,
default: 0
},
// 输入框类型 - digit表示带小数点的数字键盘,number表示纯数字键盘
inputType: {
type: String as PropType<"digit" | "number">,
default: "number"
},
// 是否可输入 - 控制是否允许手动输入数值
inputable: {
type: Boolean,
default: true
},
// 是否禁用 - 禁用后无法输入和点击加减按钮
disabled: {
type: Boolean,
default: false
},
// 组件大小 - 控制加减按钮的尺寸,支持数字或字符串形式
size: {
type: [Number, String] as PropType<number | string>,
default: 50
}
});
// 定义组件事件
const emit = defineEmits(["update:modelValue", "change"]);
// 长按操作
const longPress = useLongPress();
// cl-form 上下文
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => {
return disabled.value || props.disabled;
});
// 数值样式
type ValuePassThrough = {
className?: string;
input?: PassThroughProps;
};
// 操作按钮样式
type OpPassThrough = {
className?: string;
minus?: PassThroughProps;
plus?: PassThroughProps;
icon?: ClIconProps;
};
// 定义透传样式类型
type PassThrough = {
className?: string;
value?: ValuePassThrough;
op?: OpPassThrough;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 绑定值
const value = ref(props.modelValue);
// 是否可以继续增加数值
const isPlus = computed(() => !isDisabled.value && value.value < props.max);
// 是否可以继续减少数值
const isMinus = computed(() => !isDisabled.value && value.value > props.min);
/**
* 更新数值并触发事件
* 确保数值在最大值和最小值范围内
*/
function update() {
nextTick(() => {
let val = value.value;
// 处理小于最小值的情况
if (val < props.min) {
val = props.min;
}
// 处理大于最大值的情况
if (val > props.max) {
val = props.max;
}
// 处理最小值大于最大值的异常情况
if (props.min > props.max) {
val = props.max;
}
// 小数点后两位
if (props.inputType == "digit") {
val = parseFloat(val.toFixed(2));
}
// 更新值,确保值是数字
value.value = val;
// 如果值发生变化,则触发事件
if (val != props.modelValue) {
emit("update:modelValue", val);
emit("change", val);
}
});
}
/**
* 点击加号按钮处理函数 (支持长按)
* 在非禁用状态下增加step值
*/
function onPlus() {
if (isDisabled.value || !isPlus.value) return;
longPress.start(() => {
if (isPlus.value) {
const val = props.max - value.value;
value.value += val > props.step ? props.step : val;
update();
}
});
}
/**
* 点击减号按钮处理函数 (支持长按)
* 在非禁用状态下减少step值
*/
function onMinus() {
if (isDisabled.value || !isMinus.value) return;
longPress.start(() => {
if (isMinus.value) {
const val = value.value - props.min;
value.value -= val > props.step ? props.step : val;
update();
}
});
}
/**
* 输入框失去焦点处理函数
* @param val 输入的字符串值
*/
function onBlur(e: UniInputBlurEvent) {
if (e.detail.value == "") {
value.value = 0;
} else {
value.value = parseFloat(e.detail.value);
}
update();
}
// 监听绑定值变化
watch(
computed(() => props.modelValue),
(val: number) => {
value.value = val;
update();
},
{
immediate: true
}
);
// 监听最大值变化,确保当前值不超过新的最大值
watch(
computed(() => props.max),
update
);
// 监听最小值变化,确保当前值不小于新的最小值
watch(
computed(() => props.min),
update
);
</script>
<style lang="scss" scoped>
.cl-input-number {
@apply flex flex-row items-center;
&__plus,
&__minus {
@apply flex items-center justify-center rounded-md bg-surface-100;
&.is-disabled {
@apply opacity-50;
}
}
&__plus {
@apply bg-primary-500;
}
&__value {
@apply flex flex-row items-center justify-center h-full;
margin: 0 12rpx;
}
}
</style>

View File

@@ -0,0 +1,34 @@
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClInputNumberValuePassThrough = {
className?: string;
input?: PassThroughProps;
};
export type ClInputNumberOpPassThrough = {
className?: string;
minus?: PassThroughProps;
plus?: PassThroughProps;
icon?: ClIconProps;
};
export type ClInputNumberPassThrough = {
className?: string;
value?: ClInputNumberValuePassThrough;
op?: ClInputNumberOpPassThrough;
};
export type ClInputNumberProps = {
className?: string;
modelValue?: number;
pt?: ClInputNumberPassThrough;
placeholder?: string;
step?: number;
max?: number;
min?: number;
inputType?: "digit" | "number";
inputable?: boolean;
disabled?: boolean;
size?: number | string;
};

View File

@@ -0,0 +1,280 @@
<template>
<view
class="cl-input-otp"
:class="[
{
'cl-input-otp--disabled': disabled
},
pt.className
]"
>
<view class="cl-input-otp__inner" @tap="onCursor()">
<cl-input
v-model="value"
ref="inputRef"
:type="inputType"
:pt="{
className: '!h-full'
}"
:disabled="disabled"
:autofocus="autofocus"
:maxlength="length"
:hold-keyboard="false"
:clearable="false"
@change="onChange"
></cl-input>
</view>
<view class="cl-input-otp__list" :class="[pt.list?.className]">
<view
v-for="(item, index) in list"
:key="index"
class="cl-input-otp__item"
:class="[
{
'is-disabled': disabled,
'is-dark': isDark,
'is-active': value.length >= index && isFocus
},
pt.item?.className
]"
>
<cl-text
:color="value.length >= index && isFocus ? 'primary' : ''"
:pt="{
className: pt.value?.className
}"
>{{ item }}</cl-text
>
<view
ref="cursorRef"
class="cl-input-otp__cursor"
:class="[pt.cursor?.className]"
v-if="value.length == index && isFocus && item == ''"
></view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch, type PropType, type Ref } from "vue";
import type { ClInputType, PassThroughProps } from "../../types";
import { createAnimation, isDark, isEmpty, last, parsePt } from "@/cool";
defineOptions({
name: "cl-input-otp"
});
// 定义组件属性
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 绑定值
modelValue: {
type: String,
default: ""
},
// 是否自动聚焦
autofocus: {
type: Boolean,
default: false
},
// 验证码位数
length: {
type: Number,
default: 4
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 输入框类型
inputType: {
type: String as PropType<ClInputType>,
default: "number"
}
});
/**
* 事件定义
* update:modelValue - 更新绑定值
* done - 输入完成
*/
const emit = defineEmits(["update:modelValue", "done"]);
/**
* 透传样式类型定义
*/
type PassThrough = {
className?: string;
list?: PassThroughProps;
item?: PassThroughProps;
cursor?: PassThroughProps;
value?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 输入框引用
const inputRef = ref<ClInputComponentPublicInstance | null>(null);
// 光标引用
const cursorRef = ref<UniElement[]>([]);
// 输入值
const value = ref(props.modelValue);
/**
* 是否聚焦状态
*/
const isFocus = computed<boolean>(() => {
if (props.disabled) {
return false;
}
if (inputRef.value == null) {
return false;
}
return (inputRef.value as ClInputComponentPublicInstance).isFocus;
});
/**
* 验证码数组
* 根据长度生成空数组,每个位置填充对应的输入值
*/
const list = computed<string[]>(() => {
const arr = [] as string[];
for (let i = 0; i < props.length; i++) {
arr.push(value.value.charAt(i));
}
return arr;
});
/**
* 光标动画
*/
async function onCursor() {
await nextTick();
if (isEmpty(cursorRef.value)) {
return;
}
// 开始动画
// #ifdef APP
createAnimation(last(cursorRef.value), {
duration: 600,
loop: -1,
alternate: true
})
.opacity("0", "1")
.play();
// #endif
}
/**
* 输入事件处理
* @param val 输入值
*/
function onChange(val: string) {
// 更新绑定值
emit("update:modelValue", val);
// 输入完成时触发done事件
if (val.length == props.length) {
uni.hideKeyboard();
emit("done", val);
}
// 更新光标动画
onCursor();
}
onMounted(() => {
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
onCursor();
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-input-otp {
@apply relative;
&__inner {
@apply absolute top-0 h-full z-10;
opacity: 0;
// 小程序隐藏 placeholder
left: -100%;
width: 200%;
}
&__list {
@apply flex flex-row relative;
margin: 0 -10rpx;
}
&__item {
@apply flex flex-row items-center justify-center duration-100;
@apply border border-solid border-surface-200 rounded-lg bg-white;
height: 80rpx;
width: 80rpx;
margin: 0 10rpx;
&.is-disabled {
@apply bg-surface-100 opacity-70;
}
&.is-dark {
@apply bg-surface-800 border-surface-600;
&.is-disabled {
@apply bg-surface-700;
}
}
&.is-active {
@apply border-primary-500;
}
}
&__cursor {
@apply absolute bg-primary-500;
width: 2rpx;
height: 24rpx;
// #ifndef APP
animation: blink 1s infinite;
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
}
// #endif
}
&--disabled {
@apply opacity-50;
}
}
</style>

View File

@@ -0,0 +1,19 @@
import type { ClInputType, PassThroughProps } from "../../types";
export type ClInputOtpPassThrough = {
className?: string;
list?: PassThroughProps;
item?: PassThroughProps;
cursor?: PassThroughProps;
value?: PassThroughProps;
};
export type ClInputOtpProps = {
className?: string;
pt?: ClInputOtpPassThrough;
modelValue?: string;
autofocus?: boolean;
length?: number;
disabled?: boolean;
inputType?: ClInputType;
};

View File

@@ -0,0 +1,411 @@
<template>
<view
class="cl-input"
:class="[
pt.className,
{
'is-dark': isDark,
'cl-input--border': border,
'cl-input--focus': isFocus,
'cl-input--disabled': isDisabled,
'cl-input--error': isError
}
]"
@tap="onTap"
>
<slot name="prepend"></slot>
<view class="cl-input__icon !pl-0 pr-[12rpx]" v-if="prefixIcon">
<cl-icon
:name="prefixIcon"
:size="pt.prefixIcon?.size ?? 32"
:pt="{ className: parseClass([pt.prefixIcon?.className]) }"
></cl-icon>
</view>
<input
class="cl-input__inner"
:class="[
{
'is-disabled': isDisabled,
'is-dark': isDark
},
ptClassName
]"
:style="inputStyle"
:value="value"
:disabled="readonly ?? isDisabled"
:type="type"
:password="isPassword"
:focus="isFocus"
:placeholder="placeholder"
:placeholder-class="`text-surface-400 ${placeholderClass}`"
:maxlength="maxlength"
:cursor-spacing="cursorSpacing"
:confirm-type="confirmType"
:confirm-hold="confirmHold"
:adjust-position="adjustPosition"
:hold-keyboard="holdKeyboard"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@confirm="onConfirm"
@keyboardheightchange="onKeyboardheightchange"
/>
<view class="cl-input__icon" v-if="suffixIcon">
<cl-icon
:name="suffixIcon"
:size="pt.suffixIcon?.size ?? 32"
:pt="{ className: parseClass([pt.prefixIcon?.className]) }"
></cl-icon>
</view>
<view class="cl-input__icon" @tap="clear" v-if="showClear">
<cl-icon
name="close-circle-fill"
:size="32"
:pt="{ className: '!text-surface-400' }"
></cl-icon>
</view>
<view class="cl-input__icon" @tap="showPassword" v-if="password">
<cl-icon
:name="isPassword ? 'eye-line' : 'eye-off-line'"
:size="32"
:pt="{ className: '!text-surface-300' }"
></cl-icon>
</view>
<slot name="append"></slot>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch, type PropType } from "vue";
import type { ClInputType, PassThroughProps } from "../../types";
import { isDark, parseClass, parsePt } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import { t } from "@/locale";
import { useForm, useFormItem, useSize } from "../../hooks";
defineOptions({
name: "cl-input"
});
// 组件属性定义
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 绑定值
modelValue: {
type: String,
default: ""
},
// 输入框类型
type: {
type: String as PropType<ClInputType>,
default: "text"
},
// 前缀图标
prefixIcon: {
type: String,
default: ""
},
// 后缀图标
suffixIcon: {
type: String,
default: ""
},
// 是否密码框
password: {
type: Boolean,
default: false
},
// 是否自动聚焦
autofocus: {
type: Boolean,
default: false
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否只读
readonly: {
type: Boolean,
default: null
},
// 占位符
placeholder: {
type: String,
default: () => t("请输入")
},
// 占位符样式类
placeholderClass: {
type: String,
default: ""
},
// 是否显示边框
border: {
type: Boolean,
default: true
},
// 是否可清除
clearable: {
type: Boolean,
default: false
},
// 光标与键盘的距离
cursorSpacing: {
type: Number,
default: 5
},
// 点击键盘确认按钮时是否保持键盘不收起
confirmHold: {
type: Boolean,
default: false
},
// 设置键盘右下角按钮的文字
confirmType: {
type: String as PropType<"done" | "go" | "next" | "search" | "send">,
default: "done"
},
// 键盘弹起时,是否自动上推页面
adjustPosition: {
type: Boolean,
default: true
},
// 最大输入长度
maxlength: {
type: Number,
default: 140
},
// 是否保持键盘不收起
holdKeyboard: {
type: Boolean,
default: false
}
});
// 事件定义
const emit = defineEmits([
"update:modelValue",
"input",
"change",
"focus",
"blur",
"confirm",
"clear",
"keyboardheightchange"
]);
// cl-form 上下文
const { disabled } = useForm();
// cl-form-item 上下文
const { isError } = useFormItem();
// 是否禁用
const isDisabled = computed(() => {
return disabled.value || props.disabled;
});
// 透传样式类型定义
type PassThrough = {
className?: string;
inner?: PassThroughProps;
prefixIcon?: ClIconProps;
suffixIcon?: ClIconProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 字号
const { ptClassName, getSize } = useSize(() => pt.value.inner?.className ?? "");
// 输入框样式
const inputStyle = computed(() => {
const style = {};
// 字号
const fontSize = getSize(null);
if (fontSize != null) {
style["fontSize"] = fontSize;
}
return style;
});
// 绑定值
const value = ref<string>("");
// 是否聚焦
const isFocus = ref<boolean>(props.autofocus);
// 是否显示清除按钮
const showClear = computed(() => {
return isFocus.value && props.clearable && value.value != "";
});
// 是否显示密码
const isPassword = ref(props.password);
// 切换密码显示状态
function showPassword() {
isPassword.value = !isPassword.value;
}
// 获取焦点事件
function onFocus(e: UniInputFocusEvent) {
isFocus.value = true;
emit("focus", e);
}
// 失去焦点事件
function onBlur(e: UniInputBlurEvent) {
emit("blur", e);
setTimeout(() => {
isFocus.value = false;
}, 0);
}
// 输入事件
function onInput(e: UniInputEvent) {
const v1 = e.detail.value;
const v2 = value.value;
value.value = v1;
emit("update:modelValue", v1);
emit("input", e);
if (v1 != v2) {
emit("change", v1);
}
}
// 点击确认按钮事件
function onConfirm(e: UniInputConfirmEvent) {
emit("confirm", e);
}
// 键盘高度变化事件
function onKeyboardheightchange(e: UniInputKeyboardHeightChangeEvent) {
emit("keyboardheightchange", e);
}
// 点击事件
function onTap() {
if (isDisabled.value) {
return;
}
isFocus.value = true;
}
// 聚焦方法
function focus() {
setTimeout(() => {
isFocus.value = false;
nextTick(() => {
isFocus.value = true;
});
}, 0);
}
// 清除方法
function clear() {
value.value = "";
emit("update:modelValue", "");
emit("change", "");
emit("clear");
// #ifdef H5
focus();
// #endif
}
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
},
{
immediate: true
}
);
defineExpose({
isFocus,
focus,
clear
});
</script>
<style lang="scss" scoped>
.cl-input {
@apply flex flex-row items-center bg-white duration-200;
@apply rounded-lg;
height: 66rpx;
padding: 0 20rpx;
transition-property: background-color, border-color;
&__inner {
@apply h-full text-surface-700;
flex: 1;
font-size: 28rpx;
&.is-dark {
@apply text-white;
}
}
&__icon {
@apply flex items-center justify-center h-full;
padding-left: 20rpx;
}
&--border {
@apply border border-solid border-surface-200;
}
&--disabled {
@apply bg-surface-100 opacity-70;
}
&--focus {
&.cl-input--border {
@apply border-primary-500;
}
}
&--error {
@apply border-red-500;
}
&.is-dark {
@apply bg-surface-800;
&.cl-input--border {
@apply border-surface-600;
&.cl-input--focus {
@apply border-primary-500;
}
}
&.cl-input--disabled {
@apply bg-surface-700;
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
import type { ClInputType, PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClInputPassThrough = {
className?: string;
inner?: PassThroughProps;
prefixIcon?: ClIconProps;
suffixIcon?: ClIconProps;
};
export type ClInputProps = {
className?: string;
pt?: ClInputPassThrough;
modelValue?: string;
type?: ClInputType;
prefixIcon?: string;
suffixIcon?: string;
password?: boolean;
autofocus?: boolean;
disabled?: boolean;
readonly?: boolean;
placeholder?: string;
placeholderClass?: string;
border?: boolean;
clearable?: boolean;
cursorSpacing?: number;
confirmHold?: boolean;
confirmType?: "done" | "go" | "next" | "search" | "send";
adjustPosition?: boolean;
maxlength?: number;
holdKeyboard?: boolean;
};

View File

@@ -0,0 +1,354 @@
<template>
<cl-popup
:title="title"
:swipe-close-threshold="100"
:pt="{
inner: {
className: parseClass([
[isDark, '!bg-surface-700', '!bg-surface-100'],
pt.popup?.className
])
},
mask: {
className: '!bg-transparent'
}
}"
v-model="visible"
>
<view class="cl-keyboard-car" :class="[pt.className]">
<slot name="value" :value="value">
<view
v-if="showValue"
class="cl-keyboard-car__value"
:class="[pt.value?.className]"
>
<cl-text
v-if="value != ''"
:pt="{
className: 'text-2xl'
}"
>{{ valueText }}</cl-text
>
<cl-text
v-else
:pt="{
className: 'text-md text-surface-400'
}"
>{{ placeholder }}</cl-text
>
</view>
</slot>
<view class="cl-keyboard-car__list">
<view
class="cl-keyboard-car__rows"
v-for="(row, rowIndex) in list"
:key="rowIndex"
:class="[`is-mode-${mode}`]"
>
<view
v-for="(item, index) in row"
:key="item"
class="cl-keyboard-car__item"
:class="[
`is-keycode-${item}`,
{
'is-dark': isDark,
'is-empty': item == '',
'is-fill': rowIndex == 0 && mode == 'plate'
},
pt.item?.className
]"
:style="{
marginRight: index == row.length - 1 ? '0' : '10rpx'
}"
hover-class="opacity-50"
:hover-stay-time="250"
@touchstart.stop="onCommand(item)"
>
<slot name="item" :item="item">
<cl-icon
v-if="item == 'delete'"
name="delete-back-2-line"
:size="36"
></cl-icon>
<cl-icon
v-else-if="item == 'confirm'"
name="check-line"
:size="36"
color="white"
></cl-icon>
<cl-text v-else>{{ item }}</cl-text>
</slot>
</view>
</view>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { useUi } from "../../hooks";
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
import { ref, computed, watch } from "vue";
import { $t, t } from "@/locale";
import { isDark, parseClass, parsePt } from "@/cool";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-keyboard-car"
});
defineSlots<{
value(props: { value: string }): any;
item(props: { item: string }): any;
}>();
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// v-model绑定的值
modelValue: {
type: String,
default: ""
},
// 弹窗标题
title: {
type: String,
default: () => t("车牌键盘")
},
// 输入框占位符
placeholder: {
type: String,
default: () => t("安全键盘,请放心输入")
},
// 最大输入长度
maxlength: {
type: Number,
default: 8
},
// 是否显示输入值
showValue: {
type: Boolean,
default: true
},
// 是否输入即绑定
inputImmediate: {
type: Boolean,
default: false
}
});
// 定义事件发射器支持v-model和change事件
const emit = defineEmits(["update:modelValue", "change"]);
// 样式穿透类型
type PassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
// 样式穿透计算
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 获取UI相关的工具方法
const ui = useUi();
// 控制弹窗显示/隐藏
const visible = ref(false);
// 输入框当前值,双向绑定
const value = ref(props.modelValue);
// 键盘模式province: 省份简称区plate: 字母数字及特殊类型区
const mode = ref<"province" | "plate">("province");
// 输入框显示值
const valueText = computed(() => {
if (value.value.length > 2) {
return value.value.substring(0, 2) + " · " + value.value.substring(2);
}
return value.value;
});
// 数字键盘的按键列表包含数字、删除、00和小数点
const list = computed(() => {
// 车牌键盘的省份简称区
const province: string[][] = [
["京", "沪", "粤", "津", "冀", "豫", "云", "辽", "黑", "湘"],
["皖", "鲁", "新", "苏", "浙", "赣", "鄂", "桂", "甘"],
["晋", "蒙", "陕", "吉", "闽", "贵", "渝", "川"],
["青", "藏", "琼", "宁"]
];
// 车牌键盘的字母数字及特殊类型区
const plate: string[][] = [
["学", "警", "港", "澳", "领", "使", "电", "挂"],
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L", "M"],
["Z", "X", "C", "V", "B", "N", "M", "delete", "confirm"]
];
// 默认返回省份区,后续可根据输入位数切换键盘区
return mode.value == "province" ? province : plate;
});
// 打开键盘弹窗
function open() {
visible.value = true;
}
// 关闭键盘弹窗
function close() {
visible.value = false;
}
// 处理键盘按键点击事件
function onCommand(key: string) {
// 震动
vibrate(1);
// 确认按钮逻辑
if (key == "confirm") {
if (value.value == "") {
ui.showToast({
message: t("请输入内容")
});
return;
}
// 校验车牌号
if (value.value.length < 7) {
ui.showToast({
message: t("车牌号格式不正确")
});
return;
}
// 触发v-model和change事件
emit("update:modelValue", value.value);
emit("change", value.value);
// 关闭弹窗
close();
return;
}
// 删除键,去掉最后一位
if (key == "delete") {
value.value = value.value.slice(0, -1);
return;
}
// 超过最大输入长度,提示并返回
if (value.value.length >= props.maxlength) {
ui.showToast({
message: $t("最多输入{maxlength}位", {
maxlength: props.maxlength
})
});
return;
}
// 其他按键直接拼接到value
value.value += key;
}
watch(value, (val: string) => {
// 如果输入即绑定,则立即更新绑定值
if (props.inputImmediate) {
emit("update:modelValue", val);
emit("change", val);
}
// 根据输入位数切换键盘模式
mode.value = val.length < 1 ? "province" : "plate";
});
// 监听外部v-model的变化保持内部value同步
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
}
);
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-keyboard-car {
padding: 0 20rpx 20rpx 20rpx;
&__value {
@apply flex flex-row items-center justify-center;
height: 80rpx;
margin-bottom: 20rpx;
}
&__list {
@apply relative;
}
&__rows {
@apply flex flex-row items-center;
&.is-mode-province {
@apply justify-center;
}
&.is-mode-plate {
@apply justify-between;
}
}
&__item {
@apply flex items-center justify-center rounded-lg bg-white;
height: 80rpx;
width: 62rpx;
margin-top: 10rpx;
&.is-dark {
@apply bg-surface-800;
}
&.is-fill {
flex: 1;
}
&.is-keycode-delete {
@apply bg-surface-200;
flex: 1;
&.is-dark {
@apply bg-surface-600;
}
}
&.is-keycode-confirm {
@apply bg-primary-500;
flex: 1;
}
&.is-empty {
background-color: transparent !important;
}
}
}
</style>

View File

@@ -0,0 +1,20 @@
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
export type ClKeyboardCarPassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
export type ClKeyboardCarProps = {
className?: string;
pt?: ClKeyboardCarPassThrough;
modelValue?: string;
title?: string;
placeholder?: string;
maxlength?: number;
showValue?: boolean;
inputImmediate?: boolean;
};

View File

@@ -0,0 +1,431 @@
<template>
<cl-popup
:title="title"
:swipe-close-threshold="100"
:pt="{
inner: {
className: parseClass([
[isDark, '!bg-surface-700', '!bg-surface-100'],
pt.popup?.className
])
},
mask: {
className: '!bg-transparent'
}
}"
v-model="visible"
>
<view class="cl-keyboard-number" :class="[pt.className]">
<slot name="value" :value="value">
<view
v-if="showValue"
class="cl-keyboard-number__value"
:class="[pt.value?.className]"
>
<cl-text
v-if="value != ''"
:pt="{
className: 'text-2xl'
}"
>{{ value }}</cl-text
>
<cl-text
v-else
:pt="{
className: 'text-md text-surface-400'
}"
>{{ placeholder }}</cl-text
>
</view>
</slot>
<view class="cl-keyboard-number__list">
<cl-row :gutter="10">
<cl-col :span="18">
<cl-row :gutter="10">
<cl-col :span="8" v-for="item in list" :key="item">
<view
class="cl-keyboard-number__item"
:class="[
`is-keycode-${item}`,
{
'is-dark': isDark,
'is-empty': item == ''
},
pt.item?.className
]"
hover-class="opacity-50"
:hover-stay-time="250"
@touchstart.stop="onCommand(item)"
>
<slot name="item" :item="item">
<cl-icon
v-if="item == 'delete'"
name="delete-back-2-line"
:size="36"
></cl-icon>
<view
v-else-if="item == 'confirm'"
class="cl-keyboard-number__item-confirm"
>
<cl-text
color="white"
:pt="{
className: 'text-lg'
}"
>{{ confirmText }}</cl-text
>
</view>
<view v-else-if="item == '_confirm'"></view>
<cl-text
v-else
:pt="{
className: 'text-lg'
}"
>{{ item }}</cl-text
>
</slot>
</view>
</cl-col>
</cl-row>
</cl-col>
<cl-col :span="6">
<view class="cl-keyboard-number__op">
<view
v-for="item in opList"
:key="item"
class="cl-keyboard-number__item"
:class="[
{
'is-dark': isDark
},
`is-keycode-${item}`
]"
hover-class="opacity-50"
:hover-stay-time="250"
@touchstart.stop="onCommand(item)"
>
<cl-icon
v-if="item == 'delete'"
name="delete-back-2-line"
:size="36"
></cl-icon>
<cl-text
v-if="item == 'confirm'"
color="white"
:pt="{
className: 'text-lg'
}"
>{{ confirmText }}</cl-text
>
</view>
</view>
</cl-col>
</cl-row>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { useUi } from "../../hooks";
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
import { ref, computed, watch, type PropType } from "vue";
import { $t, t } from "@/locale";
import { isAppIOS, isDark, parseClass, parsePt } from "@/cool";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-keyboard-number"
});
defineSlots<{
value(props: { value: string }): any;
item(props: { item: string }): any;
}>();
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// v-model绑定的值
modelValue: {
type: String,
default: ""
},
// 键盘类型支持number、digit、idcard
type: {
type: String as PropType<"number" | "digit" | "idcard">,
default: "digit"
},
// 弹窗标题
title: {
type: String,
default: () => t("数字键盘")
},
// 输入框占位符
placeholder: {
type: String,
default: () => t("安全键盘,请放心输入")
},
// 最大输入长度
maxlength: {
type: Number,
default: 10
},
// 确认按钮文本
confirmText: {
type: String,
default: () => t("确定")
},
// 是否显示输入值
showValue: {
type: Boolean,
default: true
},
// 是否输入即绑定
inputImmediate: {
type: Boolean,
default: false
}
});
// 定义事件发射器支持v-model和change事件
const emit = defineEmits(["update:modelValue", "change"]);
// 样式穿透类型
type PassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
// 样式穿透计算
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 获取UI相关的工具方法
const ui = useUi();
// 控制弹窗显示/隐藏
const visible = ref(false);
// 输入框当前值,双向绑定
const value = ref(props.modelValue);
// 最大输入长度
const maxlength = computed(() => {
if (props.type == "idcard") {
return 18;
}
return props.maxlength;
});
// 数字键盘的按键列表包含数字、删除、00和小数点
const list = computed(() => {
const arr = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "00", "0", ""];
// 数字键盘显示为小数点 "."
if (props.type == "digit") {
arr[11] = ".";
}
// 身份证键盘显示为 "X"
if (props.type == "idcard") {
arr[11] = "X";
}
return arr;
});
// 操作按钮列表
const opList = computed(() => {
return ["delete", "confirm"];
});
// 打开键盘弹窗
function open() {
visible.value = true;
}
// 关闭键盘弹窗
function close() {
visible.value = false;
}
// 处理键盘按键点击事件
function onCommand(key: string) {
// 震动
try {
vibrate(1);
} catch (error) {}
// 确认按钮逻辑
if (key == "confirm" || key == "_confirm") {
if (value.value == "") {
ui.showToast({
message: t("请输入内容")
});
return;
}
// 如果最后一位是小数点,去掉
if (value.value.endsWith(".")) {
value.value = value.value.slice(0, -1);
}
// 身份证号码正则校验支持15位和18位18位末尾可为X/x
if (props.type == "idcard") {
if (
!/^(^[1-9]\d{5}(18|19|20)?\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}(\d|X|x)?$)$/.test(
value.value
)
) {
ui.showToast({
message: t("身份证号码格式不正确")
});
return;
}
}
// 触发v-model和change事件
emit("update:modelValue", value.value);
emit("change", value.value);
// 关闭弹窗
close();
return;
}
// 删除键,去掉最后一位
if (key == "delete") {
value.value = value.value.slice(0, -1);
return;
}
// 超过最大输入长度,提示并返回
if (value.value.length >= maxlength.value) {
ui.showToast({
message: $t("最多输入{maxlength}位", {
maxlength: maxlength.value
})
});
return;
}
// 处理小数点输入,已存在则不再添加
if (key == ".") {
if (value.value.includes(".")) {
return;
}
if (value.value == "") {
value.value = "0.";
return;
}
}
// 处理00键首位不能输入00只能输入0
if (key == "00") {
if (value.value.length + 2 > maxlength.value) {
value.value += "0";
return;
}
if (value.value == "") {
value.value = "0";
return;
}
}
if (key == "00" || key == "0") {
if (value.value == "" || value.value == "0") {
value.value = "0";
return;
}
}
// 其他按键直接拼接到value
value.value += key;
}
watch(value, (val: string) => {
// 如果输入即绑定,则立即更新绑定值
if (props.inputImmediate) {
emit("update:modelValue", val);
emit("change", val);
}
});
// 监听外部v-model的变化保持内部value同步
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
}
);
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-keyboard-number {
padding: 0 20rpx 20rpx 20rpx;
&__value {
@apply flex flex-row items-center justify-center;
height: 80rpx;
margin-bottom: 20rpx;
}
&__list {
@apply relative overflow-visible;
}
&__op {
@apply flex flex-col h-full;
}
&__item {
@apply flex items-center justify-center rounded-xl bg-white overflow-visible;
height: 100rpx;
margin-top: 10rpx;
&.is-dark {
@apply bg-surface-800;
}
&.is-keycode-delete {
@apply bg-surface-200;
&.is-dark {
@apply bg-surface-800;
}
}
&.is-keycode-confirm {
@apply flex flex-col items-center justify-center;
@apply bg-primary-500 rounded-xl flex-1;
}
&.is-empty {
background-color: transparent !important;
}
}
}
</style>

View File

@@ -0,0 +1,22 @@
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
export type ClKeyboardNumberPassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
export type ClKeyboardNumberProps = {
className?: string;
pt?: ClKeyboardNumberPassThrough;
modelValue?: string;
type?: "number" | "digit" | "idcard";
title?: string;
placeholder?: string;
maxlength?: number;
confirmText?: string;
showValue?: boolean;
inputImmediate?: boolean;
};

View File

@@ -0,0 +1,450 @@
<template>
<cl-popup
:title="title"
:swipe-close-threshold="100"
:pt="{
inner: {
className: parseClass([
[isDark, '!bg-surface-700', '!bg-surface-100'],
pt.popup?.className
])
},
mask: {
className: '!bg-transparent'
}
}"
v-model="visible"
>
<view class="cl-keyboard-password" :class="[pt.className]">
<slot name="value" :value="value">
<view
v-if="showValue"
class="cl-keyboard-password__value"
:class="[pt.value?.className]"
>
<cl-text
v-if="value != ''"
:pt="{
className: 'text-2xl'
}"
>{{ valueText }}</cl-text
>
<cl-text
v-else
:pt="{
className: 'text-md text-surface-400'
}"
>{{ placeholder }}</cl-text
>
</view>
</slot>
<view class="cl-keyboard-password__list">
<view
class="cl-keyboard-password__rows"
v-for="(row, rowIndex) in list"
:key="rowIndex"
:class="[`is-mode-${mode}`]"
>
<view
v-for="(item, index) in row"
:key="item"
class="cl-keyboard-password__item"
:class="[
`is-keycode-${item}`,
{
'is-empty': item == '',
'is-dark': isDark
},
pt.item?.className
]"
:style="{
marginRight: index == row.length - 1 ? '0' : '10rpx'
}"
hover-class="opacity-50"
:hover-stay-time="250"
@touchstart.stop="onCommand(item)"
>
<slot name="item" :item="item">
<cl-icon
v-if="item == 'delete'"
name="delete-back-2-line"
:size="36"
></cl-icon>
<cl-text
v-else-if="item == 'confirm'"
color="white"
:pt="{
className: 'text-lg'
}"
>{{ confirmText }}</cl-text
>
<cl-text
v-else-if="item == 'letter'"
:pt="{
className: 'text-lg'
}"
>ABC</cl-text
>
<cl-text
v-else-if="item == 'number'"
:pt="{
className: 'text-lg'
}"
>123</cl-text
>
<template v-else-if="item == 'caps'">
<cl-icon name="upload-line" :size="36"></cl-icon>
<cl-badge
dot
position
type="info"
:pt="{
className: '!right-1 !top-1'
}"
v-if="mode == 'letterUpper'"
></cl-badge>
</template>
<cl-text
v-else
:pt="{
className: 'text-lg'
}"
>{{ item }}</cl-text
>
</slot>
</view>
</view>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { useUi } from "../../hooks";
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
import { ref, computed, watch } from "vue";
import { $t, t } from "@/locale";
import { isDark, parseClass, parsePt } from "@/cool";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-keyboard-password"
});
defineSlots<{
value(props: { value: string }): any;
item(props: { item: string }): any;
}>();
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// v-model绑定的值
modelValue: {
type: String,
default: ""
},
// 弹窗标题
title: {
type: String,
default: () => t("密码键盘")
},
// 输入框占位符
placeholder: {
type: String,
default: () => t("安全键盘,请放心输入")
},
// 最小输入长度
minlength: {
type: Number,
default: 6
},
// 最大输入长度
maxlength: {
type: Number,
default: 20
},
// 确认按钮文本
confirmText: {
type: String,
default: () => t("确定")
},
// 是否显示输入值
showValue: {
type: Boolean,
default: true
},
// 是否输入即绑定
inputImmediate: {
type: Boolean,
default: false
},
// 是否加密
encrypt: {
type: Boolean,
default: false
}
});
// 定义事件发射器支持v-model和change事件
const emit = defineEmits(["update:modelValue", "change"]);
// 样式穿透类型
type PassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
// 样式穿透计算
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 获取UI相关的工具方法
const ui = useUi();
// 控制弹窗显示/隐藏
const visible = ref(false);
// 输入框当前值,双向绑定
const value = ref(props.modelValue);
// 键盘模式letter: 字母区number: 数字区
const mode = ref<"letter" | "letterUpper" | "number">("letter");
// 输入框显示值
const valueText = computed(() => {
if (props.encrypt) {
return "*".repeat(value.value.length);
}
return value.value;
});
// 数字键盘的按键列表包含数字、删除、00和小数点
const list = computed(() => {
// 字母键盘的字母区
const letter: string[][] = [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l", "m"],
["caps", "z", "x", "c", "v", "b", "n", "m", "delete"],
["number", "space", "confirm"]
];
// 大写字母键盘的字母区
const letterUpper: string[][] = [
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L", "M"],
["caps", "Z", "X", "C", "V", "B", "N", "M", "delete"],
["number", "space", "confirm"]
];
// 数字键盘的数字区
const number: string[][] = [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["#", "/", ":", ";", "(", ")", "^", "*", "+"],
["-", "=", "|", "~", "$", "&", ".", ",", "delete"],
["letter", "%", "?", "!", "{", "}", "confirm"]
];
switch (mode.value) {
case "letter":
return letter;
case "letterUpper":
return letterUpper;
case "number":
return number;
default:
return letter;
}
});
// 打开键盘弹窗
function open() {
visible.value = true;
}
// 关闭键盘弹窗
function close() {
visible.value = false;
}
// 处理键盘按键点击事件
function onCommand(key: string) {
// 震动
vibrate(1);
// 大写字母键盘
if (key == "caps") {
mode.value = mode.value == "letter" ? "letterUpper" : "letter";
return;
}
// 字母键盘
if (key == "letter") {
mode.value = "letter";
return;
}
// 数字键盘
if (key == "number") {
mode.value = "number";
return;
}
// 确认按钮逻辑
if (key == "confirm") {
if (value.value == "") {
ui.showToast({
message: t("请输入内容")
});
return;
}
// 校验密码长度
if (value.value.length < props.minlength || value.value.length > props.maxlength) {
ui.showToast({
message: $t("请输入{minlength}到{maxlength}位密码", {
minlength: props.minlength,
maxlength: props.maxlength
})
});
return;
}
// 触发v-model和change事件
emit("update:modelValue", value.value);
emit("change", value.value);
// 关闭弹窗
close();
return;
}
// 删除键,去掉最后一位
if (key == "delete") {
value.value = value.value.slice(0, -1);
return;
}
// 超过最大输入长度,提示并返回
if (value.value.length >= props.maxlength) {
ui.showToast({
message: $t("最多输入{maxlength}位", {
maxlength: props.maxlength
})
});
return;
}
// 其他按键直接拼接到value
value.value += key;
}
watch(value, (val: string) => {
// 如果输入即绑定,则立即更新绑定值
if (props.inputImmediate) {
emit("update:modelValue", val);
emit("change", val);
}
});
// 监听外部v-model的变化保持内部value同步
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
}
);
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-keyboard-password {
padding: 0 20rpx 20rpx 20rpx;
&__value {
@apply flex flex-row items-center justify-center;
height: 80rpx;
margin-bottom: 20rpx;
}
&__list {
@apply relative;
}
&__rows {
@apply flex flex-row items-center;
&.is-mode-province {
@apply justify-center;
}
&.is-mode-plate {
@apply justify-between;
}
}
&__item {
@apply flex items-center justify-center rounded-lg relative bg-white;
height: 80rpx;
width: 62rpx;
margin-top: 10rpx;
flex: 1;
&.is-dark {
@apply bg-surface-800;
}
&.is-keycode-number,
&.is-keycode-letter {
width: 150rpx;
flex: none;
}
&.is-keycode-caps,
&.is-keycode-delete {
width: 80rpx;
flex: none;
}
&.is-keycode-letter,
&.is-keycode-number,
&.is-keycode-caps,
&.is-keycode-delete {
@apply bg-surface-200;
&.is-dark {
@apply bg-surface-600;
}
}
&.is-keycode-confirm {
@apply bg-primary-500;
width: 150rpx !important;
flex: none;
}
&.is-empty {
background-color: transparent !important;
}
}
}
</style>

View File

@@ -0,0 +1,23 @@
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
export type ClKeyboardPasswordPassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
export type ClKeyboardPasswordProps = {
className?: string;
pt?: ClKeyboardPasswordPassThrough;
modelValue?: string;
title?: string;
placeholder?: string;
minlength?: number;
maxlength?: number;
confirmText?: string;
showValue?: boolean;
inputImmediate?: boolean;
encrypt?: boolean;
};

View File

@@ -0,0 +1,436 @@
<template>
<view
class="cl-list-item"
:class="[
{
'cl-list-item--disabled': disabled
},
pt.className
]"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@touchmove="onTouchMove"
@touchcancel="onTouchCancel"
>
<view
class="cl-list-item__wrapper"
:class="[
{
'is-transition': !isHover,
[isDark ? 'bg-surface-800' : 'bg-white']: true,
[isDark ? '!bg-surface-700' : '!bg-surface-50']: hoverable && isHover
},
pt.wrapper?.className
]"
:style="{
transform: `translateX(${swipe.offsetX}px)`
}"
@tap="onTap"
>
<view class="cl-list-item__inner" :class="[pt.inner?.className]">
<slot name="icon">
<cl-icon
v-if="icon != ''"
:name="icon"
:size="pt.icon?.size ?? 36"
:color="pt.icon?.color"
:pt="{
className: `mr-3 ${pt.icon?.className}`
}"
></cl-icon>
</slot>
<slot name="image">
<cl-image
v-if="image != ''"
:width="pt.image?.width ?? 36"
:height="pt.image?.height ?? 36"
:src="image"
:pt="{
className: `mr-3 rounded-full ${pt.image?.className}`
}"
></cl-image>
</slot>
<cl-text
v-if="label != ''"
:pt="{
className: parseClass([
'cl-list-item__label whitespace-nowrap overflow-visible',
[justify == 'start', 'w-24'],
pt.label?.className
])
}"
>
{{ label }}
</cl-text>
<view
class="cl-list-item__content"
:class="[
{
'justify-start': justify == 'start',
'justify-center': justify == 'center',
'justify-end': justify == 'end'
},
pt.content?.className
]"
>
<slot></slot>
</view>
<cl-icon
name="arrow-right-s-line"
:size="36"
:pt="{
className: parseClass([
'text-surface-400 ml-1 duration-200',
{
'rotate-90': isCollapse
}
])
}"
v-if="arrow"
></cl-icon>
</view>
<view
:class="['cl-list-item__swipe', `cl-list-item__swipe-${swipe.direction}`]"
v-if="swipeable"
>
<slot name="swipe-left"></slot>
<slot name="swipe-right"></slot>
</view>
</view>
<cl-collapse
v-model="isCollapse"
:pt="{
className: parseClass(['p-[24rpx]', pt.collapse?.className])
}"
>
<slot name="collapse"></slot>
</cl-collapse>
</view>
</template>
<script lang="ts" setup>
import {
computed,
getCurrentInstance,
onMounted,
reactive,
ref,
useSlots,
type PropType
} from "vue";
import { isAppIOS, isDark, isHarmony, parseClass, parsePt } from "@/cool";
import type { Justify, PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
import type { ClImageProps } from "../cl-image/props";
defineOptions({
name: "cl-list-item"
});
// 定义组件属性
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 图标名称
icon: {
type: String,
default: ""
},
// 图标名称
image: {
type: String,
default: ""
},
// 标签文本
label: {
type: String,
default: ""
},
// 内容对齐方式
justify: {
type: String as PropType<Justify>,
default: "end"
},
// 是否显示箭头
arrow: {
type: Boolean,
default: false
},
// 是否可滑动
swipeable: {
type: Boolean,
default: false
},
// 是否显示点击态
hoverable: {
type: Boolean,
default: false
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否显示折叠
collapse: {
type: Boolean,
default: false
}
});
const { proxy } = getCurrentInstance()!;
const slots = useSlots();
// 透传样式类型定义
type PassThrough = {
className?: string; // 根元素类名
wrapper?: PassThroughProps; // 包裹容器样式
inner?: PassThroughProps; // 内部容器样式
label?: PassThroughProps; // 标签文本样式
content?: PassThroughProps; // 内容区域样式
icon?: ClIconProps; // 图标样式
image?: ClImageProps; // 图片样式
collapse?: PassThroughProps; // 折叠内容样式
};
// 计算透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 滑动状态类型定义
type Swipe = {
width: number; // 滑动区域宽度
maxX: number; // 最大滑动距离
startX: number; // 开始触摸位置
endX: number; // 结束触摸位置
offsetX: number; // 当前偏移量
direction: "right" | "left"; // 滑动方向 - right表示向右滑动显示左侧内容,left表示向左滑动显示右侧内容
moveDirection: "right" | "left"; // 移动方向 - 实时记录手指滑动方向
};
// 滑动状态数据
const swipe = reactive<Swipe>({
width: 0, // 滑动区域宽度,通过查询获取
maxX: 0, // 最大可滑动距离
startX: 0, // 开始触摸的X坐标
endX: 0, // 结束触摸的X坐标
offsetX: 0, // X轴偏移量
direction: "left", // 默认向左滑动
moveDirection: "left" // 默认向左移动
});
/**
* 初始化滑动状态
* 根据插槽判断滑动方向并获取滑动区域宽度
*/
function initSwipe() {
if (!props.swipeable) return;
// 根据是否有左侧插槽判断滑动方向
swipe.direction = slots["swipe-left"] != null ? "right" : "left";
// 获取滑动区域宽度
uni.createSelectorQuery()
.in(proxy)
.select(".cl-list-item__swipe")
.boundingClientRect((node) => {
// 获取滑动区域的宽度如果未获取到则默认为0
swipe.width = (node as NodeInfo).width ?? 0;
// 根据滑动方向(left/right)设置最大可滑动距离,左滑为负,右滑为正
swipe.maxX = swipe.width * (swipe.direction == "left" ? -1 : 1);
})
.exec();
}
/**
* 重置滑动状态
* 将开始和结束位置重置为0
*/
function resetSwipe() {
swipe.startX = 0;
swipe.endX = 0;
swipe.offsetX = 0;
}
/**
* 滑动到指定位置
* @param num 目标位置
* 使用requestAnimationFrame实现平滑滑动动画
*/
function swipeTo(num: number) {
swipe.offsetX = num;
swipe.endX = num;
}
// 点击态状态
const isHover = ref(false);
/**
* 触摸开始事件处理
* @param e 触摸事件对象
*/
function onTouchStart(e: UniTouchEvent) {
isHover.value = true;
// 记录开始触摸位置
if (props.swipeable) {
swipe.startX = (e.touches[0] as UniTouch).pageX;
}
}
/**
* 触摸结束事件处理
* 根据滑动距离判断是否触发完整滑动
*/
function onTouchEnd() {
if (isHover.value) {
// 计算滑动阈值 - 取滑动区域一半和50px中的较小值
const threshold = swipe.width / 2 > 50 ? 50 : swipe.width / 2;
// 计算实际滑动距离
const offset = Math.abs(swipe.offsetX - swipe.endX);
// 移除点击效果
isHover.value = false;
// 根据滑动距离判断是否触发滑动
if (offset > threshold) {
// 如果滑动方向与预设方向一致,滑动到最大位置
if (swipe.direction == swipe.moveDirection) {
swipeTo(swipe.maxX);
} else {
// 否则回到起始位置
swipeTo(0);
}
} else {
// 滑动距离不够,回到最近的位置
swipeTo(swipe.endX == 0 ? 0 : swipe.maxX);
}
}
}
/**
* 触摸取消事件处理
*/
function onTouchCancel() {
onTouchEnd();
isHover.value = false; // 移除点击效果
}
/**
* 触摸移动事件处理
* @param e 触摸事件对象
*/
function onTouchMove(e: UniTouchEvent) {
if (isHover.value) {
// 计算滑动偏移量
const offsetX = (e.touches[0] as UniTouch).pageX - swipe.startX;
// 根据偏移量判断滑动方向
swipe.moveDirection = offsetX > 0 ? "right" : "left";
// 计算目标位置
let x = offsetX + swipe.endX;
// 限制滑动范围
if (swipe.direction == "right") {
// 向右滑动时的边界处理
if (x > swipe.maxX) {
x = swipe.maxX;
}
if (x < 0) {
x = 0;
}
}
if (swipe.direction == "left") {
// 向左滑动时的边界处理
if (x < swipe.maxX) {
x = swipe.maxX;
}
if (x > 0) {
x = 0;
}
}
// 更新偏移量
swipe.offsetX = x;
}
}
// 折叠状态
const isCollapse = ref(false);
/**
* 点击事件处理
*/
function onTap() {
if (props.collapse) {
isCollapse.value = !isCollapse.value;
}
}
onMounted(() => {
setTimeout(
() => {
initSwipe();
},
isHarmony() || isAppIOS() ? 50 : 0
);
});
defineExpose({
initSwipe,
resetSwipe
});
</script>
<style lang="scss" scoped>
.cl-list-item {
@apply flex flex-col w-full relative;
&__wrapper {
@apply w-full transition-none;
overflow: visible;
&.is-transition {
@apply duration-200;
transition-property: transform;
}
}
&__inner {
@apply flex flex-row items-center;
padding: 24rpx;
}
&__content {
@apply flex flex-row items-center;
flex: 1;
}
&__swipe {
@apply absolute h-full;
&-left {
@apply left-full;
transform: translateX(1rpx);
}
&-right {
@apply right-full;
}
}
&--disabled {
@apply opacity-50;
}
}
</style>

View File

@@ -0,0 +1,28 @@
import type { Justify, PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
import type { ClImageProps } from "../cl-image/props";
export type ClListItemPassThrough = {
className?: string;
wrapper?: PassThroughProps;
inner?: PassThroughProps;
label?: PassThroughProps;
content?: PassThroughProps;
icon?: ClIconProps;
image?: ClImageProps;
collapse?: PassThroughProps;
};
export type ClListItemProps = {
className?: string;
pt?: ClListItemPassThrough;
icon?: string;
image?: string;
label?: string;
justify?: Justify;
arrow?: boolean;
swipeable?: boolean;
hoverable?: boolean;
disabled?: boolean;
collapse?: boolean;
};

View File

@@ -0,0 +1,759 @@
<template>
<view class="cl-list-view" :class="[pt.className]">
<!-- 滚动容器 -->
<scroll-view
class="cl-list-view__scroller"
:class="[pt.scroller?.className]"
:scroll-top="targetScrollTop"
:scroll-into-view="scrollIntoView"
:scroll-with-animation="scrollWithAnimation"
:show-scrollbar="showScrollbar"
:refresher-triggered="refreshTriggered"
:refresher-enabled="refresherEnabled"
:refresher-threshold="refresherThreshold"
:refresher-background="refresherBackground"
refresher-default-style="none"
direction="vertical"
@scrolltoupper="onScrollToUpper"
@scrolltolower="onScrollToLower"
@scroll="onScroll"
@scrollend="onScrollEnd"
@refresherpulling="onRefresherPulling"
@refresherrefresh="onRefresherRefresh"
@refresherrestore="onRefresherRestore"
@refresherabort="onRefresherAbort"
>
<!-- 下拉刷新 -->
<!-- #ifndef APP-HARMONY -->
<view
slot="refresher"
class="cl-list-view__refresher"
:class="[
{
'is-pulling': refresherStatus === 'pulling',
'is-refreshing': refresherStatus === 'refreshing'
},
pt.refresher?.className
]"
:style="{
height: refresherThreshold + 'px'
}"
>
<slot name="refresher" :status="refresherStatus" :text="refresherText">
<cl-loading
v-if="refresherStatus === 'refreshing'"
:size="28"
:pt="{
className: 'mr-2'
}"
></cl-loading>
<cl-text> {{ refresherText }} </cl-text>
</slot>
</view>
<!-- #endif -->
<!-- 列表 -->
<view
class="cl-list-view__virtual-list"
:class="[pt.list?.className]"
:style="listStyle"
>
<!-- 顶部占位 -->
<view class="cl-list-view__spacer-top" :style="spacerTopStyle">
<slot name="top"></slot>
</view>
<!-- 列表项 -->
<view
v-for="(item, index) in visibleItems"
:key="item.key"
class="cl-list-view__virtual-item"
>
<view
class="cl-list-view__header"
:class="[
{
'is-dark': isDark
}
]"
:style="{
height: headerHeight + 'px'
}"
v-if="item.type == 'header'"
>
<slot name="header" :index="item.data.index!">
<cl-text> {{ item.data.label }} </cl-text>
</slot>
</view>
<view
v-else
class="cl-list-view__item"
:class="[
{
'is-dark': isDark
},
pt.item?.className
]"
:hover-class="pt.itemHover?.className"
:style="{
height: virtual ? itemHeight + 'px' : 'auto'
}"
@tap="onItemTap(item)"
>
<slot
name="item"
:item="item"
:data="item.data"
:value="item.data.value"
:index="index"
>
<view class="cl-list-view__item-inner">
<cl-text> {{ item.data.label }} </cl-text>
</view>
</slot>
</view>
</view>
<!-- 底部占位 -->
<view class="cl-list-view__spacer-bottom" :style="spacerBottomStyle">
<slot name="bottom"></slot>
</view>
</view>
<!-- 空状态 -->
<cl-empty v-if="noData" :fixed="false"></cl-empty>
</scroll-view>
<!-- 右侧索引栏 -->
<cl-index-bar
v-if="hasIndex"
v-model="activeIndex"
:list="indexList"
:pt="{
className: parseClass([pt.indexBar?.className])
}"
@change="onIndexChange"
>
</cl-index-bar>
<!-- 索引提示 -->
<view
class="cl-list-view__index"
:class="[
{
'is-dark': isDark
}
]"
:style="{ height: headerHeight + 'px' }"
v-if="hasIndex"
>
<slot name="index" :index="indexList[activeIndex]">
<cl-text> {{ indexList[activeIndex] }} </cl-text>
</slot>
</view>
<!-- 回到顶部 -->
<cl-back-top :top="scrollTop" v-if="showBackTop" @back-top="scrollToTop"></cl-back-top>
</view>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch, type PropType } from "vue";
import type {
ClListViewItem,
ClListViewGroup,
ClListViewVirtualItem,
PassThroughProps,
ClListViewRefresherStatus
} from "../../types";
import { isApp, isDark, isEmpty, parseClass, parsePt } from "@/cool";
import { t } from "@/locale";
defineOptions({
name: "cl-list-view"
});
defineSlots<{
// 顶部插槽
top(): any;
// 分组头部插槽
header(props: { index: string }): any;
// 列表项插槽
item(props: {
data: ClListViewItem;
item: ClListViewVirtualItem;
value: any | null;
index: number;
}): any;
// 底部插槽
bottom(): any;
// 索引插槽
index(props: { index: string }): any;
// 下拉刷新插槽
refresher(props: { status: ClListViewRefresherStatus; text: string }): any;
}>();
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 列表数据源
data: {
type: Array as PropType<ClListViewItem[]>,
default: () => []
},
// 列表项高度
itemHeight: {
type: Number,
default: 50
},
// 分组头部高度
headerHeight: {
type: Number,
default: 32
},
// 列表顶部预留空间高度
topHeight: {
type: Number,
default: 0
},
// 列表底部预留空间高度
bottomHeight: {
type: Number,
default: 0
},
// 缓冲区大小,即可视区域外预渲染的项目数量
bufferSize: {
type: Number,
default: isApp() ? 5 : 15
},
// 是否启用虚拟列表渲染,当数据量大时建议开启以提升性能
virtual: {
type: Boolean,
default: true
},
// 滚动到指定位置
scrollIntoView: {
type: String,
default: ""
},
// 是否启用滚动动画
scrollWithAnimation: {
type: Boolean,
default: false
},
// 是否显示滚动条
showScrollbar: {
type: Boolean,
default: false
},
// 是否启用下拉刷新
refresherEnabled: {
type: Boolean,
default: false
},
// 下拉刷新触发距离,相当于下拉内容高度
refresherThreshold: {
type: Number,
default: 50
},
// 下拉刷新区域背景色
refresherBackground: {
type: String,
default: "transparent"
},
// 下拉刷新默认文案
refresherDefaultText: {
type: String,
default: () => t("下拉刷新")
},
// 释放刷新文案
refresherPullingText: {
type: String,
default: () => t("释放立即刷新")
},
// 正在刷新文案
refresherRefreshingText: {
type: String,
default: () => t("加载中")
},
// 是否显示回到顶部按钮
showBackTop: {
type: Boolean,
default: true
}
});
const emit = defineEmits([
"item-tap",
"refresher-pulling",
"refresher-refresh",
"refresher-restore",
"refresher-abort",
"scrolltoupper",
"scrolltolower",
"scroll",
"scrollend",
"pull",
"top",
"bottom"
]);
// 获取当前组件实例,用于后续DOM操作
const { proxy } = getCurrentInstance()!;
// 透传样式配置类型
type PassThrough = {
className?: string;
item?: PassThroughProps;
itemHover?: PassThroughProps;
list?: PassThroughProps;
indexBar?: PassThroughProps;
scroller?: PassThroughProps;
refresher?: PassThroughProps;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 当前激活的索引位置,用于控制索引栏的高亮状态
const activeIndex = ref(0);
// 是否没有数据
const noData = computed(() => {
return isEmpty(props.data);
});
// 是否包含索引
const hasIndex = computed(() => {
return props.data.every((e) => e.index != null) && !noData.value;
});
// 计算属性:将原始数据按索引分组
const data = computed<ClListViewGroup[]>(() => {
// 初始化分组数组
const group: ClListViewGroup[] = [];
// 遍历原始数据,按index字段进行分组
props.data.forEach((item) => {
// 查找是否已存在相同index的分组
const index = group.findIndex((group) => group.index == item.index);
if (index != -1) {
// 如果分组已存在,将当前项添加到该分组的列表中
group[index].children.push(item);
} else {
// 如果分组不存在,创建新的分组
group.push({
index: item.index ?? "",
children: [item]
} as ClListViewGroup);
}
});
return group;
});
// 计算属性:提取所有分组的索引列表,用于索引栏显示
const indexList = computed<string[]>(() => {
return data.value.map((item) => item.index);
});
// 计算属性:将分组数据扁平化为虚拟列表项数组
const virtualItems = computed<ClListViewVirtualItem[]>(() => {
// 初始化虚拟列表数组
const items: ClListViewVirtualItem[] = [];
// 初始化顶部位置,考虑预留空间
let top = props.topHeight;
// 初始化索引计数器
let index = 0;
// 遍历每个分组,生成虚拟列表项
data.value.forEach((group, groupIndex) => {
if (group.index != "") {
// 添加分组头部项
items.push({
key: `header-${groupIndex}`,
type: "header",
index: index++,
top,
height: props.headerHeight,
data: {
label: group.index!,
index: group.index
}
});
// 更新top位置
top += props.headerHeight;
}
// 添加分组内的所有列表项
group.children.forEach((item, itemIndex) => {
items.push({
key: `item-${groupIndex}-${itemIndex}`,
type: "item",
index: index++,
top,
height: props.itemHeight,
data: item
});
// 更新top位置
top += props.itemHeight;
});
});
return items;
});
// 计算属性:计算整个列表的总高度
const listHeight = computed<number>(() => {
return (
// 所有项目高度之和
virtualItems.value.reduce((total, item) => total + item.height, 0) +
// 加上顶部预留空间高度
props.topHeight +
// 加上底部预留空间高度
props.bottomHeight
);
});
// 当前滚动位置
const scrollTop = ref(0);
// 目标滚动位置,用于控制滚动到指定位置
const targetScrollTop = ref(0);
// 滚动容器的高度
const scrollerHeight = ref(0);
// 计算属性:获取当前可见区域的列表项
const visibleItems = computed<ClListViewVirtualItem[]>(() => {
// 如果虚拟列表为空,返回空数组
if (isEmpty(virtualItems.value)) {
return [];
}
// 如果未启用虚拟列表,直接返回所有项目
if (!props.virtual) {
return virtualItems.value;
}
// 计算缓冲区高度
const bufferHeight = props.bufferSize * props.itemHeight;
// 计算可视区域的顶部位置(包含缓冲区)
const viewportTop = scrollTop.value - bufferHeight;
// 计算可视区域的底部位置(包含缓冲区)
const viewportBottom = scrollTop.value + scrollerHeight.value + bufferHeight;
// 初始化可见项目数组
const visible: ClListViewVirtualItem[] = [];
// 使用二分查找优化查找起始位置
let startIndex = 0;
let endIndex = virtualItems.value.length - 1;
// 二分查找第一个可见项目的索引
while (startIndex < endIndex) {
const mid = Math.floor((startIndex + endIndex) / 2);
const item = virtualItems.value[mid];
if (item.top + item.height <= viewportTop) {
startIndex = mid + 1;
} else {
endIndex = mid;
}
}
// 从找到的起始位置开始,收集所有可见项目
for (let i = startIndex; i < virtualItems.value.length; i++) {
const item = virtualItems.value[i];
// 如果项目完全超出视口下方,停止收集
if (item.top >= viewportBottom) {
break;
}
// 如果项目与视口有交集,添加到可见列表
if (item.top + item.height > viewportTop) {
visible.push(item);
}
}
return visible;
});
// 计算属性:计算上方占位容器的高度
const spacerTopHeight = computed<number>(() => {
// 如果没有可见项目,返回0
if (isEmpty(visibleItems.value)) {
return 0;
}
// 返回第一个可见项目的顶部位置
return visibleItems.value[0].top;
});
// 计算属性:计算下方占位容器的高度
const spacerBottomHeight = computed<number>(() => {
// 如果没有可见项目,返回0
if (isEmpty(visibleItems.value)) {
return 0;
}
// 获取最后一个可见项目
const lastItem = visibleItems.value[visibleItems.value.length - 1];
// 计算下方占位高度
return listHeight.value - (lastItem.top + lastItem.height);
});
// 列表样式
const listStyle = computed(() => {
return {
height: props.virtual ? `${listHeight.value}px` : "auto"
};
});
// 上方占位容器样式
const spacerTopStyle = computed(() => {
return {
height: props.virtual ? `${spacerTopHeight.value}px` : "auto"
};
});
// 下方占位容器样式
const spacerBottomStyle = computed(() => {
return {
height: props.virtual ? `${spacerBottomHeight.value}px` : "auto"
};
});
// 存储每个分组头部距离顶部的位置数组
const tops = ref<number[]>([]);
// 计算并更新所有分组头部的位置
function getTops() {
// 初始化一个空数组
const arr = [] as number[];
// 初始化顶部位置
let top = 0;
// 计算每个分组的顶部位置
data.value.forEach((group) => {
// 将当前分组头部的位置添加到数组中
arr.push(top);
// 累加当前分组的总高度(头部高度+所有项目高度)
top += props.headerHeight + group.children.length * props.itemHeight;
});
tops.value = arr;
}
// 下拉刷新触发标志
const refreshTriggered = ref(false);
// 下拉刷新相关状态
const refresherStatus = ref<"default" | "pulling" | "refreshing">("default");
// 下拉刷新文案
const refresherText = computed(() => {
switch (refresherStatus.value) {
case "pulling":
return props.refresherPullingText;
case "refreshing":
return props.refresherRefreshingText;
default:
return props.refresherDefaultText;
}
});
// 停止下拉刷新
function stopRefresh() {
refreshTriggered.value = false;
refresherStatus.value = "default";
}
// 滚动到顶部事件处理函数
function onScrollToUpper(e: UniScrollToUpperEvent) {
emit("scrolltoupper", e);
emit("top");
}
// 滚动到底部事件处理函数
function onScrollToLower(e: UniScrollToLowerEvent) {
emit("scrolltolower", e);
emit("bottom");
}
// 滚动锁定标志,用于防止滚动时触发不必要的计算
let scrollLock = false;
// 滚动事件处理函数
function onScroll(e: UniScrollEvent) {
// 更新当前滚动位置
scrollTop.value = Math.floor(e.detail.scrollTop);
// 如果滚动被锁定,直接返回
if (scrollLock) return;
// 根据滚动位置自动更新激活的索引
tops.value.forEach((top, index) => {
if (scrollTop.value >= top) {
activeIndex.value = index;
}
});
emit("scroll", e);
}
// 滚动结束事件处理函数
function onScrollEnd(e: UniScrollEvent) {
emit("scrollend", e);
}
// 行点击事件处理函数
function onItemTap(item: ClListViewVirtualItem) {
emit("item-tap", item.data);
}
// 索引栏点击事件处理函数
function onIndexChange(index: number) {
// 锁定滚动,防止触发不必要的计算
scrollLock = true;
// 设置目标滚动位置为对应分组头部的位置
targetScrollTop.value = tops.value[index];
// 300ms后解除滚动锁定
setTimeout(() => {
scrollLock = false;
}, 300);
}
// 下拉刷新事件处理函数
function onRefresherPulling(e: UniRefresherEvent) {
if (e.detail.dy > props.refresherThreshold) {
refresherStatus.value = "pulling";
}
emit("refresher-pulling", e);
}
// 下拉刷新事件处理函数
function onRefresherRefresh(e: UniRefresherEvent) {
refresherStatus.value = "refreshing";
refreshTriggered.value = true;
emit("refresher-refresh", e);
emit("pull", e);
}
// 恢复下拉刷新
function onRefresherRestore(e: UniRefresherEvent) {
refresherStatus.value = "default";
emit("refresher-restore", e);
}
// 停止下拉刷新
function onRefresherAbort(e: UniRefresherEvent) {
refresherStatus.value = "default";
emit("refresher-abort", e);
}
// 滚动到顶部
function scrollToTop() {
targetScrollTop.value = 0.01;
nextTick(() => {
targetScrollTop.value = 0;
});
}
// 获取滚动容器的高度
function getScrollerHeight() {
setTimeout(() => {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-list-view__scroller")
.boundingClientRect()
.exec((res) => {
if (isEmpty(res)) {
return;
}
// 设置容器高度
scrollerHeight.value = (res[0] as NodeInfo).height ?? 0;
});
}, 100);
}
// 组件挂载后的初始化逻辑
onMounted(() => {
// 获取容器高度
getScrollerHeight();
// 监听数据变化,重新计算位置信息
watch(
computed(() => props.data),
() => {
getTops();
},
{
// 立即执行一次
immediate: true
}
);
});
defineExpose({
data,
stopRefresh
});
</script>
<style lang="scss" scoped>
.cl-list-view {
@apply h-full w-full relative;
&__scroller {
@apply h-full w-full;
}
&__virtual-list {
@apply relative w-full;
}
&__spacer-top,
&__spacer-bottom {
@apply w-full;
}
&__index {
@apply flex flex-row items-center bg-white;
@apply absolute top-0 left-0 w-full px-[20rpx] z-20;
&.is-dark {
@apply bg-surface-600 border-none;
}
}
&__virtual-item {
@apply w-full;
}
&__header {
@apply flex flex-row items-center relative px-[20rpx] z-10;
}
&__item {
&-inner {
@apply flex flex-row items-center px-[20rpx] h-full;
}
}
&__refresher {
@apply flex flex-row items-center justify-center w-full h-full;
}
}
</style>

View File

@@ -0,0 +1,33 @@
import type { ClListViewItem, ClListViewGroup, ClListViewVirtualItem, PassThroughProps, ClListViewRefresherStatus } from "../../types";
export type ClListViewPassThrough = {
className?: string;
item?: PassThroughProps;
itemHover?: PassThroughProps;
list?: PassThroughProps;
indexBar?: PassThroughProps;
scroller?: PassThroughProps;
refresher?: PassThroughProps;
};
export type ClListViewProps = {
className?: string;
pt?: ClListViewPassThrough;
data?: ClListViewItem[];
itemHeight?: number;
headerHeight?: number;
topHeight?: number;
bottomHeight?: number;
bufferSize?: number;
virtual?: boolean;
scrollIntoView?: string;
scrollWithAnimation?: boolean;
showScrollbar?: boolean;
refresherEnabled?: boolean;
refresherThreshold?: number;
refresherBackground?: string;
refresherDefaultText?: string;
refresherPullingText?: string;
refresherRefreshingText?: string;
showBackTop?: boolean;
};

View File

@@ -0,0 +1,115 @@
<template>
<view
class="cl-list dark:!border-surface-700"
:class="[
{
'cl-list--border': border
},
pt.className
]"
>
<slot name="header">
<text class="cl-list__title" v-if="title != ''">{{ title }}</text>
</slot>
<view class="cl-list__items">
<view v-for="(item, index) in list" :key="index">
<cl-list-item
:icon="item.icon"
:label="item.label"
:arrow="item.arrow"
:hoverable="item.hoverable"
:pt="{
className: `bg-white dark:!bg-surface-700 ${pt.item?.className}`,
inner: pt.item?.inner,
label: pt.item?.label,
content: pt.item?.content,
icon: pt.item?.icon
}"
>
<slot name="item" :item="item">
<cl-text>{{ item.content }}</cl-text>
</slot>
</cl-list-item>
<view class="cl-list__line" v-if="index != list.length - 1">
<view class="cl-list__line-inner"></view>
</view>
</view>
<slot></slot>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ClListItem, PassThroughProps } from "../../types";
import type { ClListItemPassThrough } from "../cl-list-item/props";
import { computed, type PropType } from "vue";
import { parsePt } from "@/cool";
defineOptions({
name: "cl-list"
});
defineSlots<{
header(): any;
item(props: { item: ClListItem }): any;
}>();
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
list: {
type: Array as PropType<ClListItem[]>,
default: () => []
},
title: {
type: String,
default: ""
},
border: {
type: Boolean,
default: false
}
});
type PassThrough = {
className?: string;
list?: PassThroughProps;
item?: ClListItemPassThrough;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
</script>
<style lang="scss" scoped>
.cl-list {
@apply duration-200;
transition-property: border-color, background-color;
&__title {
@apply text-surface-500 text-sm pb-2;
padding-left: 24rpx;
}
&__items {
@apply rounded-2xl overflow-hidden;
}
&__line {
padding: 0 24rpx;
&-inner {
@apply bg-surface-50 w-full;
height: 1rpx;
}
}
&--border {
@apply border border-solid border-surface-200 rounded-2xl;
}
}
</style>

View File

@@ -0,0 +1,16 @@
import type { ClListItem, PassThroughProps } from "../../types";
import type { ClListItemPassThrough } from "../cl-list-item/props";
export type ClListPassThrough = {
className?: string;
list?: PassThroughProps;
item?: ClListItemPassThrough;
};
export type ClListProps = {
className?: string;
pt?: ClListPassThrough;
list?: ClListItem[];
title?: string;
border?: boolean;
};

View File

@@ -0,0 +1,140 @@
<template>
<view
ref="loadingRef"
class="cl-loading"
:class="[
{
'cl-loading--dark': isDark && color == '',
'!border-r-transparent': true
},
pt.className
]"
:style="{
height: getPx(size!),
width: getPx(size!),
borderWidth: '1px',
borderTopColor: color,
borderRightColor: 'transparent',
borderBottomColor: color,
borderLeftColor: color
}"
v-if="loading"
>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, shallowRef, watch } from "vue";
import { createAnimation, ctx, isDark, parsePt } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import { useSize } from "../../hooks";
defineOptions({
name: "cl-loading"
});
// 定义组件属性
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 是否加载中
loading: {
type: Boolean,
default: true
},
// 图标大小
size: {
type: [Number, String],
default: 24
},
// 图标颜色
color: {
type: String,
default: ""
}
});
const { getPx } = useSize();
// 透传样式类型定义
type PassThrough = {
className?: string;
icon?: ClIconProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 组件引用
const loadingRef = shallowRef<UniElement | null>(null);
// 颜色值
const color = computed<string>(() => {
if (props.color == "") {
return isDark.value ? "#ffffff" : (ctx.color["surface-700"] as string);
}
switch (props.color) {
case "primary":
return ctx.color["primary-500"] as string;
case "success":
return "#22c55e";
case "warn":
return "#eab308";
case "error":
return "#ef4444";
case "info":
return "#71717a";
case "dark":
return "#3f3f46";
case "light":
return "#ffffff";
case "disabled":
return "#d4d4d8";
default:
return props.color;
}
});
// 开始旋转动画
async function start() {
createAnimation(loadingRef.value, {
duration: 2500,
loop: -1,
timingFunction: "linear"
})
.rotate("0deg", "360deg")
.play();
}
// 组件挂载后监听loading状态
onMounted(() => {
watch(
computed(() => props.loading),
(val: boolean) => {
// 当loading为true时开始旋转
if (val) {
start();
}
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-loading {
@apply flex flex-row items-center justify-center rounded-full;
@apply border-surface-700 border-solid;
&--dark {
border-color: white !important;
border-right-color: transparent !important;
}
}
</style>

View File

@@ -0,0 +1,14 @@
import type { ClIconProps } from "../cl-icon/props";
export type ClLoadingPassThrough = {
className?: string;
icon?: ClIconProps;
};
export type ClLoadingProps = {
className?: string;
pt?: ClLoadingPassThrough;
loading?: boolean;
size?: any;
color?: string;
};

View File

@@ -0,0 +1,96 @@
<template>
<view class="cl-loadmore-wrapper">
<view class="cl-loadmore">
<cl-loading
:size="28"
:pt="{
className: `mr-2 ${pt.icon?.className}`
}"
v-if="loading"
></cl-loading>
<cl-text
:pt="{
className: pt.text?.className
}"
>{{ message }}</cl-text
>
</view>
<cl-safe-area type="bottom" v-if="safeAreaBottom"></cl-safe-area>
</view>
</template>
<script lang="ts" setup>
import { t } from "@/locale";
import { computed } from "vue";
import { parsePt } from "@/cool";
import type { PassThroughProps } from "../../types";
defineOptions({
name: "cl-loadmore"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
// 是否加载中
loading: {
type: Boolean,
default: false
},
// 加载中显示内容
loadingText: {
type: String,
default: () => t("加载中")
},
// 是否加载完成
finish: {
type: Boolean,
default: false
},
// 加载完成显示内容
finishText: {
type: String,
default: () => t("没有更多了")
},
// 是否显示底部安全区
safeAreaBottom: {
type: Boolean,
default: false
}
});
type PassThrough = {
className?: string;
icon?: PassThroughProps;
text?: PassThroughProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 消息内容
const message = computed(() => {
if (props.finish) {
return props.finishText;
}
if (props.loading) {
return props.loadingText;
}
return "";
});
</script>
<style lang="scss" scoped>
.cl-loadmore-wrapper {
@apply flex flex-col items-center justify-center py-2;
}
.cl-loadmore {
@apply flex flex-row items-center justify-center;
}
</style>

View File

@@ -0,0 +1,17 @@
import type { PassThroughProps } from "../../types";
export type ClLoadmorePassThrough = {
className?: string;
icon?: PassThroughProps;
text?: PassThroughProps;
};
export type ClLoadmoreProps = {
className?: string;
pt?: ClLoadmorePassThrough;
loading?: boolean;
loadingText?: string;
finish?: boolean;
finishText?: string;
safeAreaBottom?: boolean;
};

View File

@@ -0,0 +1,281 @@
<template>
<view ref="marqueeRef" class="cl-marquee" :class="[pt.className]">
<view
class="cl-marquee__list"
:class="[
pt.list?.className,
{
'is-vertical': direction == 'vertical',
'is-horizontal': direction == 'horizontal'
}
]"
:style="listStyle"
>
<!-- 渲染两份图片列表实现无缝滚动 -->
<view
class="cl-marquee__item"
v-for="(item, index) in duplicatedList"
:key="`${item.url}-${index}`"
:class="[pt.item?.className]"
:style="itemStyle"
>
<slot name="item" :item="item" :index="item.originalIndex">
<image
:src="item.url"
mode="aspectFill"
class="cl-marquee__image"
:class="[pt.image?.className]"
></image>
</slot>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, type PropType, watch } from "vue";
import { AnimationEngine, createAnimation, getPx, parsePt } from "@/cool";
import type { PassThroughProps } from "../../types";
type MarqueeItem = {
url: string;
originalIndex: number;
};
defineOptions({
name: "cl-marquee"
});
defineSlots<{
item(props: { item: MarqueeItem; index: number }): any;
}>();
const props = defineProps({
// 透传属性
pt: {
type: Object,
default: () => ({})
},
// 图片列表
list: {
type: Array as PropType<string[]>,
default: () => []
},
// 滚动方向
direction: {
type: String as PropType<"horizontal" | "vertical">,
default: "horizontal"
},
// 一次滚动的持续时间
duration: {
type: Number,
default: 5000
},
// 图片高度
itemHeight: {
type: [Number, String],
default: 200
},
// 图片宽度 (仅横向滚动时生效纵向为100%)
itemWidth: {
type: [Number, String],
default: 300
},
// 间距
gap: {
type: [Number, String],
default: 20
}
});
// 透传属性类型定义
type PassThrough = {
className?: string;
list?: PassThroughProps;
item?: PassThroughProps;
image?: PassThroughProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
/** 跑马灯引用 */
const marqueeRef = ref<UniElement | null>(null);
/** 当前偏移量 */
const currentOffset = ref(0);
/** 重复的图片列表(用于无缝滚动) */
const duplicatedList = computed<MarqueeItem[]>(() => {
if (props.list.length == 0) return [];
const originalItems = props.list.map(
(url, index) =>
({
url,
originalIndex: index
}) as MarqueeItem
);
// 复制一份用于无缝滚动
const duplicatedItems = props.list.map(
(url, index) =>
({
url,
originalIndex: index
}) as MarqueeItem
);
return [...originalItems, ...duplicatedItems] as MarqueeItem[];
});
/** 容器样式 */
const listStyle = computed(() => {
const isVertical = props.direction == "vertical";
return {
transform: isVertical
? `translateY(${currentOffset.value}px)`
: `translateX(${currentOffset.value}px)`
};
});
/** 图片项样式 */
const itemStyle = computed(() => {
const style = {};
const gap = `${getPx(props.gap)}px`;
if (props.direction == "vertical") {
style["height"] = `${getPx(props.itemHeight)}px`;
style["marginBottom"] = gap;
} else {
style["width"] = `${getPx(props.itemWidth)}px`;
style["marginRight"] = gap;
}
return style;
});
/** 单个项目的尺寸(包含间距) */
const itemSize = computed(() => {
const size = props.direction == "vertical" ? props.itemHeight : props.itemWidth;
return getPx(size) + getPx(props.gap);
});
/** 总的滚动距离 */
const totalScrollDistance = computed(() => {
return props.list.length * itemSize.value;
});
/** 动画实例 */
let animation: AnimationEngine | null = null;
/**
* 开始动画
*/
function start() {
if (props.list.length <= 1) return;
animation = createAnimation(marqueeRef.value, {
duration: props.duration,
timingFunction: "linear",
loop: -1,
frame: (progress: number) => {
currentOffset.value = -progress * totalScrollDistance.value;
}
});
animation!.play();
}
/**
* 播放动画
*/
function play() {
if (animation != null) {
animation!.play();
}
}
/**
* 暂停动画
*/
function pause() {
if (animation != null) {
animation!.pause();
}
}
/**
* 停止动画
*/
function stop() {
if (animation != null) {
animation!.stop();
}
}
/**
* 重置动画
*/
function reset() {
currentOffset.value = 0;
if (animation != null) {
animation!.stop();
animation!.reset();
}
}
onMounted(() => {
setTimeout(() => {
start();
}, 300);
watch(
computed(() => [props.duration, props.itemHeight, props.itemWidth, props.gap, props.list]),
() => {
reset();
start();
}
);
});
onUnmounted(() => {
stop();
});
defineExpose({
start,
stop,
reset,
pause,
play
});
</script>
<style lang="scss" scoped>
.cl-marquee {
@apply relative;
&__list {
@apply flex h-full overflow-visible;
&.is-horizontal {
@apply flex-row;
}
&.is-vertical {
@apply flex-col;
}
}
&__item {
@apply relative h-full;
}
&__image {
@apply w-full h-full rounded-xl;
}
}
</style>

View File

@@ -0,0 +1,19 @@
import type { PassThroughProps } from "../../types";
export type ClMarqueePassThrough = {
className?: string;
list?: PassThroughProps;
item?: PassThroughProps;
image?: PassThroughProps;
};
export type ClMarqueeProps = {
className?: string;
pt?: ClMarqueePassThrough;
list?: string[];
direction?: "horizontal" | "vertical";
duration?: number;
itemHeight?: any;
itemWidth?: any;
gap?: any;
};

View File

@@ -0,0 +1,255 @@
<template>
<view
class="cl-noticebar"
:class="[pt.className]"
:style="{
height: parseRpx(height!)
}"
>
<view class="cl-noticebar__scroller" :class="[`is-${direction}`]" :style="scrollerStyle">
<view
v-for="(item, index) in list"
:key="index"
class="cl-noticebar__item"
:style="{
height: parseRpx(height!)
}"
>
<slot name="text" :item="item">
<view class="cl-noticebar__text">
<cl-text
:pt="{
className: parseClass(['whitespace-nowrap', pt.text?.className])
}"
>{{ item }}</cl-text
>
</view>
</slot>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import {
onMounted,
onUnmounted,
reactive,
computed,
getCurrentInstance,
type PropType,
watch
} from "vue";
import { parseRpx, parsePt, parseClass } from "@/cool";
import type { PassThroughProps } from "../../types";
defineOptions({
name: "cl-noticebar"
});
defineSlots<{
text(props: { item: string }): any;
}>();
const props = defineProps({
// 样式穿透对象,允许外部自定义样式
pt: {
type: Object,
default: () => ({})
},
// 公告文本内容,支持字符串或字符串数组
text: {
type: [String, Array] as PropType<string | string[]>,
default: ""
},
// 滚动方向,支持 horizontal水平或 vertical垂直
direction: {
type: String as PropType<"horizontal" | "vertical">,
default: "horizontal"
},
// 垂直滚动时的切换间隔,单位:毫秒
duration: {
type: Number,
default: 3000
},
// 水平滚动时的速度单位px/s
speed: {
type: Number,
default: 100
},
// 公告栏高度,支持字符串或数字
height: {
type: [String, Number] as PropType<string | number>,
default: 40
}
});
// 事件定义,当前仅支持 close 事件
const emit = defineEmits(["close"]);
// 获取当前组件实例,用于后续 DOM 查询
const { proxy } = getCurrentInstance()!;
// 获取设备屏幕信息
const { windowWidth } = uni.getWindowInfo();
// 样式透传类型定义
type PassThrough = {
className?: string;
text?: PassThroughProps;
};
// 计算样式透传对象,便于样式自定义
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 滚动相关状态,包含偏移量、动画时长等
type Scroll = {
left: number;
top: number;
translateX: number;
duration: number;
};
const scroll = reactive<Scroll>({
left: windowWidth,
top: 0,
translateX: 0,
duration: 0
});
// 定时器句柄,用于控制滚动动画
let timer: number = 0;
// 公告文本列表,统一为数组格式,便于遍历
const list = computed<string[]>(() => {
return Array.isArray(props.text) ? (props.text as string[]) : [props.text as string];
});
// 滚动容器样式,动态计算滚动动画相关样式
const scrollerStyle = computed(() => {
const style = {};
if (props.direction == "horizontal") {
style["left"] = `${scroll.left}px`;
style["transform"] = `translateX(-${scroll.translateX}px)`;
style["transition-duration"] = `${scroll.duration}ms`;
} else {
style["transform"] = `translateY(${scroll.top}px)`;
}
return style;
});
// 清除定时器,防止内存泄漏或重复动画
function clear(): void {
if (timer != 0) {
clearInterval(timer);
clearTimeout(timer);
timer = 0;
}
}
// 刷新滚动状态
function refresh() {
// 先清除已有定时器,避免重复动画
clear();
// 查询公告栏容器尺寸
uni.createSelectorQuery()
.in(proxy)
.select(".cl-noticebar")
.boundingClientRect((box) => {
// 获取容器高度和宽度
const boxHeight = (box as NodeInfo).height ?? 0;
const boxWidth = (box as NodeInfo).width ?? 0;
// 查询文本节点尺寸
uni.createSelectorQuery()
.in(proxy)
.select(".cl-noticebar__text")
.boundingClientRect((text) => {
// 水平滚动逻辑
if (props.direction == "horizontal") {
// 获取文本宽度
const textWidth = (text as NodeInfo).width ?? 0;
// 启动水平滚动动画
function next() {
// 计算滚动距离和动画时长
scroll.translateX = textWidth + boxWidth;
scroll.duration = Math.ceil((scroll.translateX / props.speed) * 1000);
scroll.left = boxWidth;
// 动画结束后重置,形成循环滚动
// @ts-ignore
timer = setTimeout(() => {
scroll.translateX = 0;
scroll.duration = 0;
setTimeout(() => {
next();
}, 100);
}, scroll.duration);
}
next();
}
// 垂直滚动逻辑
else {
// 定时切换文本,循环滚动
// @ts-ignore
timer = setInterval(() => {
if (Math.abs(scroll.top) >= boxHeight * (list.value.length - 1)) {
scroll.top = 0;
} else {
scroll.top -= boxHeight;
}
}, props.duration);
}
})
.exec();
})
.exec();
}
onMounted(() => {
watch(
computed(() => props.text!),
() => {
refresh();
},
{
immediate: true
}
);
});
onUnmounted(() => {
clear();
});
</script>
<style lang="scss" scoped>
.cl-noticebar {
flex-shrink: 1;
&__scroller {
@apply flex;
transition-property: transform;
transition-timing-function: linear;
&.is-horizontal {
@apply flex-row;
overflow: visible;
}
&.is-vertical {
@apply flex-col;
transition-duration: 0.5s;
}
}
&__item {
@apply flex flex-row items-center;
}
}
</style>

View File

@@ -0,0 +1,16 @@
import type { PassThroughProps } from "../../types";
export type ClNoticebarPassThrough = {
className?: string;
text?: PassThroughProps;
};
export type ClNoticebarProps = {
className?: string;
pt?: ClNoticebarPassThrough;
text?: string | string[];
direction?: "horizontal" | "vertical";
duration?: number;
speed?: number;
height?: string | number;
};

View File

@@ -0,0 +1,88 @@
<template>
<!-- #ifdef APP -->
<scroll-view
:style="{ flex: 1 }"
:scroll-top="scrollViewTop"
:scroll-with-animation="true"
@scroll="onScroll"
>
<cl-back-top v-if="backTop"></cl-back-top>
<theme></theme>
<ui></ui>
<slot></slot>
</scroll-view>
<!-- #endif -->
<!-- #ifndef APP -->
<cl-back-top v-if="backTop"></cl-back-top>
<theme></theme>
<ui></ui>
<slot></slot>
<!-- #endif -->
</template>
<script setup lang="ts">
import { ref } from "vue";
import Theme from "./theme.uvue";
import Ui from "./ui.uvue";
import { config } from "@/config";
import { scroller } from "@/cool";
defineOptions({
name: "cl-page"
});
defineProps({
// 是否显示回到顶部按钮
backTop: {
type: Boolean,
default: config.backTop
}
});
// 滚动距离
const scrollTop = ref(0);
// scroll-view 滚动位置
const scrollViewTop = ref(0);
// view 滚动事件
function onScroll(e: UniScrollEvent) {
// 触发滚动事件
scroller.emit(e.detail.scrollTop);
}
// 页面滚动事件
scroller.on((top) => {
scrollTop.value = top;
});
// 滚动到指定位置
function scrollTo(top: number) {
// #ifdef H5
window.scrollTo({ top, behavior: "smooth" });
// #endif
// #ifdef MP
uni.pageScrollTo({
scrollTop: top,
duration: 300
});
// #endif
// #ifdef APP
scrollViewTop.value = top;
// #endif
}
// 回到顶部
function scrollToTop() {
scrollTo(0 + Math.random() / 1000);
}
defineExpose({
scrollTop,
scrollTo,
scrollToTop
});
</script>

View File

@@ -0,0 +1,4 @@
export type ClPageProps = {
className?: string;
backTop?: boolean;
};

View File

@@ -0,0 +1,45 @@
<template>
<cl-float-view :size="40" :left="20" :bottom="20" :gap="20" v-if="config.showDarkButton">
<view class="theme-set" @tap="toggleTheme()">
<view class="theme-set__inner" :class="{ 'is-dark': isDark }">
<view class="theme-set__icon" v-for="item in list" :key="item">
<cl-icon :name="item" color="white" size="18px"></cl-icon>
</view>
</view>
</view>
</cl-float-view>
</template>
<script setup lang="ts">
import { config } from "@/config";
import { isDark, toggleTheme } from "@/cool";
defineOptions({
name: "cl-page-theme"
});
const list = ["moon-fill", "sun-fill"];
</script>
<style lang="scss" scoped>
.theme-set {
@apply flex flex-col items-center justify-center rounded-full h-full w-full;
@apply bg-primary-500;
&__inner {
@apply flex flex-col duration-300;
transform: translateY(20px);
transition-property: transform;
&.is-dark {
transform: translateY(-20px);
}
}
&__icon {
@apply flex items-center justify-center;
height: 40px;
width: 40px;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<cl-confirm ref="confirmRef"></cl-confirm>
<cl-confirm ref="tipsRef"></cl-confirm>
<cl-toast ref="toastRef"></cl-toast>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { ClConfirmAction, ClConfirmOptions, ClToastOptions } from "../../types";
import { createUi, type UiInstance } from "../../hooks";
import { t } from "@/locale";
defineOptions({
name: "cl-page-ui"
});
// 确认弹窗实例
const confirmRef = ref<ClConfirmComponentPublicInstance | null>(null);
// 提示弹窗实例
const tipsRef = ref<ClConfirmComponentPublicInstance | null>(null);
// 提示弹窗实例
const toastRef = ref<ClToastComponentPublicInstance | null>(null);
/**
* 显示确认弹窗
* @param options ClConfirmOptions 弹窗配置项
*/
function showConfirm(options: ClConfirmOptions) {
if (confirmRef.value != null) {
confirmRef.value!.open(options);
}
}
/**
* 显示提示弹窗
* @param message 提示消息
* @param callback 回调函数
*/
function showTips(message: string, callback: (action: ClConfirmAction) => void) {
if (tipsRef.value != null) {
tipsRef.value!.open({
title: t("提示"),
message,
callback,
showCancel: false
} as ClConfirmOptions);
}
}
/**
* 显示提示弹窗
* @param options ClToastOptions 弹窗配置项
*/
function showToast(options: ClToastOptions) {
if (toastRef.value != null) {
toastRef.value!.open(options);
}
}
// 注册当前页面的 UiInstance 实例
createUi({
showConfirm,
showTips,
showToast
} as UiInstance);
</script>

View File

@@ -0,0 +1,256 @@
<template>
<view class="cl-pagination">
<view
class="cl-pagination__prev"
:class="[
{
'is-disabled': value == 1,
'is-dark': isDark
},
pt.item?.className,
pt.prev?.className
]"
@tap="prev"
>
<slot name="prev" :disabled="value == 1">
<cl-icon
:name="pt.prevIcon?.name ?? 'arrow-left-s-line'"
:size="pt.prevIcon?.size"
:color="pt.prevIcon?.color"
:pt="{
className: pt.prevIcon?.className
}"
></cl-icon>
</slot>
</view>
<view
v-for="(item, index) in list"
:key="index"
class="cl-pagination__item"
:class="[
{
'is-active': item == value,
'is-dark': isDark
},
pt.item?.className
]"
@tap="toPage(item)"
>
<cl-text
:pt="{
className: parseClass([
'cl-pagination__item-text',
{
'text-white': item == value
},
pt.itemText?.className
])
}"
>{{ item }}</cl-text
>
</view>
<view
class="cl-pagination__next"
:class="[
{
'is-disabled': value == totalPage,
'is-dark': isDark
},
pt.item?.className,
pt.next?.className
]"
@tap="next"
>
<slot name="next" :disabled="value == totalPage">
<cl-icon
:name="pt.nextIcon?.name ?? 'arrow-right-s-line'"
:size="pt.nextIcon?.size"
:color="pt.nextIcon?.color"
:pt="{
className: pt.nextIcon?.className
}"
></cl-icon>
</slot>
</view>
</view>
</template>
<script setup lang="ts">
import type { PassThroughProps } from "../../types";
import { isDark, parseClass, parsePt } from "@/cool";
import { computed, ref, watch } from "vue";
import type { ClIconProps } from "../cl-icon/props";
defineOptions({
name: "cl-pagination"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
modelValue: {
type: Number,
default: 1
},
total: {
type: Number,
default: 0
},
size: {
type: Number,
default: 10
}
});
const emit = defineEmits(["update:modelValue", "change"]);
// 透传样式类型定义
type PassThrough = {
className?: string;
item?: PassThroughProps;
itemText?: PassThroughProps;
prev?: PassThroughProps;
prevIcon?: ClIconProps;
next?: PassThroughProps;
nextIcon?: ClIconProps;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 计算总页数,根据总数和每页大小向上取整
const totalPage = computed(() => {
if (props.total == 0) return 1;
return Math.ceil(props.total / props.size);
});
// 绑定值
const value = ref(props.modelValue);
// 计算分页列表,根据当前页码和总页数生成显示的分页按钮
const list = computed(() => {
const total = totalPage.value;
const list: (number | string)[] = [];
if (total <= 7) {
// 总页数小于等于7显示所有页码
for (let i = 1; i <= total; i++) {
list.push(i);
}
} else {
// 总是显示第一页
list.push(1);
if (value.value <= 4) {
// 当前页在前面: 1 2 3 4 5 ... 100
for (let i = 2; i <= 5; i++) {
list.push(i);
}
list.push("...");
list.push(total);
} else if (value.value >= total - 3) {
// 当前页在后面: 1 ... 96 97 98 99 100
list.push("...");
for (let i = total - 4; i <= total; i++) {
list.push(i);
}
} else {
// 当前页在中间: 1 ... 4 5 6 ... 100
list.push("...");
for (let i = value.value - 1; i <= value.value + 1; i++) {
list.push(i);
}
list.push("...");
list.push(total);
}
}
return list;
});
// 跳转到指定页面,处理页码点击事件
function toPage(item: number | string) {
// 忽略省略号点击
if (item == "..." || typeof item !== "number") return;
// 边界检查,确保页码在有效范围内
if (typeof item == "number") {
if (item > totalPage.value) return;
if ((item as number) < 1) return;
}
value.value = item;
// 触发双向绑定更新和变化事件
emit("update:modelValue", item);
emit("change", item);
}
// 跳转到上一页
function prev() {
toPage(value.value - 1);
}
// 跳转到下一页
function next() {
toPage(value.value + 1);
}
watch(
computed(() => props.modelValue),
(val: number) => {
value.value = val;
},
{
immediate: true
}
);
defineExpose({
prev,
next
});
</script>
<style lang="scss" scoped>
.cl-pagination {
@apply flex flex-row justify-center w-full;
&__prev,
&__next,
&__item {
@apply flex flex-row items-center justify-center bg-surface-100 rounded-lg;
height: 60rpx;
width: 60rpx;
&.is-disabled {
@apply opacity-50;
}
&.is-dark {
@apply bg-surface-700;
}
}
&__item {
margin: 0 5rpx;
&.is-active {
@apply bg-primary-500;
}
}
&__prev {
margin-right: 5rpx;
}
&__next {
margin-left: 5rpx;
}
}
</style>

View File

@@ -0,0 +1,20 @@
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClPaginationPassThrough = {
className?: string;
item?: PassThroughProps;
itemText?: PassThroughProps;
prev?: PassThroughProps;
prevIcon?: ClIconProps;
next?: PassThroughProps;
nextIcon?: ClIconProps;
};
export type ClPaginationProps = {
className?: string;
pt?: ClPaginationPassThrough;
modelValue?: number;
total?: number;
size?: number;
};

View File

@@ -0,0 +1,208 @@
<template>
<view class="cl-picker-view">
<view class="cl-picker-view__header" v-if="headers.length > 0">
<cl-text
:pt="{
className: 'flex-1 text-sm text-center'
}"
v-for="(label, index) in headers"
:key="index"
>{{ label }}</cl-text
>
</view>
<view
class="px-[10rpx]"
:style="{
height: parseRpx(height)
}"
>
<picker-view
class="h-full"
:value="value"
:mask-style="maskStyle"
:mask-top-style="maskStyle"
:mask-bottom-style="maskStyle"
:indicator-style="indicatorStyle"
@change="onChange"
>
<picker-view-column
class="cl-select-popup__column"
v-for="(column, columnIndex) in columns"
:key="columnIndex"
>
<view
class="cl-picker-view__item"
:style="{
height: `${itemHeight}px`
}"
v-for="(item, index) in column"
:key="index"
>
<cl-text
:pt="{
className: parseClass([
[isDark, 'text-surface-500'],
[isDark && index == value[columnIndex], 'text-white']
])
}"
>{{ item.label }}</cl-text
>
</view>
</picker-view-column>
</picker-view>
</view>
</view>
</template>
<script setup lang="ts">
import { forInObject, isAppIOS, isDark, isEqual, isNull, parseClass, rpx2px } from "@/cool";
import type { ClSelectOption } from "../../types";
import { parseRpx } from "@/cool";
import { computed } from "vue";
import type { PropType } from "vue";
defineOptions({
name: "cl-select-picker-view"
});
const props = defineProps({
// 选择器表头
headers: {
type: Array as PropType<string[]>,
default: () => []
},
// 选择器值
value: {
type: Array as PropType<number[]>,
default: () => []
},
// 选择器选项
columns: {
type: Array as PropType<ClSelectOption[][]>,
default: () => []
},
// 选择器选项高度
itemHeight: {
type: Number,
default: 42
},
// 选择器高度
height: {
type: Number,
default: 600
}
});
const emit = defineEmits(["change-value", "change-index"]);
// 获取窗口宽度,用于计算选择器列宽
const { windowWidth } = uni.getWindowInfo();
// 顶部显示表头
const headers = computed(() => {
return props.headers.slice(0, props.columns.length);
});
// 遮罩层样式
const maskStyle = computed(() => {
if (isDark.value) {
if(isAppIOS()) {
return `background-color: rgba(0, 0, 0, 0);`
}
return `background-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0)
)`;
}
return "";
});
// 计算选择器列样式
const indicatorStyle = computed(() => {
// 根据窗口宽度和列数计算每列宽度
const width = ((windowWidth - rpx2px(20)) / props.columns.length - rpx2px(2) - 8).toFixed(0);
let str = "";
// 选择器列样式配置
const style = {
height: `${props.itemHeight}px`,
width: `${width}px`,
left: "4px",
backgroundColor: "rgba(10, 10, 10, 0.04)",
borderRadius: "10px",
border: "1rpx solid rgba(10, 10, 10, 0.2)"
};
// 深色模式
if (isDark.value) {
style.backgroundColor = "rgba(0, 0, 0, 0.1)";
style.border = "1rpx solid rgba(255, 255, 255, 0.3)";
}
// 构建样式字符串
forInObject(style, (value, key) => {
str += `${key}: ${value};`;
});
return str;
});
// 监听选择器值改变事件
function onChange(e: UniPickerViewChangeEvent) {
// 获取选择器当前选中值数组
const indexs = e.detail.value;
// 处理因快速滑动导致下级数据未及时渲染而产生的索引越界问题
indexs.forEach((v, i, arr) => {
if (i < props.columns.length) {
const n = props.columns[i].length;
if (v >= n) {
arr[i] = n - 1;
}
}
});
// 相同值不触发事件
if (isEqual(indexs, props.value)) {
return;
}
// 获取所有列的值
const values = props.columns.map((c, i) => {
return isNull(c[indexs[i]]) ? 0 : c[indexs[i]].value;
});
// 返回所有列的值或下标
emit("change-value", values);
emit("change-index", indexs);
}
</script>
<style lang="scss" scoped>
.cl-picker-view {
@apply w-full h-full;
&__header {
@apply flex flex-row items-center py-4;
}
&__item {
@apply flex flex-row items-center justify-center;
}
.uni-picker-view-indicator {
// #ifdef H5
&::after,
&::before {
display: none;
}
// #endif
}
}
</style>

View File

@@ -0,0 +1,10 @@
import type { ClSelectOption } from "../../types";
export type ClSelectPickerViewProps = {
className?: string;
headers?: string[];
value?: number[];
columns?: ClSelectOption[][];
itemHeight?: number;
height?: number;
};

View File

@@ -0,0 +1,647 @@
<template>
<!-- #ifdef H5 -->
<teleport to="uni-app" :disabled="!enablePortal">
<!-- #endif -->
<!-- #ifdef MP -->
<root-portal :enable="enablePortal">
<!-- #endif -->
<view
class="cl-popup-wrapper"
:class="[`cl-popup-wrapper--${direction}`]"
:style="{
zIndex,
pointerEvents
}"
v-show="visible"
v-if="keepAlive ? true : visible"
@touchmove.stop.prevent
>
<view
class="cl-popup-mask"
:class="[
{
'is-open': status == 1,
'is-close': status == 2
},
pt.mask?.className
]"
@tap="maskClose"
v-if="showMask"
></view>
<view
class="cl-popup"
:class="[
{
'is-open': status == 1,
'is-close': status == 2,
'is-custom-navbar': router.isCustomNavbarPage(),
'stop-transition': swipe.isTouch
},
pt.className
]"
:style="popupStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view
class="cl-popup__inner"
:class="[
{
'is-dark': isDark
},
pt.inner?.className
]"
:style="{
paddingBottom
}"
>
<view
class="cl-popup__draw"
:class="[
{
'!bg-surface-400': swipe.isMove
},
pt.draw?.className
]"
v-if="isSwipeClose"
></view>
<view
class="cl-popup__header"
:class="[pt.header?.className]"
v-if="showHeader"
>
<slot name="header">
<cl-text
ellipsis
:pt="{
className: `text-lg font-bold ${pt.header?.text?.className}`
}"
>{{ title }}</cl-text
>
</slot>
<cl-icon
name="close-circle-fill"
:size="40"
:pt="{
className: parseClass([
'absolute right-[24rpx] text-surface-400',
[isDark, 'text-surface-50']
])
}"
@tap="close"
@touchmove.stop
v-if="isOpen && showClose"
></cl-icon>
</view>
<view
class="cl-popup__container"
:class="[pt.container?.className]"
@touchmove.stop
>
<slot></slot>
</view>
</view>
</view>
</view>
<!-- #ifdef MP -->
</root-portal>
<!-- #endif -->
<!-- #ifdef H5 -->
</teleport>
<!-- #endif -->
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch, type PropType } from "vue";
import { getSafeAreaHeight, isAppIOS, parseClass, parsePt, parseRpx } from "@/cool";
import type { ClPopupDirection, PassThroughProps } from "../../types";
import { isDark, router } from "@/cool";
import { config } from "../../config";
defineOptions({
name: "cl-popup"
});
defineSlots<{
header: () => any;
}>();
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 是否可见
modelValue: {
type: Boolean,
default: false
},
// 标题
title: {
type: String,
default: null
},
// 弹出方向
direction: {
type: String as PropType<ClPopupDirection>,
default: "bottom"
},
// 弹出框宽度
size: {
type: [String, Number],
default: ""
},
// 是否显示头部
showHeader: {
type: Boolean,
default: true
},
// 显示关闭按钮
showClose: {
type: Boolean,
default: true
},
// 是否显示遮罩层
showMask: {
type: Boolean,
default: true
},
// 是否点击遮罩层关闭弹窗
maskClosable: {
type: Boolean,
default: true
},
// 是否开启拖拽关闭
swipeClose: {
type: Boolean,
default: true
},
// 拖拽关闭的阈值
swipeCloseThreshold: {
type: Number,
default: 150
},
// 触摸事件响应方式
pointerEvents: {
type: String as PropType<"auto" | "none">,
default: "auto"
},
// 是否开启缓存
keepAlive: {
type: Boolean,
default: false
},
// 是否启用 portal
enablePortal: {
type: Boolean,
default: true
}
});
// 定义组件事件
const emit = defineEmits(["update:modelValue", "open", "opened", "close", "closed", "maskClose"]);
// 透传样式类型定义
type HeaderPassThrough = {
className?: string;
text?: PassThroughProps;
};
type PassThrough = {
className?: string;
inner?: PassThroughProps;
header?: HeaderPassThrough;
container?: PassThroughProps;
mask?: PassThroughProps;
draw?: PassThroughProps;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 控制弹出层显示/隐藏
const visible = ref(false);
// 0: 初始状态 1: 打开中 2: 关闭中
const status = ref(0);
// 标记弹出层是否处于打开状态(包含动画过程)
const isOpen = ref(false);
// 标记弹出层是否已完全打开(动画结束)
const isOpened = ref(false);
// 弹出层z-index值
const zIndex = ref(config.zIndex);
// 计算弹出层高度
const height = computed(() => {
switch (props.direction) {
case "top":
case "bottom":
return parseRpx(props.size); // 顶部和底部弹出时使用传入的size
case "left":
case "right":
return "100%"; // 左右弹出时占满全高
default:
return "";
}
});
// 计算弹出层宽度
const width = computed(() => {
switch (props.direction) {
case "top":
case "bottom":
return "100%"; // 顶部和底部弹出时占满全宽
case "left":
case "right":
case "center":
return parseRpx(props.size); // 其他方向使用传入的size
default:
return "";
}
});
// 底部安全距离
const paddingBottom = computed(() => {
let h = 0;
if (props.direction == "bottom") {
h += getSafeAreaHeight("bottom");
}
return h + "px";
});
// 是否显示拖动条
const isSwipeClose = computed(() => {
return props.direction == "bottom" && props.swipeClose;
});
// 动画定时器
let timer: number = 0;
// 打开弹出层
function open() {
// 递增z-index,保证多个弹出层次序
zIndex.value = config.zIndex++;
if (!visible.value) {
// 显示弹出层
visible.value = true;
// 触发事件
emit("update:modelValue", true);
emit("open");
// 等待DOM更新后开始动画
setTimeout(
() => {
// 设置打开状态
status.value = 1;
// 动画结束后触发opened事件
// @ts-ignore
timer = setTimeout(() => {
isOpened.value = true;
emit("opened");
}, 350);
},
isAppIOS() ? 100 : 50
);
}
}
// 关闭弹出层
function close() {
if (status.value == 1) {
// 重置打开状态
isOpened.value = false;
// 设置关闭状态
status.value = 2;
// 触发事件
emit("close");
// 清除未完成的定时器
if (timer != 0) {
clearTimeout(timer);
}
// 动画结束后隐藏弹出层
// @ts-ignore
timer = setTimeout(() => {
// 隐藏弹出层
visible.value = false;
// 重置状态
status.value = 0;
// 触发事件
emit("update:modelValue", false);
emit("closed");
}, 350);
}
}
// 点击遮罩层关闭
function maskClose() {
if (props.maskClosable) {
close();
}
emit("maskClose");
}
// 滑动状态类型定义
type Swipe = {
isMove: boolean; // 是否移动
isTouch: boolean; // 是否处于触摸状态
startY: number; // 开始触摸的Y坐标
offsetY: number; // Y轴偏移量
};
// 初始化滑动状态数据
const swipe = reactive<Swipe>({
isMove: false, // 是否移动
isTouch: false, // 默认非触摸状态
startY: 0, // 初始Y坐标为0
offsetY: 0 // 初始偏移量为0
});
/**
* 触摸开始事件处理
* @param e 触摸事件对象
* 当弹出层获得焦点且允许滑动关闭时,记录触摸起始位置
*/
function onTouchStart(e: UniTouchEvent) {
if (props.direction != "bottom") {
return;
}
if (isOpened.value && isSwipeClose.value) {
swipe.isTouch = true; // 标记开始触摸
swipe.startY = e.touches[0].clientY; // 记录起始Y坐标
}
}
/**
* 触摸移动事件处理
* @param e 触摸事件对象
* 计算手指移动距离,更新弹出层位置
*/
function onTouchMove(e: UniTouchEvent) {
if (swipe.isTouch) {
// 标记为移动状态
swipe.isMove = true;
// 计算Y轴偏移量
const offsetY = (e.touches[0] as UniTouch).pageY - swipe.startY;
// 只允许向下滑动(offsetY > 0)
if (offsetY > 0) {
swipe.offsetY = offsetY;
}
}
}
/**
* 触摸结束事件处理
* 根据滑动距离判断是否关闭弹出层
*/
function onTouchEnd() {
if (swipe.isTouch) {
// 结束触摸状态
swipe.isTouch = false;
// 结束移动状态
swipe.isMove = false;
// 如果滑动距离超过阈值,则关闭弹出层
if (swipe.offsetY > props.swipeCloseThreshold) {
close();
}
// 重置偏移量
swipe.offsetY = 0;
}
}
/**
* 计算弹出层样式
* 根据滑动状态动态设置transform属性实现位移动画
*/
const popupStyle = computed(() => {
const style = {};
// 基础样式
style["height"] = height.value;
style["width"] = width.value;
// 处于触摸状态时添加位移效果
if (swipe.isTouch) {
style["transform"] = `translateY(${swipe.offsetY}px)`;
}
return style;
});
// 监听modelValue变化
watch(
computed(() => props.modelValue),
(val: boolean) => {
if (val) {
open();
} else {
close();
}
},
{
immediate: true
}
);
// 监听状态变化
watch(status, (val: number) => {
isOpen.value = val == 1;
});
defineExpose({
isOpened,
isOpen,
open,
close
});
</script>
<style lang="scss" scoped>
.cl-popup-wrapper {
@apply h-full w-full;
@apply fixed top-0 bottom-0 left-0 right-0;
pointer-events: none;
.cl-popup-mask {
@apply absolute top-0 bottom-0 left-0 right-0;
@apply h-full w-full;
@apply bg-black opacity-0;
transition-property: opacity;
&.is-open {
@apply opacity-40;
}
&.is-open,
&.is-close {
transition-duration: 0.3s;
}
}
.cl-popup {
@apply absolute duration-300;
transition-property: transform;
&__inner {
@apply bg-white h-full w-full flex flex-col;
&.is-dark {
@apply bg-surface-700;
}
}
&__draw {
@apply bg-surface-200 rounded-md;
@apply absolute top-2 left-1/2;
height: 10rpx;
width: 70rpx;
transform: translateX(-50%);
transition-property: background-color;
transition-duration: 0.2s;
}
&__header {
@apply flex flex-row items-center flex-wrap;
height: 90rpx;
padding: 0 80rpx 0 26rpx;
}
&__container {
flex: 1;
}
&.stop-transition {
@apply transition-none;
}
}
&--left {
.cl-popup {
@apply left-0 top-0;
transform: translateX(-100%);
&.is-open {
transform: translateX(0);
}
}
}
&--right {
.cl-popup {
@apply right-0 top-0;
transform: translateX(100%);
&.is-open {
transform: translateX(0);
}
}
}
&--top {
.cl-popup {
@apply left-0 top-0;
transform: translateY(-100%);
.cl-popup__inner {
@apply rounded-b-2xl;
}
&.is-open {
transform: translateY(0);
}
}
}
&--left,
&--right,
&--top {
& > .cl-popup {
// #ifdef H5
top: 44px;
// #endif
&.is-custom-navbar {
top: 0;
}
}
}
&--left,
&--right {
& > .cl-popup {
// #ifdef H5
height: calc(100% - 44px) !important;
// #endif
}
}
&--bottom {
& > .cl-popup {
@apply left-0 bottom-0;
transform: translateY(100%);
.cl-popup__inner {
@apply rounded-t-2xl;
}
&.is-open {
transform: translateY(0);
}
&.is-close {
transform: translateY(100%);
}
}
}
&--center {
@apply flex flex-col items-center justify-center;
& > .cl-popup {
transform: scale(1.3);
opacity: 0;
transition-property: transform, opacity;
.cl-popup__inner {
@apply rounded-2xl;
}
&.is-open {
transform: translate(0, 0) scale(1);
opacity: 1;
}
}
}
}
</style>

View File

@@ -0,0 +1,33 @@
import type { ClPopupDirection, PassThroughProps } from "../../types";
export type ClPopupHeaderPassThrough = {
className?: string;
text?: PassThroughProps;
};
export type ClPopupPassThrough = {
className?: string;
inner?: PassThroughProps;
header?: ClPopupHeaderPassThrough;
container?: PassThroughProps;
mask?: PassThroughProps;
draw?: PassThroughProps;
};
export type ClPopupProps = {
className?: string;
pt?: ClPopupPassThrough;
modelValue?: boolean;
title?: string;
direction?: ClPopupDirection;
size?: any;
showHeader?: boolean;
showClose?: boolean;
showMask?: boolean;
maskClosable?: boolean;
swipeClose?: boolean;
swipeCloseThreshold?: number;
pointerEvents?: "auto" | "none";
keepAlive?: boolean;
enablePortal?: boolean;
};

View File

@@ -0,0 +1,270 @@
<template>
<view class="cl-progress-circle" :class="[pt.className]">
<canvas
class="cl-progress-circle__canvas"
:id="canvasId"
:style="{
height: `${props.size}px`,
width: `${props.size}px`
}"
></canvas>
<slot name="text">
<cl-text
:value="`${value}${unit}`"
:pt="{
className: parseClass(['absolute', pt.text?.className])
}"
v-if="showText"
></cl-text>
</slot>
</view>
</template>
<script lang="ts" setup>
import {
getColor,
getDevicePixelRatio,
isDark,
isHarmony,
parseClass,
parsePt,
uuid
} from "@/cool";
import { computed, getCurrentInstance, onMounted, ref, watch, type PropType } from "vue";
import type { PassThroughProps } from "../../types";
defineOptions({
name: "cl-progress-circle"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
// 数值 (0-100)
value: {
type: Number,
default: 0
},
// 圆形大小
size: {
type: Number,
default: 120
},
// 线条宽度
strokeWidth: {
type: Number,
default: 8
},
// 进度条颜色
color: {
type: String as PropType<string | null>,
default: null
},
// 底色
unColor: {
type: String as PropType<string | null>,
default: null
},
// 是否显示文本
showText: {
type: Boolean,
default: true
},
// 单位
unit: {
type: String,
default: "%"
},
// 起始角度 (弧度)
startAngle: {
type: Number,
default: -Math.PI / 2
},
// 是否顺时针
clockwise: {
type: Boolean,
default: true
},
// 动画时长
duration: {
type: Number,
default: 500
}
});
const { proxy } = getCurrentInstance()!;
// 获取设备像素比
const dpr = getDevicePixelRatio();
// 透传样式类型定义
type PassThrough = {
className?: string;
text?: PassThroughProps;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// canvas组件上下文
let canvasCtx: CanvasContext | null = null;
// 绘图上下文
let drawCtx: CanvasRenderingContext2D | null = null;
// 生成唯一的canvas ID
const canvasId = `cl-progress-circle__${uuid()}`;
// 当前显示值
const value = ref(0);
// 绘制圆形进度条
function drawProgress() {
if (drawCtx == null) return;
const centerX = (props.size / 2) * dpr;
const centerY = (props.size / 2) * dpr;
const radius = ((props.size - props.strokeWidth) / 2) * dpr;
// 清除画布
// #ifdef APP
drawCtx!.reset();
// #endif
// #ifndef APP
drawCtx!.clearRect(0, 0, props.size * dpr, props.size * dpr);
// #endif
// 优化渲染质量
drawCtx!.textBaseline = "middle";
drawCtx!.textAlign = "center";
drawCtx!.miterLimit = 10;
// 保存当前状态
drawCtx!.save();
// 优化的圆环绘制
const drawCircle = (startAngle: number, endAngle: number, color: string) => {
if (drawCtx == null) return;
drawCtx!.beginPath();
drawCtx!.arc(centerX, centerY, radius, startAngle, endAngle, false);
drawCtx!.strokeStyle = color;
drawCtx!.lineWidth = props.strokeWidth * dpr;
drawCtx!.lineCap = "round";
drawCtx!.lineJoin = "round";
drawCtx!.stroke();
};
// 绘制底色圆环
drawCircle(
0,
2 * Math.PI,
props.unColor ?? (isDark.value ? getColor("surface-700") : getColor("surface-200"))
);
// 绘制进度圆弧
if (value.value > 0) {
const progress = Math.max(0, Math.min(100, value.value)) / 100;
const endAngle = props.startAngle + (props.clockwise ? 1 : -1) * 2 * Math.PI * progress;
drawCircle(props.startAngle, endAngle, props.color ?? getColor("primary-500"));
}
}
// 动画更新数值
function animate(targetValue: number) {
const startValue = value.value;
const startTime = Date.now();
function update() {
// 获取当前时间
const currentTime = Date.now();
// 计算动画经过的时间
const elapsed = currentTime - startTime;
// 计算动画进度
const progress = Math.min(elapsed / props.duration, 1);
// 缓动函数
const easedProgress = 1 - Math.pow(1 - progress, 3);
// 计算当前值
value.value = Math.round(startValue + (targetValue - startValue) * easedProgress);
// 绘制进度条
drawProgress();
if (progress < 1) {
if (canvasCtx != null) {
// @ts-ignore
canvasCtx!.requestAnimationFrame(() => {
update();
});
}
}
}
update();
}
// 初始化画布
function initCanvas() {
setTimeout(
() => {
uni.createCanvasContextAsync({
id: canvasId,
component: proxy,
success: (context: CanvasContext) => {
// 设置canvas上下文
canvasCtx = context;
// 获取绘图上下文
drawCtx = context.getContext("2d")!;
// 设置宽高
drawCtx!.canvas.width = props.size * dpr;
drawCtx!.canvas.height = props.size * dpr;
// 开始动画
animate(props.value);
}
});
},
isHarmony() ? 100 : 0
);
}
onMounted(() => {
initCanvas();
// 监听value变化
watch(
computed(() => props.value),
(val: number) => {
animate(Math.max(0, Math.min(100, val)));
},
{
immediate: true
}
);
watch(
computed(() => [props.color, props.unColor, isDark.value]),
() => {
drawProgress();
}
);
});
defineExpose({
animate
});
</script>
<style lang="scss" scoped>
.cl-progress-circle {
@apply flex flex-col items-center justify-center relative;
}
</style>

View File

@@ -0,0 +1,21 @@
import type { PassThroughProps } from "../../types";
export type ClProgressCirclePassThrough = {
className?: string;
text?: PassThroughProps;
};
export type ClProgressCircleProps = {
className?: string;
pt?: ClProgressCirclePassThrough;
value?: number;
size?: number;
strokeWidth?: number;
color?: string | any;
unColor?: string | any;
showText?: boolean;
unit?: string;
startAngle?: number;
clockwise?: boolean;
duration?: number;
};

View File

@@ -0,0 +1,171 @@
<template>
<view class="cl-progress" :class="[pt.className]">
<view
class="cl-progress__outer"
:class="[
{
'!bg-surface-700': isDark && props.unColor == null
},
pt.outer?.className
]"
:style="outerStyle"
>
<view
class="cl-progress__inner"
:class="[pt.inner?.className]"
:style="innerStyle"
></view>
</view>
<slot name="text">
<cl-rolling-number
:value="value"
:pt="{
className: parseClass(['w-[100rpx] text-center', pt.text?.className])
}"
unit="%"
v-if="showText"
>
</cl-rolling-number>
</slot>
</view>
</template>
<script lang="ts" setup>
import { computed, getCurrentInstance, onMounted, ref, watch } from "vue";
import { isDark, parseClass, parsePt, parseRpx } from "@/cool";
import type { PassThroughProps } from "../../types";
defineOptions({
name: "cl-progress"
});
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 数值
value: {
type: Number,
default: 0
},
// 线条宽度
strokeWidth: {
type: Number,
default: 12
},
// 是否显示文本
showText: {
type: Boolean,
default: true
},
// 线条颜色
color: {
type: String,
default: null
},
// 底色
unColor: {
type: String,
default: null
}
});
const { proxy } = getCurrentInstance()!;
// 透传样式类型定义
type PassThrough = {
className?: string;
outer?: PassThroughProps;
inner?: PassThroughProps;
text?: PassThroughProps;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 当前值
const value = ref(0);
// 进度条宽度
const width = ref(0);
// 外层样式
const outerStyle = computed(() => {
const style = {};
style["height"] = parseRpx(props.strokeWidth);
if (props.unColor != null) {
style["backgroundColor"] = props.unColor!;
}
return style;
});
// 内层样式
const innerStyle = computed(() => {
const style = {};
style["width"] = `${(value.value / 100) * width.value}px`;
if (props.color != null) {
style["backgroundColor"] = props.color!;
}
return style;
});
// 获取进度条宽度
function getWidth() {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-progress__outer")
.boundingClientRect((node) => {
width.value = (node as NodeInfo).width ?? 0;
})
.exec();
}
onMounted(() => {
watch(
computed(() => props.value),
(val: number) => {
getWidth();
setTimeout(() => {
if (val > 100) {
value.value = 100;
} else if (val < 0) {
value.value = 0;
} else {
value.value = val;
}
}, 10);
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-progress {
@apply flex flex-row items-center w-full rounded-md;
&__outer {
@apply bg-surface-100 relative rounded-md;
flex: 1;
}
&__inner {
@apply h-full absolute top-0 left-0 z-10 rounded-md;
@apply bg-primary-500;
transition-property: width;
transition-duration: 0.5s;
}
}
</style>

View File

@@ -0,0 +1,18 @@
import type { PassThroughProps } from "../../types";
export type ClProgressPassThrough = {
className?: string;
outer?: PassThroughProps;
inner?: PassThroughProps;
text?: PassThroughProps;
};
export type ClProgressProps = {
className?: string;
pt?: ClProgressPassThrough;
value?: number;
strokeWidth?: number;
showText?: boolean;
color?: string;
unColor?: string;
};

View File

@@ -0,0 +1,182 @@
<template>
<view :style="{ width: getPx(props.width) + 'px', height: getPx(props.height) + 'px' }">
<canvas
ref="canvasRef"
:canvas-id="qrcodeId"
type="2d"
:id="qrcodeId"
:style="{ width: getPx(props.width) + 'px', height: getPx(props.height) + 'px' }"
></canvas>
</view>
</template>
<script lang="ts" setup>
import {
ref,
watch,
onMounted,
getCurrentInstance,
nextTick,
computed,
type PropType,
onUnmounted,
shallowRef
} from "vue";
import { drawQrcode, type QrcodeOptions } from "./draw";
import { canvasToPng, getPx, isAppIOS, isHarmony, uuid } from "@/cool";
import type { ClQrcodeMode } from "../../types";
defineOptions({
name: "cl-qrcode"
});
const props = defineProps({
// 二维码宽度,支持 px/rpx 单位
width: {
type: String,
default: "200px"
},
// 二维码高度,支持 px/rpx 单位
height: {
type: String,
default: "200px"
},
// 二维码前景色
foreground: {
type: String,
default: "#131313"
},
// 二维码背景色
background: {
type: String,
default: "#FFFFFF"
},
// 定位点颜色,不填写时与前景色一致
pdColor: {
type: String as PropType<string | null>,
default: null
},
// 定位图案圆角半径为0时绘制直角矩形
pdRadius: {
type: Number,
default: 10
},
// 二维码内容
text: {
type: String,
default: "https://cool-js.com/"
},
// logo 图片地址,支持网络、本地路径
logo: {
type: String,
default: ""
},
// logo 大小,支持 px/rpx 单位 (建议不超过二维码尺寸的20%以确保识别率)
logoSize: {
type: String,
default: "40px"
},
// 二维码边距,单位 px
padding: {
type: Number,
default: 5
},
// 二维码样式rect 普通矩形、circular 小圆点、line 线条、rectSmall 小方格
mode: {
type: String as PropType<ClQrcodeMode>,
default: "circular"
}
});
const { proxy } = getCurrentInstance()!;
// 二维码组件id
const qrcodeId = ref<string>("cl-qrcode-" + uuid());
// 二维码组件画布
const canvasRef = shallowRef<UniElement | null>(null);
/**
* 主绘制方法,根据当前 props 生成二维码并绘制到 canvas。
* 支持多平台APP、H5、微信小程序自动适配高分屏。
* 内部调用 drawQrcode 进行二维码点阵绘制。
*/
function drawer() {
const data = {
text: props.text,
size: getPx(props.width),
foreground: props.foreground,
background: props.background,
padding: props.padding,
logo: props.logo,
logoSize: getPx(props.logoSize),
ecc: "H", // 使用最高纠错级别
mode: props.mode,
pdColor: props.pdColor,
pdRadius: props.pdRadius
} as QrcodeOptions;
nextTick(() => {
// #ifdef APP || MP-WEIXIN
uni.createCanvasContextAsync({
id: qrcodeId.value,
component: proxy,
success(context) {
drawQrcode(context, data);
},
fail(err) {
console.error(err);
}
});
// #endif
// #ifdef H5
// @ts-ignore
drawQrcode(canvasRef.value, data);
// #endif
});
}
/**
* 获取当前二维码图片的临时文件地址
* @param call 回调函数,返回图片路径,失败返回空字符串
*/
function toPng(): Promise<string> {
return canvasToPng(canvasRef.value!);
}
// 自动重绘
const stopWatch = watch(
computed(() => [
props.pdColor,
props.pdRadius,
props.foreground,
props.background,
props.text,
props.logo,
props.logoSize,
props.mode,
props.padding
]),
() => {
drawer();
}
);
onMounted(() => {
setTimeout(
() => {
drawer();
},
isHarmony() || isAppIOS() ? 50 : 0
);
});
onUnmounted(() => {
stopWatch();
});
defineExpose({
toPng
});
</script>

View File

@@ -0,0 +1,403 @@
/**
* 导入所需的工具函数和依赖
*/
import { isNull } from "@/cool";
import { generateFrame } from "./qrcode";
import type { ClQrcodeMode } from "../../types";
/**
* 二维码生成配置选项接口
* 定义了生成二维码所需的所有参数
*/
export type QrcodeOptions = {
ecc: string; // 纠错级别,可选 L/M/Q/H,纠错能力依次增强
text: string; // 二维码内容,要编码的文本
size: number; // 二维码尺寸,单位px
foreground: string; // 前景色,二维码数据点的颜色
background: string; // 背景色,二维码背景的颜色
padding: number; // 内边距,二维码四周留白的距离
logo: string; // logo图片地址,可以在二维码中心显示logo
logoSize: number; // logo尺寸,logo图片的显示大小
mode: ClQrcodeMode; // 二维码样式模式,支持矩形、圆形、线条、小方块
pdColor: string | null; // 定位点颜色,三个角上定位图案的颜色,为null时使用前景色
pdRadius: number; // 定位图案圆角半径,为0时绘制直角矩形
};
/**
* 获取当前设备的像素比
* 用于处理高分屏显示
* @returns 设备像素比
*/
function getRatio() {
// #ifdef APP || MP-WEIXIN
return uni.getWindowInfo().pixelRatio; // App和小程序环境
// #endif
// #ifdef H5
return window.devicePixelRatio; // H5环境
// #endif
}
/**
* 绘制圆角矩形
* 兼容不同平台的圆角矩形绘制方法
* @param ctx Canvas上下文
* @param x 矩形左上角x坐标
* @param y 矩形左上角y坐标
* @param width 矩形宽度
* @param height 矩形高度
* @param radius 圆角半径
*/
function drawRoundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number
) {
if (radius <= 0) {
// 圆角半径为0时直接绘制矩形
ctx.fillRect(x, y, width, height);
return;
}
// 限制圆角半径不超过矩形的一半
const maxRadius = Math.min(width, height) / 2;
const r = Math.min(radius, maxRadius);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + width - r, y);
ctx.arcTo(x + width, y, x + width, y + r, r);
ctx.lineTo(x + width, y + height - r);
ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
ctx.lineTo(x + r, y + height);
ctx.arcTo(x, y + height, x, y + height - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
ctx.fill();
}
/**
* 绘制定位图案
* 绘制7x7的定位图案,包含外框、内框和中心点
* @param ctx Canvas上下文
* @param startX 定位图案起始X坐标
* @param startY 定位图案起始Y坐标
* @param px 单个像素点大小
* @param pdColor 定位图案颜色
* @param background 背景颜色
* @param radius 圆角半径
*/
function drawPositionPattern(
ctx: CanvasRenderingContext2D,
startX: number,
startY: number,
px: number,
pdColor: string,
background: string,
radius: number
) {
const patternSize = px * 7; // 定位图案总尺寸 7x7
// 绘制外层边框 (7x7)
ctx.fillStyle = pdColor;
drawRoundedRect(ctx, startX, startY, patternSize, patternSize, radius);
// 绘制内层空心区域 (5x5)
ctx.fillStyle = background;
const innerStartX = startX + px;
const innerStartY = startY + px;
const innerSize = px * 5;
const innerRadius = Math.max(0, radius - px); // 内层圆角适当减小
drawRoundedRect(ctx, innerStartX, innerStartY, innerSize, innerSize, innerRadius);
// 绘制中心实心区域 (3x3)
ctx.fillStyle = pdColor;
const centerStartX = startX + px * 2;
const centerStartY = startY + px * 2;
const centerSize = px * 3;
const centerRadius = Math.max(0, radius - px * 2); // 中心圆角适当减小
drawRoundedRect(ctx, centerStartX, centerStartY, centerSize, centerSize, centerRadius);
}
/**
* 绘制二维码到Canvas上下文
* 主要的二维码绘制函数,处理不同平台的兼容性
* @param context Canvas上下文对象
* @param options 二维码配置选项
*/
export function drawQrcode(context: CanvasContext, options: QrcodeOptions) {
// 获取2D绘图上下文
const ctx = context.getContext("2d")!;
if (isNull(ctx)) return;
// 获取设备像素比,用于高清适配
const ratio = getRatio();
// App和小程序平台的画布初始化
// #ifdef APP || MP-WEIXIN
const c1 = ctx.canvas;
// 清空画布并设置尺寸
ctx.clearRect(0, 0, c1.offsetWidth, c1.offsetHeight);
c1.width = c1.offsetWidth * ratio;
c1.height = c1.offsetHeight * ratio;
// #endif
// #ifdef APP
ctx.reset();
// #endif
// H5平台的画布初始化
// #ifdef H5
const c2 = context as HTMLCanvasElement;
c2.width = c2.offsetWidth * ratio;
c2.height = c2.offsetHeight * ratio;
// #endif
// 缩放画布以适配高分屏
ctx.scale(ratio, ratio);
// 生成二维码数据矩阵
const frame = generateFrame(options.text, options.ecc);
const points = frame.frameBuffer; // 点阵数据
const width = frame.width; // 矩阵宽度
// 计算二维码内容区域大小减去四周的padding
const contentSize = options.size - options.padding * 2;
// 计算每个数据点的实际像素大小
const px = contentSize / width;
// 二维码内容的起始位置考虑padding
const offsetX = options.padding;
const offsetY = options.padding;
// 绘制整个画布背景
ctx.fillStyle = options.background;
ctx.fillRect(0, 0, options.size, options.size);
/**
* 判断坐标点是否在定位图案区域内
* 二维码三个角上的定位图案是7x7的方块
* @param i 横坐标
* @param j 纵坐标
* @param width 二维码宽度
* @returns 是否是定位点
*/
function isPositionDetectionPattern(i: number, j: number, width: number): boolean {
// 判断三个角的定位图案(7x7)
if (i < 7 && j < 7) return true; // 左上角
if (i > width - 8 && j < 7) return true; // 右上角
if (i < 7 && j > width - 8) return true; // 左下角
return false;
}
/**
* 判断坐标点是否在Logo区域内(包含缓冲区)
* @param i 横坐标
* @param j 纵坐标
* @param width 二维码宽度
* @param logoSize logo尺寸(像素)
* @param px 单个数据点像素大小
* @returns 是否在logo区域内
*/
function isInLogoArea(
i: number,
j: number,
width: number,
logoSize: number,
px: number
): boolean {
if (logoSize <= 0) return false;
// 计算logo在矩阵中占用的点数限制最大不超过二维码总宽度的25%
// 根据二维码标准中心区域最多可以遮挡约30%的数据但为了确保识别率我们限制在20%
const maxLogoRatio = 0.2; // 20%的区域用于logo
const maxLogoPoints = Math.floor(width * maxLogoRatio);
const logoPoints = Math.min(Math.ceil(logoSize / px), maxLogoPoints);
// 减少缓冲区,只保留必要的边距,避免过度遮挡数据
// 当logo较小时不需要缓冲区当logo较大时才添加最小缓冲区
const buffer = logoPoints > width * 0.1 ? 1 : 0;
const totalLogoPoints = logoPoints + buffer * 2;
// 计算logo区域在矩阵中的中心位置
const centerI = Math.floor(width / 2);
const centerJ = Math.floor(width / 2);
// 计算logo区域的边界
const halfSize = Math.floor(totalLogoPoints / 2);
const minI = centerI - halfSize;
const maxI = centerI + halfSize;
const minJ = centerJ - halfSize;
const maxJ = centerJ + halfSize;
// 判断当前点是否在logo区域内
return i >= minI && i <= maxI && j >= minJ && j <= maxJ;
}
// 先绘制定位图案
const pdColor = options.pdColor ?? options.foreground;
const radius = options.pdRadius;
// 绘制三个定位图案
// 左上角 (0, 0)
drawPositionPattern(ctx, offsetX, offsetY, px, pdColor, options.background, radius);
// 右上角 (width-7, 0)
drawPositionPattern(
ctx,
offsetX + (width - 7) * px,
offsetY,
px,
pdColor,
options.background,
radius
);
// 左下角 (0, width-7)
drawPositionPattern(
ctx,
offsetX,
offsetY + (width - 7) * px,
px,
pdColor,
options.background,
radius
);
// 点的间距,用于圆形和小方块模式
const dot = px * 0.1;
// 遍历绘制数据点(跳过定位图案区域和logo区域)
for (let i = 0; i < width; i++) {
for (let j = 0; j < width; j++) {
if (points[j * width + i] > 0) {
// 跳过定位图案区域
if (isPositionDetectionPattern(i, j, width)) {
continue;
}
// 跳过logo区域(包含缓冲区)
if (options.logo != "" && isInLogoArea(i, j, width, options.logoSize, px)) {
continue;
}
// 绘制数据点
ctx.fillStyle = options.foreground;
const x = offsetX + px * i;
const y = offsetY + px * j;
// 根据不同模式绘制数据点
switch (options.mode) {
case "line": // 线条模式 - 绘制水平线条
ctx.fillRect(x, y, px, px / 2);
break;
case "circular": // 圆形模式 - 绘制圆点
ctx.beginPath();
const rx = x + px / 2 - dot;
const ry = y + px / 2 - dot;
ctx.arc(rx, ry, px / 2 - dot, 0, 2 * Math.PI);
ctx.fill();
ctx.closePath();
break;
case "rectSmall": // 小方块模式 - 绘制小一号的方块
ctx.fillRect(x + dot, y + dot, px - dot * 2, px - dot * 2);
break;
default: // 默认实心方块模式
ctx.fillRect(x, y, px, px);
}
}
}
}
// 绘制 Logo
if (options.logo != "") {
let img: Image;
// 微信小程序环境创建图片
// #ifdef MP-WEIXIN || APP-HARMONY
img = context.createImage();
// #endif
// 其他环境创建图片
// #ifndef MP-WEIXIN || APP-HARMONY
img = new Image(options.logoSize, options.logoSize);
// #endif
// 设置图片加载完成后的回调,然后设置图片源
img.onload = () => {
drawLogo(ctx, options, img);
};
img.src = options.logo;
}
}
/**
* 在二维码中心绘制Logo
* 在二维码中心位置绘制Logo图片,优化背景处理以减少对二维码数据的影响
* @param ctx Canvas上下文
* @param options 二维码配置
* @param img Logo图片对象
*/
function drawLogo(ctx: CanvasRenderingContext2D, options: QrcodeOptions, img: Image) {
ctx.save(); // 保存当前绘图状态
// 计算二维码内容区域的中心位置考虑padding
const contentSize = options.size - options.padding * 2;
const contentCenterX = options.padding + contentSize / 2;
const contentCenterY = options.padding + contentSize / 2;
// 优化背景处理:减少背景边距,最小化对二维码数据的影响
// 背景边距从6px减少到3px降低对数据点的遮挡
const backgroundPadding = 3; // 背景比logo大3px
const backgroundSize = options.logoSize + backgroundPadding * 2;
// 绘制白色背景作为Logo的底色适当大于logo以确保可读性
ctx.fillStyle = options.background; // 使用二维码背景色而不是固定白色,保持一致性
const backgroundX = contentCenterX - backgroundSize / 2;
const backgroundY = contentCenterY - backgroundSize / 2;
// 绘制圆角背景让logo与二维码更好融合
const cornerRadius = Math.min(backgroundSize * 0.1, 6); // 背景圆角半径
drawRoundedRect(ctx, backgroundX, backgroundY, backgroundSize, backgroundSize, cornerRadius);
// 获取图片信息后绘制Logo
uni.getImageInfo({
src: options.logo,
success: (imgInfo) => {
// 计算logo的精确位置
const logoX = contentCenterX - options.logoSize / 2;
const logoY = contentCenterY - options.logoSize / 2;
// 绘制Logo图片减少边距从3px到1.5px让logo更大一些
const logoPadding = 1.5;
const actualLogoSize = options.logoSize - logoPadding * 2;
// #ifdef APP-HARMONY
ctx.drawImage(
img,
logoX + logoPadding,
logoY + logoPadding,
actualLogoSize,
actualLogoSize,
0,
0,
imgInfo.width,
imgInfo.height
);
// #endif
// #ifndef APP-HARMONY
ctx.drawImage(img, logoX + logoPadding, logoY + logoPadding, actualLogoSize, actualLogoSize);
// #endif
ctx.restore(); // 恢复之前的绘图状态
},
fail(err) {
console.error(err);
}
});
}

View File

@@ -0,0 +1,17 @@
import type { QrcodeOptions } from "./draw";
import type { ClQrcodeMode } from "../../types";
export type ClQrcodeProps = {
className?: string;
width?: string;
height?: string;
foreground?: string;
background?: string;
pdColor?: string | any;
pdRadius?: number;
text?: string;
logo?: string;
logoSize?: string;
padding?: number;
mode?: ClQrcodeMode;
};

View File

@@ -0,0 +1,972 @@
export type GenerateFrameResult = {
frameBuffer: Uint8Array;
width: number;
};
/**
* 二维码生成器
* @description 纯 UTS 实现的二维码生成算法,支持多平台,兼容 uni-app x。核心算法参考 QR Code 标准,支持自定义纠错级别、自动适配内容长度。
* @version 1.0.0
* @平台兼容性 App、H5、微信小程序、UTS
* @注意事项
* - 仅支持 8bit 字符串内容,不支持数字/字母/汉字等模式优化
* - 生成结果为二维码点阵数据和宽度,需配合 canvas 绘制
* - 纠错级别支持 'L'/'M'/'Q'/'H',默认 'L'
*/
// 对齐块间距表 - 不同版本二维码的对齐块分布位置
const ALIGNMENT_DELTA = [
0, 11, 15, 19, 23, 27, 31, 16, 18, 20, 22, 24, 26, 28, 20, 22, 24, 24, 26, 28, 28, 22, 24, 24,
26, 26, 28, 28, 24, 24, 26, 26, 26, 28, 28, 24, 26, 26, 26, 28, 28
] as number[];
// 纠错块参数表 - 每个版本包含4个参数:块数、数据宽度、纠错宽度
const ECC_BLOCKS = [
1, 0, 19, 7, 1, 0, 16, 10, 1, 0, 13, 13, 1, 0, 9, 17, 1, 0, 34, 10, 1, 0, 28, 16, 1, 0, 22, 22,
1, 0, 16, 28, 1, 0, 55, 15, 1, 0, 44, 26, 2, 0, 17, 18, 2, 0, 13, 22, 1, 0, 80, 20, 2, 0, 32,
18, 2, 0, 24, 26, 4, 0, 9, 16, 1, 0, 108, 26, 2, 0, 43, 24, 2, 2, 15, 18, 2, 2, 11, 22, 2, 0,
68, 18, 4, 0, 27, 16, 4, 0, 19, 24, 4, 0, 15, 28, 2, 0, 78, 20, 4, 0, 31, 18, 2, 4, 14, 18, 4,
1, 13, 26, 2, 0, 97, 24, 2, 2, 38, 22, 4, 2, 18, 22, 4, 2, 14, 26, 2, 0, 116, 30, 3, 2, 36, 22,
4, 4, 16, 20, 4, 4, 12, 24, 2, 2, 68, 18, 4, 1, 43, 26, 6, 2, 19, 24, 6, 2, 15, 28, 4, 0, 81,
20, 1, 4, 50, 30, 4, 4, 22, 28, 3, 8, 12, 24, 2, 2, 92, 24, 6, 2, 36, 22, 4, 6, 20, 26, 7, 4,
14, 28, 4, 0, 107, 26, 8, 1, 37, 22, 8, 4, 20, 24, 12, 4, 11, 22, 3, 1, 115, 30, 4, 5, 40, 24,
11, 5, 16, 20, 11, 5, 12, 24, 5, 1, 87, 22, 5, 5, 41, 24, 5, 7, 24, 30, 11, 7, 12, 24, 5, 1, 98,
24, 7, 3, 45, 28, 15, 2, 19, 24, 3, 13, 15, 30, 1, 5, 107, 28, 10, 1, 46, 28, 1, 15, 22, 28, 2,
17, 14, 28, 5, 1, 120, 30, 9, 4, 43, 26, 17, 1, 22, 28, 2, 19, 14, 28, 3, 4, 113, 28, 3, 11, 44,
26, 17, 4, 21, 26, 9, 16, 13, 26, 3, 5, 107, 28, 3, 13, 41, 26, 15, 5, 24, 30, 15, 10, 15, 28,
4, 4, 116, 28, 17, 0, 42, 26, 17, 6, 22, 28, 19, 6, 16, 30, 2, 7, 111, 28, 17, 0, 46, 28, 7, 16,
24, 30, 34, 0, 13, 24, 4, 5, 121, 30, 4, 14, 47, 28, 11, 14, 24, 30, 16, 14, 15, 30, 6, 4, 117,
30, 6, 14, 45, 28, 11, 16, 24, 30, 30, 2, 16, 30, 8, 4, 106, 26, 8, 13, 47, 28, 7, 22, 24, 30,
22, 13, 15, 30, 10, 2, 114, 28, 19, 4, 46, 28, 28, 6, 22, 28, 33, 4, 16, 30, 8, 4, 122, 30, 22,
3, 45, 28, 8, 26, 23, 30, 12, 28, 15, 30, 3, 10, 117, 30, 3, 23, 45, 28, 4, 31, 24, 30, 11, 31,
15, 30, 7, 7, 116, 30, 21, 7, 45, 28, 1, 37, 23, 30, 19, 26, 15, 30, 5, 10, 115, 30, 19, 10, 47,
28, 15, 25, 24, 30, 23, 25, 15, 30, 13, 3, 115, 30, 2, 29, 46, 28, 42, 1, 24, 30, 23, 28, 15,
30, 17, 0, 115, 30, 10, 23, 46, 28, 10, 35, 24, 30, 19, 35, 15, 30, 17, 1, 115, 30, 14, 21, 46,
28, 29, 19, 24, 30, 11, 46, 15, 30, 13, 6, 115, 30, 14, 23, 46, 28, 44, 7, 24, 30, 59, 1, 16,
30, 12, 7, 121, 30, 12, 26, 47, 28, 39, 14, 24, 30, 22, 41, 15, 30, 6, 14, 121, 30, 6, 34, 47,
28, 46, 10, 24, 30, 2, 64, 15, 30, 17, 4, 122, 30, 29, 14, 46, 28, 49, 10, 24, 30, 24, 46, 15,
30, 4, 18, 122, 30, 13, 32, 46, 28, 48, 14, 24, 30, 42, 32, 15, 30, 20, 4, 117, 30, 40, 7, 47,
28, 43, 22, 24, 30, 10, 67, 15, 30, 19, 6, 118, 30, 18, 31, 47, 28, 34, 34, 24, 30, 20, 61, 15,
30
] as number[];
// 纠错级别映射表 - 将人类可读的纠错级别映射为内部数值
const ECC_LEVELS = new Map<string, number>([
["L", 1],
["M", 0],
["Q", 3],
["H", 2]
]);
// 最终格式信息掩码表 - 用于格式信息区域的掩码计算(level << 3 | mask)
const FINAL_FORMAT = [
0x77c4, 0x72f3, 0x7daa, 0x789d, 0x662f, 0x6318, 0x6c41, 0x6976 /* L */, 0x5412, 0x5125, 0x5e7c,
0x5b4b, 0x45f9, 0x40ce, 0x4f97, 0x4aa0 /* M */, 0x355f, 0x3068, 0x3f31, 0x3a06, 0x24b4, 0x2183,
0x2eda, 0x2bed /* Q */, 0x1689, 0x13be, 0x1ce7, 0x19d0, 0x0762, 0x0255, 0x0d0c, 0x083b /* H */
];
// Galois域指数表 - 用于纠错码计算的查找表
const GALOIS_EXPONENT = [
0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1d, 0x3a, 0x74, 0xe8, 0xcd, 0x87, 0x13, 0x26,
0x4c, 0x98, 0x2d, 0x5a, 0xb4, 0x75, 0xea, 0xc9, 0x8f, 0x03, 0x06, 0x0c, 0x18, 0x30, 0x60, 0xc0,
0x9d, 0x27, 0x4e, 0x9c, 0x25, 0x4a, 0x94, 0x35, 0x6a, 0xd4, 0xb5, 0x77, 0xee, 0xc1, 0x9f, 0x23,
0x46, 0x8c, 0x05, 0x0a, 0x14, 0x28, 0x50, 0xa0, 0x5d, 0xba, 0x69, 0xd2, 0xb9, 0x6f, 0xde, 0xa1,
0x5f, 0xbe, 0x61, 0xc2, 0x99, 0x2f, 0x5e, 0xbc, 0x65, 0xca, 0x89, 0x0f, 0x1e, 0x3c, 0x78, 0xf0,
0xfd, 0xe7, 0xd3, 0xbb, 0x6b, 0xd6, 0xb1, 0x7f, 0xfe, 0xe1, 0xdf, 0xa3, 0x5b, 0xb6, 0x71, 0xe2,
0xd9, 0xaf, 0x43, 0x86, 0x11, 0x22, 0x44, 0x88, 0x0d, 0x1a, 0x34, 0x68, 0xd0, 0xbd, 0x67, 0xce,
0x81, 0x1f, 0x3e, 0x7c, 0xf8, 0xed, 0xc7, 0x93, 0x3b, 0x76, 0xec, 0xc5, 0x97, 0x33, 0x66, 0xcc,
0x85, 0x17, 0x2e, 0x5c, 0xb8, 0x6d, 0xda, 0xa9, 0x4f, 0x9e, 0x21, 0x42, 0x84, 0x15, 0x2a, 0x54,
0xa8, 0x4d, 0x9a, 0x29, 0x52, 0xa4, 0x55, 0xaa, 0x49, 0x92, 0x39, 0x72, 0xe4, 0xd5, 0xb7, 0x73,
0xe6, 0xd1, 0xbf, 0x63, 0xc6, 0x91, 0x3f, 0x7e, 0xfc, 0xe5, 0xd7, 0xb3, 0x7b, 0xf6, 0xf1, 0xff,
0xe3, 0xdb, 0xab, 0x4b, 0x96, 0x31, 0x62, 0xc4, 0x95, 0x37, 0x6e, 0xdc, 0xa5, 0x57, 0xae, 0x41,
0x82, 0x19, 0x32, 0x64, 0xc8, 0x8d, 0x07, 0x0e, 0x1c, 0x38, 0x70, 0xe0, 0xdd, 0xa7, 0x53, 0xa6,
0x51, 0xa2, 0x59, 0xb2, 0x79, 0xf2, 0xf9, 0xef, 0xc3, 0x9b, 0x2b, 0x56, 0xac, 0x45, 0x8a, 0x09,
0x12, 0x24, 0x48, 0x90, 0x3d, 0x7a, 0xf4, 0xf5, 0xf7, 0xf3, 0xfb, 0xeb, 0xcb, 0x8b, 0x0b, 0x16,
0x2c, 0x58, 0xb0, 0x7d, 0xfa, 0xe9, 0xcf, 0x83, 0x1b, 0x36, 0x6c, 0xd8, 0xad, 0x47, 0x8e, 0x00
];
// Galois域对数表 - 用于纠错码计算的反向查找表
const GALOIS_LOG = [
0xff, 0x00, 0x01, 0x19, 0x02, 0x32, 0x1a, 0xc6, 0x03, 0xdf, 0x33, 0xee, 0x1b, 0x68, 0xc7, 0x4b,
0x04, 0x64, 0xe0, 0x0e, 0x34, 0x8d, 0xef, 0x81, 0x1c, 0xc1, 0x69, 0xf8, 0xc8, 0x08, 0x4c, 0x71,
0x05, 0x8a, 0x65, 0x2f, 0xe1, 0x24, 0x0f, 0x21, 0x35, 0x93, 0x8e, 0xda, 0xf0, 0x12, 0x82, 0x45,
0x1d, 0xb5, 0xc2, 0x7d, 0x6a, 0x27, 0xf9, 0xb9, 0xc9, 0x9a, 0x09, 0x78, 0x4d, 0xe4, 0x72, 0xa6,
0x06, 0xbf, 0x8b, 0x62, 0x66, 0xdd, 0x30, 0xfd, 0xe2, 0x98, 0x25, 0xb3, 0x10, 0x91, 0x22, 0x88,
0x36, 0xd0, 0x94, 0xce, 0x8f, 0x96, 0xdb, 0xbd, 0xf1, 0xd2, 0x13, 0x5c, 0x83, 0x38, 0x46, 0x40,
0x1e, 0x42, 0xb6, 0xa3, 0xc3, 0x48, 0x7e, 0x6e, 0x6b, 0x3a, 0x28, 0x54, 0xfa, 0x85, 0xba, 0x3d,
0xca, 0x5e, 0x9b, 0x9f, 0x0a, 0x15, 0x79, 0x2b, 0x4e, 0xd4, 0xe5, 0xac, 0x73, 0xf3, 0xa7, 0x57,
0x07, 0x70, 0xc0, 0xf7, 0x8c, 0x80, 0x63, 0x0d, 0x67, 0x4a, 0xde, 0xed, 0x31, 0xc5, 0xfe, 0x18,
0xe3, 0xa5, 0x99, 0x77, 0x26, 0xb8, 0xb4, 0x7c, 0x11, 0x44, 0x92, 0xd9, 0x23, 0x20, 0x89, 0x2e,
0x37, 0x3f, 0xd1, 0x5b, 0x95, 0xbc, 0xcf, 0xcd, 0x90, 0x87, 0x97, 0xb2, 0xdc, 0xfc, 0xbe, 0x61,
0xf2, 0x56, 0xd3, 0xab, 0x14, 0x2a, 0x5d, 0x9e, 0x84, 0x3c, 0x39, 0x53, 0x47, 0x6d, 0x41, 0xa2,
0x1f, 0x2d, 0x43, 0xd8, 0xb7, 0x7b, 0xa4, 0x76, 0xc4, 0x17, 0x49, 0xec, 0x7f, 0x0c, 0x6f, 0xf6,
0x6c, 0xa1, 0x3b, 0x52, 0x29, 0x9d, 0x55, 0xaa, 0xfb, 0x60, 0x86, 0xb1, 0xbb, 0xcc, 0x3e, 0x5a,
0xcb, 0x59, 0x5f, 0xb0, 0x9c, 0xa9, 0xa0, 0x51, 0x0b, 0xf5, 0x16, 0xeb, 0x7a, 0x75, 0x2c, 0xd7,
0x4f, 0xae, 0xd5, 0xe9, 0xe6, 0xe7, 0xad, 0xe8, 0x74, 0xd6, 0xf4, 0xea, 0xa8, 0x50, 0x58, 0xaf
];
// 二维码质量评估系数 - 用于计算最佳掩码模式
// N1: 连续5个及以上同色模块的惩罚分数
const N1 = 3;
// N2: 2x2同色模块区域的惩罚分数
const N2 = 3;
// N3: 类似定位图形的图案(1:1:3:1:1)的惩罚分数
const N3 = 40;
// N4: 黑白模块比例不均衡的惩罚分数
const N4 = 10;
// 版本信息掩码表 - 用于在二维码中嵌入版本信息
const VERSION_BLOCK = [
0xc94, 0x5bc, 0xa99, 0x4d3, 0xbf6, 0x762, 0x847, 0x60d, 0x928, 0xb78, 0x45d, 0xa17, 0x532,
0x9a6, 0x683, 0x8c9, 0x7ec, 0xec4, 0x1e1, 0xfab, 0x08e, 0xc1a, 0x33f, 0xd75, 0x250, 0x9d5,
0x6f0, 0x8ba, 0x79f, 0xb0b, 0x42e, 0xa64, 0x541, 0xc69
];
/**
* 生成二维码点阵
* @param _str 输入字符串,支持任意文本内容,默认 null 表示空字符串
* @param ecc 纠错级别,可选 'L' | 'M' | 'Q' | 'H',默认 'L'
* @returns {GenerateFrameResult} 返回二维码点阵数据和宽度
*/
export function generateFrame(
_str: string | null = null,
ecc: string | null = null
): GenerateFrameResult {
// 变量声明区,所有临时变量、缓冲区
let i: number;
let t: number;
let j: number;
let k: number;
let m: number;
let v: number;
let x: number;
let y: number;
let version: number;
let str = _str == null ? "" : _str;
let width = 0;
// 获取纠错级别数值
let eccLevel = ECC_LEVELS.get(ecc == null ? "L" : ecc)!;
// Data block
// 数据块、纠错块、块数
let dataBlock: number;
let eccBlock: number;
let neccBlock1: number;
let neccBlock2: number;
// ECC buffer.
// 纠错码缓冲区 - 先初始化为空数组,后面会重新赋值
let eccBuffer: Uint8Array;
// Image buffer.
// 二维码点阵缓冲区 - 先初始化为空数组,后面会重新赋值
let frameBuffer = new Uint8Array(0);
// Fixed part of the image.
// 点阵掩码缓冲区(标记不可变区域) - 先初始化为空数组,后面会重新赋值
let frameMask = new Uint8Array(0);
// Generator polynomial.
// 生成多项式缓冲区(纠错码计算用) - 先初始化为空数组,后面会重新赋值
let polynomial = new Uint8Array(0);
// Data input buffer.
// 数据输入缓冲区 - 先初始化为空数组,后面会重新赋值
let stringBuffer = new Uint8Array(0);
/**
* 设置掩码位,表示该点为不可变区域(对称处理)
* @param _x 横坐标
* @param _y 纵坐标
*/
function setMask(_x: number, _y: number) {
let bit: number;
let x = _x;
let y = _y;
if (x > y) {
bit = x;
x = y;
y = bit;
}
bit = y;
bit *= y;
bit += y;
bit >>= 1;
bit += x;
frameMask[bit] = 1;
}
/**
* 添加对齐块,设置对应点阵和掩码
* @param _x 横坐标
* @param _y 纵坐标
*/
function addAlignment(_x: number, _y: number) {
let i: number;
let x = _x;
let y = _y;
frameBuffer[x + width * y] = 1;
for (i = -2; i < 2; i++) {
frameBuffer[x + i + width * (y - 2)] = 1;
frameBuffer[x - 2 + width * (y + i + 1)] = 1;
frameBuffer[x + 2 + width * (y + i)] = 1;
frameBuffer[x + i + 1 + width * (y + 2)] = 1;
}
for (i = 0; i < 2; i++) {
setMask(x - 1, y + i);
setMask(x + 1, y - i);
setMask(x - i, y - 1);
setMask(x + i, y + 1);
}
for (i = 2; i < 4; i++) {
frameBuffer[x + i + width * (y - 2)] = 1;
frameBuffer[x - 2 + width * (y + i - 1)] = 1;
frameBuffer[x + 2 + width * (y + i - 2)] = 1;
frameBuffer[x - 1 + width * (y + i - 2)] = 1;
}
}
/**
* Galois 域取模运算
* @param _x 输入数值
* @returns {number} 取模结果
*/
function modN(_x: number): number {
var x = _x;
while (x >= 255) {
x -= 255;
x = (x >> 8) + (x & 255);
}
return x;
}
/**
* 计算并追加纠错码到数据块
* @param _data 数据起始索引
* @param _dataLength 数据长度
* @param _ecc 纠错码起始索引
* @param _eccLength 纠错码长度
*/
function appendData(_data: number, _dataLength: number, _ecc: number, _eccLength: number) {
let bit: number;
let i: number;
let j: number;
let data = _data;
let dataLength = _dataLength;
let ecc = _ecc;
let eccLength = _eccLength;
for (i = 0; i < eccLength; i++) {
stringBuffer[ecc + i] = 0;
}
for (i = 0; i < dataLength; i++) {
bit = GALOIS_LOG[stringBuffer[data + i] ^ stringBuffer[ecc]];
if (bit != 255) {
for (j = 1; j < eccLength; j++) {
stringBuffer[ecc + j - 1] =
stringBuffer[ecc + j] ^
GALOIS_EXPONENT[modN(bit + polynomial[eccLength - j])];
}
} else {
for (j = ecc; j < ecc + eccLength; j++) {
stringBuffer[j] = stringBuffer[j + 1];
}
}
stringBuffer[ecc + eccLength - 1] =
bit == 255 ? 0 : GALOIS_EXPONENT[modN(bit + polynomial[0])];
}
}
/**
* 判断某点是否为掩码区域
* @param _x 横坐标
* @param _y 纵坐标
* @returns {boolean} 是否为掩码
*/
function isMasked(_x: number, _y: number): boolean {
let bit: number;
let x = _x;
let y = _y;
if (x > y) {
bit = x;
x = y;
y = bit;
}
bit = y;
bit += y * y;
bit >>= 1;
bit += x;
return frameMask[bit] == 1;
}
/**
* 根据 QR Code 标准,应用指定的掩码 pattern
* @param mask 掩码编号 (0-7)
*/
function applyMask(mask: number) {
for (let y = 0; y < width; y++) {
for (let x = 0; x < width; x++) {
if (!isMasked(x, y)) {
let shouldInvert = false;
switch (mask) {
case 0:
shouldInvert = (x + y) % 2 == 0;
break;
case 1:
shouldInvert = y % 2 == 0;
break;
case 2:
shouldInvert = x % 3 == 0;
break;
case 3:
shouldInvert = (x + y) % 3 == 0;
break;
case 4:
shouldInvert = (Math.floor(y / 2) + Math.floor(x / 3)) % 2 == 0;
break;
case 5:
shouldInvert = ((x * y) % 2) + ((x * y) % 3) == 0;
break;
case 6:
shouldInvert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0;
break;
case 7:
shouldInvert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0;
break;
}
if (shouldInvert) {
frameBuffer[x + y * width] ^= 1;
}
}
}
}
}
/**
* 计算连续同色块的"坏度"分数
* @param runLengths
* @param length 块长度
* @returns {number} 坏度分数
*/
function getBadRuns(runLengths: number[], length: number): number {
let badRuns = 0;
let i: number;
for (i = 0; i <= length; i++) {
if (i < runLengths.length && runLengths[i] >= 5) {
badRuns += N1 + runLengths[i] - 5;
}
}
// FBFFFBF as in finder.
for (i = 3; i < length - 1; i += 2) {
// 检查数组索引是否越界
if (i + 2 >= runLengths.length || i - 3 < 0) {
continue;
}
if (
runLengths[i - 2] == runLengths[i + 2] &&
runLengths[i + 2] == runLengths[i - 1] &&
runLengths[i - 1] == runLengths[i + 1] &&
runLengths[i - 1] * 3 == runLengths[i] &&
// Background around the foreground pattern? Not part of the specs.
(runLengths[i - 3] == 0 ||
i + 3 > length ||
runLengths[i - 3] * 3 >= runLengths[i] * 4 ||
runLengths[i + 3] * 3 >= runLengths[i] * 4)
) {
badRuns += N3;
}
}
return badRuns;
}
/**
* 评估当前二维码点阵的整体"坏度"
* @returns {number} 坏度分数
*/
function checkBadness(): number {
let b: number;
let b1: number;
let bad = 0;
let big: number;
let bw = 0;
let count = 0;
let h: number;
let x: number;
let y: number;
// 优化在函数内创建badBuffer避免外部变量的内存泄漏风险
let badBuffer = new Array<number>(width);
// Blocks of same colour.
for (y = 0; y < width - 1; y++) {
for (x = 0; x < width - 1; x++) {
// All foreground colour.
if (
(frameBuffer[x + width * y] == 1 &&
frameBuffer[x + 1 + width * y] == 1 &&
frameBuffer[x + width * (y + 1)] == 1 &&
frameBuffer[x + 1 + width * (y + 1)] == 1) ||
// All background colour.
(frameBuffer[x + width * y] == 0 &&
frameBuffer[x + 1 + width * y] == 0 &&
frameBuffer[x + width * (y + 1)] == 0 &&
frameBuffer[x + 1 + width * (y + 1)] == 0)
) {
bad += N2;
}
}
}
// X runs
for (y = 0; y < width; y++) {
h = 0;
badBuffer[h] = 0;
b = 0;
for (x = 0; x < width; x++) {
b1 = frameBuffer[x + width * y];
if (b1 == b) {
if (h < badBuffer.length) {
badBuffer[h]++;
}
} else {
h++;
if (h < badBuffer.length) {
badBuffer[h] = 1;
}
}
b = b1;
bw += b > 0 ? 1 : -1;
}
bad += getBadRuns(badBuffer, h);
}
if (bw < 0) bw = -bw;
big = bw;
big += big << 2;
big <<= 1;
while (big > width * width) {
big -= width * width;
count++;
}
bad += count * N4;
// Y runs.
for (x = 0; x < width; x++) {
h = 0;
badBuffer[h] = 0;
b = 0;
for (y = 0; y < width; y++) {
b1 = frameBuffer[x + width * y];
if (b1 == b) {
if (h < badBuffer.length) {
badBuffer[h]++;
}
} else {
h++;
if (h < badBuffer.length) {
badBuffer[h] = 1;
}
}
b = b1;
}
bad += getBadRuns(badBuffer, h);
}
return bad;
}
/**
* 将字符串转为 UTF-8 编码,兼容多平台
* @param str 输入字符串
* @returns {string} UTF-8 编码字符串
*/
function toUtf8(str: string): string {
let out = "";
let i: number;
let len: number;
let c: number;
len = str.length;
for (i = 0; i < len; i++) {
c = str.charCodeAt(i)!;
if (c >= 0x0001 && c <= 0x007f) {
out += str.charAt(i);
} else if (c > 0x07ff) {
out += String.fromCharCode(0xe0 | ((c >> 12) & 0x0f));
out += String.fromCharCode(0x80 | ((c >> 6) & 0x3f));
out += String.fromCharCode(0x80 | ((c >> 0) & 0x3f));
} else {
out += String.fromCharCode(0xc0 | ((c >> 6) & 0x1f));
out += String.fromCharCode(0x80 | ((c >> 0) & 0x3f));
}
}
return out;
}
//end functions
// Find the smallest version that fits the string.
// 1. 字符串转 UTF-8计算长度
str = toUtf8(str);
t = str.length;
// 2. 自动选择最小可用版本
version = 0;
do {
version++;
k = (eccLevel - 1) * 4 + (version - 1) * 16;
neccBlock1 = ECC_BLOCKS[k++];
neccBlock2 = ECC_BLOCKS[k++];
dataBlock = ECC_BLOCKS[k++];
eccBlock = ECC_BLOCKS[k];
k = dataBlock * (neccBlock1 + neccBlock2) + neccBlock2 - 3 + (version <= 9 ? 1 : 0);
if (t <= k) break;
} while (version < 40);
// FIXME: Ensure that it fits insted of being truncated.
// 3. 计算二维码宽度
width = 17 + 4 * version;
// Allocate, clear and setup data structures.
// 4. 分配缓冲区, 使用定长的 Uint8Array 优化内存
v = dataBlock + (dataBlock + eccBlock) * (neccBlock1 + neccBlock2) + neccBlock2;
eccBuffer = new Uint8Array(v);
stringBuffer = new Uint8Array(v);
// 5. 预分配点阵、掩码缓冲区
frameBuffer = new Uint8Array(width * width);
frameMask = new Uint8Array(Math.floor((width * (width + 1) + 1) / 2));
// Insert finders: Foreground colour to frame and background to mask.
// 插入定位点: 前景色为二维码,背景色为掩码
for (t = 0; t < 3; t++) {
k = 0;
y = 0;
if (t == 1) k = width - 7;
if (t == 2) y = width - 7;
frameBuffer[y + 3 + width * (k + 3)] = 1;
for (x = 0; x < 6; x++) {
frameBuffer[y + x + width * k] = 1;
frameBuffer[y + width * (k + x + 1)] = 1;
frameBuffer[y + 6 + width * (k + x)] = 1;
frameBuffer[y + x + 1 + width * (k + 6)] = 1;
}
for (x = 1; x < 5; x++) {
setMask(y + x, k + 1);
setMask(y + 1, k + x + 1);
setMask(y + 5, k + x);
setMask(y + x + 1, k + 5);
}
for (x = 2; x < 4; x++) {
frameBuffer[y + x + width * (k + 2)] = 1;
frameBuffer[y + 2 + width * (k + x + 1)] = 1;
frameBuffer[y + 4 + width * (k + x)] = 1;
frameBuffer[y + x + 1 + width * (k + 4)] = 1;
}
}
// Alignment blocks.
// 插入对齐点: 前景色为二维码,背景色为掩码
if (version > 1) {
t = ALIGNMENT_DELTA[version];
y = width - 7;
for (;;) {
x = width - 7;
while (x > t - 3) {
addAlignment(x, y);
if (x < t) break;
x -= t;
}
if (y <= t + 9) break;
y -= t;
addAlignment(6, y);
addAlignment(y, 6);
}
}
// Single foreground cell.
// 插入单个前景色单元格: 前景色为二维码,背景色为掩码
frameBuffer[8 + width * (width - 8)] = 1;
// Timing gap (mask only).
// 插入时间间隔: 掩码
for (y = 0; y < 7; y++) {
setMask(7, y);
setMask(width - 8, y);
setMask(7, y + width - 7);
}
for (x = 0; x < 8; x++) {
setMask(x, 7);
setMask(x + width - 8, 7);
setMask(x, width - 8);
}
// Reserve mask, format area.
// 保留掩码,格式化区域
for (x = 0; x < 9; x++) {
setMask(x, 8);
}
for (x = 0; x < 8; x++) {
setMask(x + width - 8, 8);
setMask(8, x);
}
for (y = 0; y < 7; y++) {
setMask(8, y + width - 7);
}
// Timing row/column.
// 插入时间间隔行/列: 掩码
for (x = 0; x < width - 14; x++) {
if ((x & 1) > 0) {
setMask(8 + x, 6);
setMask(6, 8 + x);
} else {
frameBuffer[8 + x + width * 6] = 1;
frameBuffer[6 + width * (8 + x)] = 1;
}
}
// Version block.
if (version > 6) {
t = VERSION_BLOCK[version - 7];
k = 17;
for (x = 0; x < 6; x++) {
for (y = 0; y < 3; y++) {
if ((1 & (k > 11 ? version >> (k - 12) : t >> k)) > 0) {
frameBuffer[5 - x + width * (2 - y + width - 11)] = 1;
frameBuffer[2 - y + width - 11 + width * (5 - x)] = 1;
} else {
setMask(5 - x, 2 - y + width - 11);
setMask(2 - y + width - 11, 5 - x);
}
k--;
}
}
}
// Sync mask bits. Only set above for background cells, so now add the foreground.
// 同步掩码位。只有上方的背景单元格需要设置,现在添加前景色。
for (y = 0; y < width; y++) {
for (x = 0; x <= y; x++) {
if (frameBuffer[x + width * y] > 0) {
setMask(x, y);
}
}
}
// Convert string to bit stream. 8-bit data to QR-coded 8-bit data (numeric, alphanum, or kanji
// not supported).
// 将字符串转换为位流。8位数据转换为QR编码的8位数据不支持数字、字母或汉字
v = str.length;
// String to array.
for (i = 0; i < v; i++) {
// #ifdef APP-ANDROID
// @ts-ignore
eccBuffer[i.toInt()] = str.charCodeAt(i)!;
// #endif
// #ifndef APP-ANDROID
eccBuffer[i] = str.charCodeAt(i)!;
// #endif
}
//++++++++++++++++++++==============
stringBuffer.set(eccBuffer.subarray(0, v));
// Calculate max string length.
x = dataBlock * (neccBlock1 + neccBlock2) + neccBlock2;
if (v >= x - 2) {
v = x - 2;
if (version > 9) v--;
}
// Shift and re-pack to insert length prefix.
// 移位并重新打包以插入长度前缀。
i = v;
if (version > 9) {
stringBuffer[i + 2] = 0;
stringBuffer[i + 3] = 0;
while (i-- > 0) {
t = stringBuffer[i];
stringBuffer[i + 3] |= 255 & (t << 4);
stringBuffer[i + 2] = t >> 4;
}
stringBuffer[2] |= 255 & (v << 4);
stringBuffer[1] = v >> 4;
stringBuffer[0] = 0x40 | (v >> 12);
} else {
stringBuffer[i + 1] = 0;
stringBuffer[i + 2] = 0;
while (i-- > 0) {
t = stringBuffer[i];
stringBuffer[i + 2] |= 255 & (t << 4);
stringBuffer[i + 1] = t >> 4;
}
stringBuffer[1] |= 255 & (v << 4);
stringBuffer[0] = 0x40 | (v >> 4);
}
// Fill to end with pad pattern.
// 用填充模式填充到结束。
i = v + 3 - (version < 10 ? 1 : 0);
while (i < x) {
stringBuffer[i++] = 0xec;
stringBuffer[i++] = 0x11;
}
// Calculate generator polynomial.
// 计算生成多项式。
polynomial = new Uint8Array(eccBlock + 1);
polynomial[0] = 1;
for (i = 0; i < eccBlock; i++) {
polynomial[i + 1] = 1;
for (j = i; j > 0; j--) {
polynomial[j] =
polynomial[j] > 0
? polynomial[j - 1] ^ GALOIS_EXPONENT[modN(GALOIS_LOG[polynomial[j]] + i)]
: polynomial[j - 1];
}
polynomial[0] = GALOIS_EXPONENT[modN(GALOIS_LOG[polynomial[0]] + i)];
}
// Use logs for generator polynomial to save calculation step.
// 使用对数计算生成多项式以节省计算步骤。
for (i = 0; i < eccBlock; i++) {
polynomial[i] = GALOIS_LOG[polynomial[i]];
}
// Append ECC to data buffer.
// 将ECC附加到数据缓冲区。
k = x;
y = 0;
for (i = 0; i < neccBlock1; i++) {
appendData(y, dataBlock, k, eccBlock);
y += dataBlock;
k += eccBlock;
}
for (i = 0; i < neccBlock2; i++) {
appendData(y, dataBlock + 1, k, eccBlock);
y += dataBlock + 1;
k += eccBlock;
}
// Interleave blocks.
y = 0;
for (i = 0; i < dataBlock; i++) {
for (j = 0; j < neccBlock1; j++) {
eccBuffer[y++] = stringBuffer[i + j * dataBlock];
}
for (j = 0; j < neccBlock2; j++) {
eccBuffer[y++] = stringBuffer[neccBlock1 * dataBlock + i + j * (dataBlock + 1)];
}
}
for (j = 0; j < neccBlock2; j++) {
eccBuffer[y++] = stringBuffer[neccBlock1 * dataBlock + i + j * (dataBlock + 1)];
}
for (i = 0; i < eccBlock; i++) {
for (j = 0; j < neccBlock1 + neccBlock2; j++) {
eccBuffer[y++] = stringBuffer[x + i + j * eccBlock];
}
}
stringBuffer.set(eccBuffer);
// Pack bits into frame avoiding masked area.
// 将位流打包到帧中,避免掩码区域。
x = width - 1;
y = width - 1;
k = 1;
v = 1;
// inteleaved data and ECC codes.
// 交错数据和ECC代码。
m = (dataBlock + eccBlock) * (neccBlock1 + neccBlock2) + neccBlock2;
for (i = 0; i < m; i++) {
t = stringBuffer[i];
for (j = 0; j < 8; j++) {
if ((0x80 & t) > 0) {
frameBuffer[x + width * y] = 1;
}
// Find next fill position.
// 找到下一个填充位置。
do {
if (v > 0) {
x--;
} else {
x++;
if (k > 0) {
if (y != 0) {
y--;
} else {
x -= 2;
k = k == 0 ? 1 : 0;
if (x == 6) {
x--;
y = 9;
}
}
} else {
if (y != width - 1) {
y++;
} else {
x -= 2;
k = k == 0 ? 1 : 0;
if (x == 6) {
x--;
y -= 8;
}
}
}
}
v = v == 0 ? 1 : 0;
} while (isMasked(x, y));
t <<= 1;
}
}
// Save pre-mask copy of frame.
const frameBufferCopy = frameBuffer.slice(0);
t = 0;
y = 30000;
// Using `for` instead of `while` since in original Arduino code if an early mask was *good
// enough* it wouldn't try for a better one since they get more complex and take longer.
// 使用`for`而不是`while`因为在原始Arduino代码中如果早期掩码足够好它不会尝试更好的掩码因为它们变得更复杂并需要更长的时间。
for (k = 0; k < 8; k++) {
// Returns foreground-background imbalance.
// 返回前景色和背景色的不平衡。
applyMask(k);
x = checkBadness();
// Is current mask better than previous best?
// 当前掩码是否比之前的最佳掩码更好?
if (x < y) {
y = x;
t = k;
}
// Don't increment `i` to a void redoing mask.
// 不要增加`i`以避免重新做掩码。
if (t == 7) break;
// Reset for next pass.
// 重置下一个循环。
frameBuffer.set(frameBufferCopy);
}
// Redo best mask as none were *good enough* (i.e. last wasn't `t`).
// 重做最佳掩码,因为没有一个掩码足够好(即最后一个不是`t`)。
if (t != k) {
// Reset buffer to pre-mask state before applying the best one
frameBuffer.set(frameBufferCopy);
applyMask(t);
}
// Add in final mask/ECC level bytes.
// 添加最终的掩码/ECC级别字节。
y = FINAL_FORMAT[t + ((eccLevel - 1) << 3)];
// Low byte.
for (k = 0; k < 8; k++) {
if ((y & 1) > 0) {
frameBuffer[width - 1 - k + width * 8] = 1;
if (k < 6) {
frameBuffer[8 + width * k] = 1;
} else {
frameBuffer[8 + width * (k + 1)] = 1;
}
}
y >>= 1;
}
// High byte.
for (k = 0; k < 7; k++) {
if ((y & 1) > 0) {
frameBuffer[8 + width * (width - 7 + k)] = 1;
if (k > 0) {
frameBuffer[6 - k + width * 8] = 1;
} else {
frameBuffer[7 + width * 8] = 1;
}
}
y >>= 1;
}
// Finally, return the image data.
return {
frameBuffer: frameBuffer,
width: width
} as GenerateFrameResult;
}

View File

@@ -0,0 +1,155 @@
<template>
<view
class="cl-radio"
:class="[
{
'cl-radio--disabled': isDisabled,
'cl-radio--checked': isChecked
},
pt.className
]"
@tap="onTap"
>
<cl-icon
v-if="showIcon"
:name="iconName"
:size="pt.icon?.size ?? 40"
:pt="{
className: parseClass([
'cl-radio__icon mr-1',
{
'text-primary-500': isChecked
},
pt.icon?.className
])
}"
></cl-icon>
<cl-text
:pt="{
className: parseClass([
'cl-radio__label',
{
'text-primary-500': isChecked
},
pt.label?.className
])
}"
v-if="showLabel"
>
<slot>{{ label }}</slot>
</cl-text>
</view>
</template>
<script lang="ts" setup>
import { computed, useSlots } from "vue";
import type { PassThroughProps } from "../../types";
import { get, parseClass, parsePt } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-radio"
});
// 定义组件属性
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
modelValue: {
type: null
},
// 选中时的图标
activeIcon: {
type: String,
default: "checkbox-circle-line"
},
// 未选中时的图标
inactiveIcon: {
type: String,
default: "checkbox-blank-circle-line"
},
// 是否显示图标
showIcon: {
type: Boolean,
default: true
},
// 标签文本
label: {
type: String,
default: ""
},
// 选项值 - 该单选框对应的值
value: {
type: null
},
// 是否禁用
disabled: {
type: Boolean,
default: false
}
});
// 定义组件事件
const emit = defineEmits(["update:modelValue", "change"]);
const slots = useSlots();
// 透传样式类型定义
type PassThrough = {
className?: string;
icon?: ClIconProps;
label?: PassThroughProps;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// cl-form 上下文
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => props.disabled || disabled.value);
// 是否为选中状态
const isChecked = computed(() => props.modelValue == props.value);
// 是否显示标签
const showLabel = computed(() => props.label != "" || get(slots, "default") != null);
// 图标名称
const iconName = computed(() => {
// 选中状态
if (isChecked.value) {
return props.activeIcon;
}
// 默认状态
return props.inactiveIcon;
});
/**
* 点击事件处理函数
* 在非禁用状态下切换选中状态
*/
function onTap() {
if (!isDisabled.value) {
emit("update:modelValue", props.value);
emit("change", props.value);
}
}
</script>
<style lang="scss" scoped>
.cl-radio {
@apply flex flex-row items-center;
&--disabled {
@apply opacity-50;
}
}
</style>

View File

@@ -0,0 +1,20 @@
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClRadioPassThrough = {
className?: string;
icon?: ClIconProps;
label?: PassThroughProps;
};
export type ClRadioProps = {
className?: string;
pt?: ClRadioPassThrough;
modelValue?: any;
activeIcon?: string;
inactiveIcon?: string;
showIcon?: boolean;
label?: string;
value?: any;
disabled?: boolean;
};

View File

@@ -0,0 +1,196 @@
<template>
<view class="cl-rate" :class="[{ 'cl-rate--disabled': isDisabled }, pt.className]">
<view
v-for="(item, index) in max"
:key="index"
class="cl-rate__item"
:class="[
{
'cl-rate__item--active': item <= modelValue
},
pt.item?.className
]"
@touchstart="onTap(index)"
>
<cl-icon
:name="voidIcon"
:color="voidColor"
:size="size"
:pt="{
className: `${pt.icon?.className}`
}"
></cl-icon>
<cl-icon
v-if="getIconActiveWidth(item) > 0"
:name="icon"
:color="color"
:size="size"
:width="getIconActiveWidth(item)"
:pt="{
className: `absolute left-0 ${pt.icon?.className}`
}"
></cl-icon>
</view>
<cl-text
v-if="showScore"
:pt="{
className: parseClass(['cl-rate__score ml-2 font-bold', pt.score?.className])
}"
>{{ modelValue }}</cl-text
>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { parseClass, parsePt } from "@/cool";
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-rate"
});
// 组件属性定义
const props = defineProps({
// 样式穿透
pt: {
type: Object,
default: () => ({})
},
// 评分值
modelValue: {
type: Number,
default: 0
},
// 最大评分
max: {
type: Number,
default: 5
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否允许半星
allowHalf: {
type: Boolean,
default: false
},
// 是否显示分数
showScore: {
type: Boolean,
default: false
},
// 组件尺寸
size: {
type: Number,
default: 40
},
// 图标名称
icon: {
type: String,
default: "star-fill"
},
// 未激活图标
voidIcon: {
type: String,
default: "star-fill"
},
// 激活颜色
color: {
type: String,
default: "primary"
},
// 默认颜色
voidColor: {
type: String,
default: "#dddddd"
}
});
// 定义事件
const emit = defineEmits(["update:modelValue", "change"]);
// 透传样式类型定义
type PassThrough = {
className?: string;
item?: PassThroughProps;
icon?: ClIconProps;
score?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// cl-form 上下文
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => props.disabled || disabled.value);
// 获取图标激活宽度
function getIconActiveWidth(item: number) {
// 如果评分值大于等于当前项,返回null表示完全填充
if (props.modelValue >= item) {
return props.size;
}
// 如果评分值的整数部分小于当前项,返回0表示不填充
if (Math.floor(props.modelValue) < item - 1) {
return 0;
}
// 处理小数部分填充
return Math.floor((props.modelValue % 1) * props.size);
}
// 点击事件处理
function onTap(index: number) {
if (isDisabled.value) {
return;
}
let value: number;
if (props.allowHalf) {
// 半星逻辑:点击同一位置切换半星和整星
const currentValue = index + 1;
if (props.modelValue == currentValue) {
value = index + 0.5;
} else if (props.modelValue == index + 0.5) {
value = index;
} else {
value = currentValue;
}
} else {
value = index + 1;
}
// 确保值在有效范围内
value = Math.max(0, Math.min(value, props.max));
emit("update:modelValue", value);
emit("change", value);
}
</script>
<style lang="scss" scoped>
.cl-rate {
@apply flex flex-row items-center;
&--disabled {
@apply opacity-50;
}
&__item {
@apply flex items-center justify-center relative duration-200 overflow-hidden;
transition-property: color;
margin-right: 6rpx;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More