小程序初始提交
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
14
cool-unix/uni_modules/cool-ui/components/cl-avatar/props.ts
Normal file
14
cool-unix/uni_modules/cool-ui/components/cl-avatar/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
export type ClBackTopProps = {
|
||||
className?: string;
|
||||
top?: number | any;
|
||||
};
|
||||
109
cool-unix/uni_modules/cool-ui/components/cl-badge/cl-badge.uvue
Normal file
109
cool-unix/uni_modules/cool-ui/components/cl-badge/cl-badge.uvue
Normal 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>
|
||||
15
cool-unix/uni_modules/cool-ui/components/cl-badge/props.ts
Normal file
15
cool-unix/uni_modules/cool-ui/components/cl-badge/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
25
cool-unix/uni_modules/cool-ui/components/cl-banner/props.ts
Normal file
25
cool-unix/uni_modules/cool-ui/components/cl-banner/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
42
cool-unix/uni_modules/cool-ui/components/cl-button/props.ts
Normal file
42
cool-unix/uni_modules/cool-ui/components/cl-button/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
273
cool-unix/uni_modules/cool-ui/components/cl-calendar/picker.uvue
Normal file
273
cool-unix/uni_modules/cool-ui/components/cl-calendar/picker.uvue
Normal 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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
106
cool-unix/uni_modules/cool-ui/components/cl-col/cl-col.uvue
Normal file
106
cool-unix/uni_modules/cool-ui/components/cl-col/cl-col.uvue
Normal 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>
|
||||
12
cool-unix/uni_modules/cool-ui/components/cl-col/props.ts
Normal file
12
cool-unix/uni_modules/cool-ui/components/cl-col/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
export type ClCollapsePassThrough = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type ClCollapseProps = {
|
||||
className?: string;
|
||||
pt?: ClCollapsePassThrough;
|
||||
modelValue?: boolean;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
1352
cool-unix/uni_modules/cool-ui/components/cl-cropper/cl-cropper.uvue
Normal file
1352
cool-unix/uni_modules/cool-ui/components/cl-cropper/cl-cropper.uvue
Normal file
File diff suppressed because it is too large
Load Diff
19
cool-unix/uni_modules/cool-ui/components/cl-cropper/props.ts
Normal file
19
cool-unix/uni_modules/cool-ui/components/cl-cropper/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
102
cool-unix/uni_modules/cool-ui/components/cl-empty/cl-empty.uvue
Normal file
102
cool-unix/uni_modules/cool-ui/components/cl-empty/cl-empty.uvue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export type ClFilterBarProps = {
|
||||
className?: string;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
export type ClFloatViewProps = {
|
||||
className?: string;
|
||||
zIndex?: number;
|
||||
size?: number;
|
||||
left?: number;
|
||||
bottom?: number;
|
||||
gap?: number;
|
||||
disabled?: boolean;
|
||||
noSnapping?: boolean;
|
||||
};
|
||||
@@ -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>
|
||||
16
cool-unix/uni_modules/cool-ui/components/cl-footer/offset.ts
Normal file
16
cool-unix/uni_modules/cool-ui/components/cl-footer/offset.ts
Normal 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();
|
||||
14
cool-unix/uni_modules/cool-ui/components/cl-footer/props.ts
Normal file
14
cool-unix/uni_modules/cool-ui/components/cl-footer/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
455
cool-unix/uni_modules/cool-ui/components/cl-form/cl-form.uvue
Normal file
455
cool-unix/uni_modules/cool-ui/components/cl-form/cl-form.uvue
Normal 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>
|
||||
18
cool-unix/uni_modules/cool-ui/components/cl-form/props.ts
Normal file
18
cool-unix/uni_modules/cool-ui/components/cl-form/props.ts
Normal 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;
|
||||
};
|
||||
162
cool-unix/uni_modules/cool-ui/components/cl-icon/cl-icon.uvue
Normal file
162
cool-unix/uni_modules/cool-ui/components/cl-icon/cl-icon.uvue
Normal 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>
|
||||
13
cool-unix/uni_modules/cool-ui/components/cl-icon/props.ts
Normal file
13
cool-unix/uni_modules/cool-ui/components/cl-icon/props.ts
Normal 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;
|
||||
};
|
||||
221
cool-unix/uni_modules/cool-ui/components/cl-image/cl-image.uvue
Normal file
221
cool-unix/uni_modules/cool-ui/components/cl-image/cl-image.uvue
Normal 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>
|
||||
26
cool-unix/uni_modules/cool-ui/components/cl-image/props.ts
Normal file
26
cool-unix/uni_modules/cool-ui/components/cl-image/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
export type ClIndexBarPassThrough = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type ClIndexBarProps = {
|
||||
className?: string;
|
||||
pt?: ClIndexBarPassThrough;
|
||||
modelValue?: number;
|
||||
list?: string[];
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
411
cool-unix/uni_modules/cool-ui/components/cl-input/cl-input.uvue
Normal file
411
cool-unix/uni_modules/cool-ui/components/cl-input/cl-input.uvue
Normal 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>
|
||||
32
cool-unix/uni_modules/cool-ui/components/cl-input/props.ts
Normal file
32
cool-unix/uni_modules/cool-ui/components/cl-input/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
115
cool-unix/uni_modules/cool-ui/components/cl-list/cl-list.uvue
Normal file
115
cool-unix/uni_modules/cool-ui/components/cl-list/cl-list.uvue
Normal 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>
|
||||
16
cool-unix/uni_modules/cool-ui/components/cl-list/props.ts
Normal file
16
cool-unix/uni_modules/cool-ui/components/cl-list/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
14
cool-unix/uni_modules/cool-ui/components/cl-loading/props.ts
Normal file
14
cool-unix/uni_modules/cool-ui/components/cl-loading/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
19
cool-unix/uni_modules/cool-ui/components/cl-marquee/props.ts
Normal file
19
cool-unix/uni_modules/cool-ui/components/cl-marquee/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
export type ClPageProps = {
|
||||
className?: string;
|
||||
backTop?: boolean;
|
||||
};
|
||||
45
cool-unix/uni_modules/cool-ui/components/cl-page/theme.uvue
Normal file
45
cool-unix/uni_modules/cool-ui/components/cl-page/theme.uvue
Normal 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>
|
||||
68
cool-unix/uni_modules/cool-ui/components/cl-page/ui.uvue
Normal file
68
cool-unix/uni_modules/cool-ui/components/cl-page/ui.uvue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ClSelectOption } from "../../types";
|
||||
|
||||
export type ClSelectPickerViewProps = {
|
||||
className?: string;
|
||||
headers?: string[];
|
||||
value?: number[];
|
||||
columns?: ClSelectOption[][];
|
||||
itemHeight?: number;
|
||||
height?: number;
|
||||
};
|
||||
647
cool-unix/uni_modules/cool-ui/components/cl-popup/cl-popup.uvue
Normal file
647
cool-unix/uni_modules/cool-ui/components/cl-popup/cl-popup.uvue
Normal 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>
|
||||
33
cool-unix/uni_modules/cool-ui/components/cl-popup/props.ts
Normal file
33
cool-unix/uni_modules/cool-ui/components/cl-popup/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
403
cool-unix/uni_modules/cool-ui/components/cl-qrcode/draw.ts
Normal file
403
cool-unix/uni_modules/cool-ui/components/cl-qrcode/draw.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
17
cool-unix/uni_modules/cool-ui/components/cl-qrcode/props.ts
Normal file
17
cool-unix/uni_modules/cool-ui/components/cl-qrcode/props.ts
Normal 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;
|
||||
};
|
||||
972
cool-unix/uni_modules/cool-ui/components/cl-qrcode/qrcode.ts
Normal file
972
cool-unix/uni_modules/cool-ui/components/cl-qrcode/qrcode.ts
Normal 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;
|
||||
}
|
||||
155
cool-unix/uni_modules/cool-ui/components/cl-radio/cl-radio.uvue
Normal file
155
cool-unix/uni_modules/cool-ui/components/cl-radio/cl-radio.uvue
Normal 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>
|
||||
20
cool-unix/uni_modules/cool-ui/components/cl-radio/props.ts
Normal file
20
cool-unix/uni_modules/cool-ui/components/cl-radio/props.ts
Normal 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;
|
||||
};
|
||||
196
cool-unix/uni_modules/cool-ui/components/cl-rate/cl-rate.uvue
Normal file
196
cool-unix/uni_modules/cool-ui/components/cl-rate/cl-rate.uvue
Normal 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
Reference in New Issue
Block a user