Przeglądaj źródła

添加cl-draggable组件

icssoa 8 miesięcy temu
rodzic
commit
9a49963a91

+ 1 - 0
App.uvue

@@ -3,6 +3,7 @@ import { useStore } from "@/cool";
 
 // #ifdef H5
 import TouchEmulator from "hammer-touchemulator";
+// 模拟移动端调试的触摸事件
 TouchEmulator();
 // #endif
 

+ 2 - 0
components/tabbar.uvue

@@ -61,7 +61,9 @@ const list = computed<Item[]>(() => {
 });
 
 // 隐藏原生 tabBar
+// #ifndef MP
 uni.hideTabBar();
+// #endif
 </script>
 
 <style lang="scss" scoped>

+ 2 - 1
cool/theme/index.ts

@@ -2,6 +2,7 @@ import { computed, ref } from "vue";
 import uniTheme from "@/theme.json";
 import { router } from "../router";
 import { ctx } from "../ctx";
+import { isNull } from "../utils";
 
 // 主题类型定义,仅支持 light 和 dark
 type Theme = "light" | "dark";
@@ -46,7 +47,7 @@ export function getStyle(key: string): string | null {
  * @returns 颜色值
  */
 export const getColor = (name: string) => {
-	if (ctx.color == null) {
+	if (isNull(ctx.color)) {
 		return "";
 	}
 

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "cool-unix",
-	"version": "8.0.2",
+	"version": "8.0.3",
 	"license": "MIT",
 	"scripts": {
 		"build-ui": "node ./uni_modules/cool-ui/scripts/generate-types.js",

+ 6 - 0
pages.json

@@ -286,6 +286,12 @@
 					}
 				},
 				{
+					"path": "data/draggable",
+					"style": {
+						"navigationBarTitleText": "Draggable 拖拽"
+					}
+				},
+				{
 					"path": "status/badge",
 					"style": {
 						"navigationBarTitleText": "Badge 角标"

+ 178 - 0
pages/demo/data/draggable.uvue

@@ -0,0 +1,178 @@
+<template>
+	<cl-page>
+		<view class="p-3 overflow-visible">
+			<demo-item label="纵向排列">
+				<cl-draggable v-model="list">
+					<template #item="{ item, index }">
+						<view
+							class="flex flex-row items-center p-3 bg-surface-100 rounded-lg mb-2"
+							:class="{
+								'!bg-surface-300': (item as UTSJSONObject).disabled
+							}"
+						>
+							<cl-text>{{ (item as UTSJSONObject).label }}</cl-text>
+						</view>
+					</template>
+				</cl-draggable>
+			</demo-item>
+
+			<demo-item label="结合列表使用">
+				<cl-list border>
+					<cl-draggable v-model="list2">
+						<template #item="{ item, index, dragging, dragIndex }">
+							<cl-list-item
+								icon="chat-thread-line"
+								:label="(item as UTSJSONObject).label"
+								arrow
+								:pt="{
+									inner: {
+										className:
+											dragging && dragIndex == index ? '!bg-surface-100' : ''
+									}
+								}"
+							></cl-list-item>
+						</template>
+					</cl-draggable>
+				</cl-list>
+			</demo-item>
+
+			<demo-item label="横向排列">
+				<cl-draggable v-model="list3" :columns="4">
+					<template #item="{ item, index }">
+						<view
+							class="flex flex-row items-center justify-center p-3 bg-surface-100 rounded-lg m-1"
+							:class="{
+								'!bg-surface-300': (item as UTSJSONObject).disabled
+							}"
+						>
+							<cl-text>{{ (item as UTSJSONObject).label }}</cl-text>
+						</view>
+					</template>
+				</cl-draggable>
+			</demo-item>
+
+			<demo-item label="结合图片使用">
+				<cl-draggable v-model="list4" :columns="4">
+					<template #item="{ item, index }">
+						<view class="p-[2px]">
+							<cl-image
+								:src="(item as UTSJSONObject).url"
+								mode="widthFix"
+								:pt="{
+									className: '!w-full'
+								}"
+								preview
+							></cl-image>
+						</view>
+					</template>
+				</cl-draggable>
+			</demo-item>
+		</view>
+	</cl-page>
+</template>
+
+<script lang="ts" setup>
+import DemoItem from "../components/item.uvue";
+import { ref } from "vue";
+
+const list = ref<UTSJSONObject[]>([
+	{
+		label: "明月几时有,把酒问青天"
+	},
+	{
+		label: "不知天上宫阙,今夕是何年",
+		disabled: true
+	},
+	{
+		label: "我欲乘风归去,又恐琼楼玉宇"
+	},
+	{
+		label: "高处不胜寒,起舞弄清影"
+	},
+	{
+		label: "何似在人间"
+	}
+]);
+
+const list2 = ref<UTSJSONObject[]>([
+	{
+		label: "明月几时有,把酒问青天"
+	},
+	{
+		label: "不知天上宫阙,今夕是何年"
+	},
+	{
+		label: "我欲乘风归去,又恐琼楼玉宇"
+	},
+	{
+		label: "高处不胜寒,起舞弄清影"
+	},
+	{
+		label: "何似在人间"
+	}
+]);
+
+const list3 = ref<UTSJSONObject[]>([
+	{
+		label: "项目1"
+	},
+	{
+		label: "项目2"
+	},
+	{
+		label: "项目3"
+	},
+	{
+		label: "项目4"
+	},
+	{
+		label: "项目5"
+	},
+	{
+		label: "项目6"
+	},
+	{
+		label: "项目7"
+	},
+	{
+		label: "项目8",
+		disabled: true
+	},
+	{
+		label: "项目9"
+	},
+	{
+		label: "项目10"
+	},
+	{
+		label: "项目11"
+	},
+	{
+		label: "项目12"
+	}
+]);
+
+const list4 = ref<UTSJSONObject[]>([
+	{
+		url: "https://unix.cool-js.com/images/demo/1.jpg"
+	},
+	{
+		url: "https://unix.cool-js.com/images/demo/2.jpg"
+	},
+	{
+		url: "https://unix.cool-js.com/images/demo/3.jpg"
+	},
+	{
+		url: "https://unix.cool-js.com/images/demo/4.jpg"
+	},
+	{
+		url: "https://unix.cool-js.com/images/demo/5.jpg"
+	},
+	{
+		url: "https://unix.cool-js.com/images/demo/6.jpg"
+	},
+	{
+		url: "https://unix.cool-js.com/images/demo/7.jpg"
+	}
+]);
+</script>

+ 5 - 0
pages/index/home.uvue

@@ -271,6 +271,11 @@ const data = computed<Item[]>(() => {
 					label: t("时间轴"),
 					icon: "timeline-view",
 					path: "/pages/demo/data/timeline"
+				},
+				{
+					label: t("拖拽"),
+					icon: "drag-move-line",
+					path: "/pages/demo/data/draggable"
 				}
 			]
 		},

+ 673 - 0
uni_modules/cool-ui/components/cl-draggable/cl-draggable.uvue

@@ -0,0 +1,673 @@
+<template>
+	<view
+		class="cl-draggable"
+		:class="[
+			{
+				'cl-draggable--grid': props.columns > 1
+			},
+			pt.className
+		]"
+	>
+		<view
+			v-for="(item, index) in list"
+			:key="getItemKey(item, index)"
+			class="cl-draggable__item"
+			:class="[
+				{
+					'cl-draggable__item--disabled': disabled
+				},
+				dragging && dragIndex == index ? `opacity-80 ${pt.ghost?.className}` : ''
+			]"
+			:style="getItemStyle(index)"
+			@touchstart="
+				(event: UniTouchEvent) => {
+					onTouchStart(event, index);
+				}
+			"
+			@touchmove.stop.prevent="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";
+
+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
+	},
+	/** 动画持续时间(毫秒) */
+	animation: {
+		type: Number,
+		default: 150
+	},
+	/** 列数:1为单列纵向布局,>1为多列网格布局 */
+	columns: {
+		type: Number,
+		default: 1
+	}
+});
+
+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 (props.animation > 0 && !isCurrent) {
+		style["transition-property"] = "transform";
+		style["transition-duration"] = `${props.animation}ms`;
+		style["transition-timing-function"] = "ease";
+	}
+
+	// 拖拽状态下的样式处理
+	if (dragging.value) {
+		if (isCurrent) {
+			// 拖拽元素:跟随移动
+			style["transform"] = `translate(${offsetX.value}px, ${offsetY.value}px)`;
+			style["z-index"] = "100";
+		} else {
+			// 其他元素:显示排序预览位移
+			const translateOffset = getItemTranslateOffset(index);
+			if (translateOffset.x != 0 || translateOffset.y != 0) {
+				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): Promise<void> {
+	// 检查是否禁用或索引无效
+	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);
+}
+
+/**
+ * 触摸移动事件处理
+ * @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;
+
+	// 让拖拽元素回到自然位置(偏移归零)
+	offsetX.value = 0;
+	offsetY.value = 0;
+
+	// 等待放下动画完成后重置所有状态
+	setTimeout(() => {
+		emit("end", newIndex >= 0 ? newIndex : oldIndex);
+		reset();
+	}, 10);
+}
+
+/**
+ * 根据平台选择合适的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;
+}
+
+.cl-draggable--grid {
+	@apply flex-row flex-wrap;
+}
+
+.cl-draggable__item {
+	@apply relative;
+}
+
+.cl-draggable__item--dragging {
+	@apply opacity-80;
+}
+
+.cl-draggable__item--disabled {
+	@apply opacity-60;
+}
+</style>

+ 15 - 0
uni_modules/cool-ui/components/cl-draggable/props.ts

@@ -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;
+	animation?: number;
+	columns?: number;
+};

+ 2 - 0
uni_modules/cool-ui/index.d.ts

@@ -12,6 +12,7 @@ import type { ClCheckboxProps, ClCheckboxPassThrough } from "./components/cl-che
 import type { ClColProps, ClColPassThrough } from "./components/cl-col/props";
 import type { ClCollapseProps, ClCollapsePassThrough } from "./components/cl-collapse/props";
 import type { ClCountdownProps, ClCountdownPassThrough } from "./components/cl-countdown/props";
+import type { ClDraggableProps, ClDraggablePassThrough } from "./components/cl-draggable/props";
 import type { ClFloatViewProps } from "./components/cl-float-view/props";
 import type { ClFooterProps, ClFooterPassThrough } from "./components/cl-footer/props";
 import type { ClIconProps, ClIconPassThrough } from "./components/cl-icon/props";
@@ -77,6 +78,7 @@ declare module "vue" {
 		"cl-col": (typeof import('./components/cl-col/cl-col.uvue')['default']) & import('vue').DefineComponent<ClColProps>;
 		"cl-collapse": (typeof import('./components/cl-collapse/cl-collapse.uvue')['default']) & import('vue').DefineComponent<ClCollapseProps>;
 		"cl-countdown": (typeof import('./components/cl-countdown/cl-countdown.uvue')['default']) & import('vue').DefineComponent<ClCountdownProps>;
+		"cl-draggable": (typeof import('./components/cl-draggable/cl-draggable.uvue')['default']) & import('vue').DefineComponent<ClDraggableProps>;
 		"cl-float-view": (typeof import('./components/cl-float-view/cl-float-view.uvue')['default']) & import('vue').DefineComponent<ClFloatViewProps>;
 		"cl-footer": (typeof import('./components/cl-footer/cl-footer.uvue')['default']) & import('vue').DefineComponent<ClFooterProps>;
 		"cl-icon": (typeof import('./components/cl-icon/cl-icon.uvue')['default']) & import('vue').DefineComponent<ClIconProps>;