Переглянути джерело

添加 cl-select-seat 选座组件

icssoa 2 місяців тому
батько
коміт
7d64b060ec

+ 198 - 0
pages/demo/other/select-seat.uvue

@@ -0,0 +1,198 @@
+<template>
+	<cl-page>
+		<view class="p-3 pb-0 w-full">
+			<view
+				class="flex flex-row items-center p-2 rounded-lg bg-white w-full dark:!bg-surface-800 mb-3"
+			>
+				<cl-icon name="information-2-line" color="warn"></cl-icon>
+
+				<cl-text
+					:pt="{
+						className: 'text-sm ml-1'
+					}"
+					color="warn"
+					>双指缩放:放大/缩小、单指拖动:移动视图</cl-text
+				>
+			</view>
+		</view>
+
+		<view v-if="width > 0">
+			<view
+				class="w-full flex flex-col items-center justify-center pt-3"
+				:style="{
+					transform: `translateX(${translateX}px)`
+				}"
+			>
+				<image
+					src="/static/demo/select-seat/screen.png"
+					mode="widthFix"
+					class="w-[400rpx]"
+				/>
+				<cl-text
+					:pt="{
+						className: 'text-center w-full text-sm'
+					}"
+					>2号激光厅 银幕</cl-text
+				>
+			</view>
+
+			<cl-select-seat
+				ref="selectSeatRef"
+				v-model="selected as ClSelectSeatValue[]"
+				:rows="rows"
+				:cols="cols"
+				:width="width"
+				:height="300"
+				:seats="seats"
+				:seatGap="8"
+				:borderRadius="6"
+				:minScale="1"
+				:maxScale="1.5"
+				selectedIcon="check-line"
+				@seat-click="onSeatClick"
+				@move="onMove"
+			></cl-select-seat>
+		</view>
+
+		<cl-footer>
+			<cl-text>寻秦记</cl-text>
+			<cl-text
+				color="info"
+				:pt="{
+					className: 'text-sm mb-3 mt-1'
+				}"
+				>今日 01月10日 14:20 - 16:16 国语 2D</cl-text
+			>
+
+			<scroll-view direction="horizontal" class="flex flex-row" :show-scrollbar="false">
+				<view
+					class="flex p-3 bg-surface-100 dark:!bg-surface-800 rounded-lg mr-2 mb-2"
+					v-for="(item, index) in selectedSeats"
+					:key="index"
+				>
+					<cl-text
+						:pt="{
+							className: 'text-sm'
+						}"
+						>{{ item }}</cl-text
+					>
+				</view>
+			</scroll-view>
+
+			<view class="mt-2">
+				<cl-button size="large" :disabled="selectedSeats.length == 0" @tap="confirm"
+					>确认选座</cl-button
+				>
+			</view>
+		</cl-footer>
+	</cl-page>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from "vue";
+import type { ClSelectSeatItem, ClSelectSeatValue } from "@/uni_modules/cool-ui/types";
+import { getColor, isDark } from "@/cool";
+import { useUi } from "@/uni_modules/cool-ui";
+import { t } from "@/locale";
+
+const selectSeatRef = ref<ClSelectSeatComponentPublicInstance | null>(null);
+
+const ui = useUi();
+
+// 已选中的座位
+const selected = ref<ClSelectSeatValue[]>([]);
+
+// 画布偏移横向值
+const translateX = ref(0);
+
+// 座位行数
+const rows = ref(9);
+// 座位列数
+const cols = ref(16);
+
+// 生成自定义座位数据
+const seats = computed<ClSelectSeatItem[]>(() => {
+	const seats: ClSelectSeatItem[] = [];
+
+	for (let row = 0; row < rows.value; row++) {
+		for (let col = 0; col < cols.value; col++) {
+			const seat: ClSelectSeatItem = {
+				row,
+				col
+			};
+
+			// 示例:设置一些座位为禁用(不可点击)
+			if (col < 4 && row > 1 && row < 6) {
+				seat.disabled = true;
+				seat.bgColor = isDark.value ? getColor("surface-600") : getColor("surface-200");
+				seat.color = isDark.value ? getColor("surface-300") : getColor("surface-400");
+			}
+
+			// 示例:VIP座位使用金色边框,选中时金色背景
+			if (row < 2) {
+				seat.borderColor = "#ffd700";
+			}
+
+			// 示例:情侣座使用粉色边框,选中时粉色背景
+			if (row > 7) {
+				seat.borderColor = "#ff69b4";
+				seat.icon = "heart-fill";
+				seat.color = "#ff69b4";
+			}
+
+			// 示例:空座位
+			if ((col < 2 && row < 8) || (col > 13 && row < 8)) {
+				seat.empty = true;
+			}
+
+			// 最佳座位
+			if (col > 4 && col < 11 && row > 1 && row < 6) {
+				seat.borderColor = "#22c55e";
+			}
+
+			seats.push(seat);
+		}
+	}
+
+	return seats;
+});
+
+// 计算已选座位显示
+const selectedSeats = computed<string[]>(() => {
+	if (selected.value.length == 0) {
+		return [];
+	}
+
+	return selected.value.map((s) => `${s.row + 1}排${s.col + 1}座`);
+});
+
+// 座位点击事件
+function onSeatClick(seat: ClSelectSeatItem) {
+	console.log("点击座位:", seat);
+}
+
+// 移动事件
+function onMove(data: UTSJSONObject) {
+	translateX.value = data["screenTranslateX"] as number;
+}
+
+function confirm() {
+	ui.showConfirm({
+		title: t("提示"),
+		message: t("确认选座后将无法修改,是否确认?"),
+		callback(action) {
+			if (action == "confirm") {
+				ui.showToast({
+					message: t("选座成功")
+				});
+			}
+		}
+	});
+}
+
+const width = ref(0);
+
+onReady(() => {
+	width.value = uni.getWindowInfo().windowWidth;
+});
+</script>

+ 5 - 0
pages/index/home.uvue

@@ -488,6 +488,11 @@ const data = computed<Item[]>(() => {
 					icon: "share-line",
 					path: "/pages/demo/other/share",
 					hidden: !isApp()
+				},
+				{
+					label: "选座",
+					icon: "armchair-line",
+					path: "/pages/demo/other/select-seat"
 				}
 			]
 		}

BIN
static/demo/select-seat/screen.png


+ 665 - 0
uni_modules/cool-ui/components/cl-select-seat/cl-select-seat.uvue

@@ -0,0 +1,665 @@
+<template>
+	<view class="cl-select-seat" :style="{ width: width + 'px', height: height + 'px' }">
+		<view
+			class="cl-select-seat__index"
+			:style="{
+				transform: `translateY(${translateY}px)`
+			}"
+		>
+			<view
+				class="cl-select-seat__index-item"
+				v-for="i in rows"
+				:key="i"
+				:style="{
+					height: seatSize * scale + 'px',
+					marginTop: i == 1 ? 0 : seatGap * scale + 'px'
+				}"
+			>
+				<cl-text
+					color="white"
+					:pt="{
+						className: 'text-xs'
+					}"
+					>{{ i }}</cl-text
+				>
+			</view>
+		</view>
+
+		<canvas
+			id="seatCanvas"
+			class="cl-select-seat__canvas"
+			@touchstart.stop.prevent="onTouchStart"
+			@touchmove.stop.prevent="onTouchMove"
+			@touchend.stop.prevent="onTouchEnd"
+		></canvas>
+	</view>
+</template>
+
+<script setup lang="ts">
+import { assign, getColor, getDevicePixelRatio, isDark, isH5 } from "@/cool";
+import { computed, getCurrentInstance, onMounted, ref, watch } from "vue";
+import { getIcon } from "../cl-icon/utils";
+import type { PropType } from "vue";
+import type { ClSelectSeatItem, ClSelectSeatValue } from "../../types";
+
+defineOptions({
+	name: "cl-select-seat"
+});
+
+type TouchPoint = {
+	x: number;
+	y: number;
+	identifier: number;
+};
+
+const props = defineProps({
+	modelValue: {
+		type: Array as PropType<ClSelectSeatValue[]>,
+		default: () => []
+	},
+	rows: {
+		type: Number,
+		default: 0
+	},
+	cols: {
+		type: Number,
+		default: 0
+	},
+	seatGap: {
+		type: Number,
+		default: 8
+	},
+	borderRadius: {
+		type: Number,
+		default: 8
+	},
+	borderWidth: {
+		type: Number,
+		default: 1
+	},
+	minScale: {
+		type: Number,
+		default: 1
+	},
+	maxScale: {
+		type: Number,
+		default: 3
+	},
+	color: {
+		type: String,
+		default: () => getColor("surface-300")
+	},
+	darkColor: {
+		type: String,
+		default: () => getColor("surface-500")
+	},
+	bgColor: {
+		type: String,
+		default: () => "#ffffff"
+	},
+	darkBgColor: {
+		type: String,
+		default: () => getColor("surface-800")
+	},
+	borderColor: {
+		type: String,
+		default: () => getColor("surface-200")
+	},
+	darkBorderColor: {
+		type: String,
+		default: () => getColor("surface-600")
+	},
+	selectedBgColor: {
+		type: String,
+		default: () => getColor("primary-500")
+	},
+	selectedColor: {
+		type: String,
+		default: () => "#ffffff"
+	},
+	selectedIcon: {
+		type: String,
+		default: "check-line"
+	},
+	width: {
+		type: Number,
+		default: 0
+	},
+	height: {
+		type: Number,
+		default: 0
+	},
+	seats: {
+		type: Array as PropType<ClSelectSeatItem[]>,
+		default: () => []
+	}
+});
+
+const emit = defineEmits(["update:modelValue", "seatClick", "move"]);
+
+const { proxy } = getCurrentInstance()!;
+
+// 画布渲染上下文
+let renderingContext: CanvasRenderingContext2D | null = null;
+// 画布偏移顶部
+let offsetTop = 0;
+// 画布偏移左侧
+let offsetLeft = 0;
+// 左侧边距
+let leftPadding = 0;
+
+// 座位数据
+let seats: ClSelectSeatItem[] = [];
+
+// 画布变换参数
+const scale = ref(1);
+const translateX = ref(0);
+const translateY = ref(0);
+
+// 触摸状态
+let touchPoints: TouchPoint[] = [];
+let lastDistance = 0;
+let lastCenterX = 0;
+let lastCenterY = 0;
+// 是否发生过拖动或缩放,用于防止误触选座
+let hasMoved = false;
+// 起始触摸点,用于判断是否发生拖动
+let startTouchX = 0;
+let startTouchY = 0;
+// 拖动阈值(像素)
+const moveThreshold = 10;
+
+// 根据视图大小计算座位大小
+const seatSize = computed(() => {
+	const availableWidth =
+		props.width - (props.cols - 1) * props.seatGap - props.borderWidth * props.cols;
+	const seatWidth = availableWidth / props.cols;
+
+	const availableHeight =
+		props.height - (props.rows - 1) * props.seatGap - props.borderWidth * props.rows;
+	const seatHeight = availableHeight / props.rows;
+
+	return Math.min(seatWidth, seatHeight);
+});
+
+// 是否选中
+function isSelected(row: number, col: number): boolean {
+	return props.modelValue.some((item) => item.row == row && item.col == col);
+}
+
+// 初始化座位数据
+function initSeats() {
+	seats = [];
+
+	if (props.seats.length > 0) {
+		props.seats.forEach((e) => {
+			seats.push({
+				row: e.row,
+				col: e.col,
+				disabled: e.disabled,
+				empty: e.empty,
+				bgColor: e.bgColor,
+				borderColor: e.borderColor,
+				selectedBgColor: e.selectedBgColor,
+				selectedColor: e.selectedColor,
+				selectedIcon: e.selectedIcon,
+				icon: e.icon,
+				color: e.color
+			} as ClSelectSeatItem);
+		});
+	} else {
+		for (let row = 0; row < props.rows; row++) {
+			for (let col = 0; col < props.cols; col++) {
+				seats.push({
+					row,
+					col
+				} as ClSelectSeatItem);
+			}
+		}
+	}
+}
+
+// 居中显示
+function centerView() {
+	if (renderingContext == null) return;
+
+	const contentWidth = props.cols * (seatSize.value + props.seatGap) - props.seatGap;
+	const contentHeight = props.rows * (seatSize.value + props.seatGap) - props.seatGap;
+
+	translateX.value = (renderingContext!.canvas.offsetWidth - contentWidth) / 2;
+	translateY.value = (renderingContext!.canvas.offsetHeight - contentHeight) / 2;
+
+	leftPadding = translateX.value;
+}
+
+// 限制平移范围
+function constrainTranslate() {
+	if (renderingContext == null) return;
+
+	// 计算内容区(座位区域)宽高
+	const contentWidth = props.cols * (seatSize.value + props.seatGap) - props.seatGap;
+	const contentHeight = props.rows * (seatSize.value + props.seatGap) - props.seatGap;
+	// 获取画布的显示区域宽高
+	const viewWidth = renderingContext!.canvas.offsetWidth;
+	const viewHeight = renderingContext!.canvas.offsetHeight;
+
+	// 计算缩放后的实际宽高
+	const scaledWidth = contentWidth * scale.value;
+	const scaledHeight = contentHeight * scale.value;
+
+	// 允许的最大空白比例(视图的 20%)
+	const marginRatio = 0.2;
+	const marginX = viewWidth * marginRatio;
+	const marginY = viewHeight * marginRatio;
+
+	// 水平方向边界限制:内容左边缘最多在视图左侧 20% 处,右边缘最多在视图右侧 80% 处
+	const minTranslateX = viewWidth * (1 - marginRatio) - scaledWidth;
+	const maxTranslateX = marginX;
+	translateX.value = Math.max(minTranslateX, Math.min(maxTranslateX, translateX.value));
+
+	// 垂直方向边界限制:内容上边缘最多在视图顶部 20% 处,下边缘最多在视图底部 80% 处
+	const minTranslateY = viewHeight * (1 - marginRatio) - scaledHeight;
+	const maxTranslateY = marginY;
+	translateY.value = Math.max(minTranslateY, Math.min(maxTranslateY, translateY.value));
+}
+
+// 绘制圆角矩形
+function drawRoundRect(
+	ctx: CanvasRenderingContext2D,
+	x: number,
+	y: number,
+	width: number,
+	height: number,
+	radius: number
+) {
+	ctx.beginPath();
+	ctx.moveTo(x + radius, y);
+	ctx.lineTo(x + width - radius, y);
+	ctx.arc(x + width - radius, y + radius, radius, Math.PI * 1.5, Math.PI * 2);
+	ctx.lineTo(x + width, y + height - radius);
+	ctx.arc(x + width - radius, y + height - radius, radius, 0, Math.PI * 0.5);
+	ctx.lineTo(x + radius, y + height);
+	ctx.arc(x + radius, y + height - radius, radius, Math.PI * 0.5, Math.PI);
+	ctx.lineTo(x, y + radius);
+	ctx.arc(x + radius, y + radius, radius, Math.PI, Math.PI * 1.5);
+	ctx.closePath();
+}
+
+// 绘制单个座位
+function drawSeat(seat: ClSelectSeatItem) {
+	if (renderingContext == null) return;
+
+	// 如果是空位,不渲染但保留位置
+	if (seat.empty == true) return;
+
+	const x = seat.col * (seatSize.value + props.seatGap);
+	const y = seat.row * (seatSize.value + props.seatGap);
+
+	// 计算图标中心位置(使用整数避免亚像素渲染问题)
+	const centerX = Math.round(x + seatSize.value / 2);
+	const centerY = Math.round(y + seatSize.value / 2);
+	const fontSize = Math.round(seatSize.value * 0.6);
+
+	if (isSelected(seat.row, seat.col)) {
+		// 绘制选中背景
+		renderingContext!.fillStyle = seat.selectedBgColor ?? props.selectedBgColor;
+		drawRoundRect(renderingContext!, x, y, seatSize.value, seatSize.value, props.borderRadius);
+		renderingContext!.fill();
+
+		// 绘制选中图标
+		const { text, font } = getIcon(seat.selectedIcon ?? props.selectedIcon);
+		if (text != "") {
+			renderingContext!.fillStyle = seat.selectedColor ?? props.selectedColor;
+			renderingContext!.font = `${fontSize}px ${font}`;
+			renderingContext!.textAlign = "center";
+			renderingContext!.textBaseline = "middle";
+			renderingContext!.fillText(text, centerX, centerY);
+		}
+	} else {
+		// 绘制未选中背景
+		const bgColor = seat.bgColor ?? (isDark.value ? props.darkBgColor : props.bgColor);
+		renderingContext!.fillStyle = bgColor;
+		drawRoundRect(renderingContext!, x, y, seatSize.value, seatSize.value, props.borderRadius);
+		renderingContext!.fill();
+
+		// 绘制边框
+		renderingContext!.strokeStyle =
+			seat.borderColor ?? (isDark.value ? props.darkBorderColor : props.borderColor);
+		renderingContext!.lineWidth = props.borderWidth;
+		drawRoundRect(renderingContext!, x, y, seatSize.value, seatSize.value, props.borderRadius);
+		renderingContext!.stroke();
+
+		// 绘制默认图标
+		if (seat.icon != null) {
+			const { text, font } = getIcon(seat.icon);
+			if (text != "") {
+				renderingContext!.fillStyle =
+					seat.color ?? (isDark.value ? props.darkColor : props.color);
+				renderingContext!.font = `${fontSize}px ${font}`;
+				renderingContext!.textAlign = "center";
+				renderingContext!.textBaseline = "middle";
+				renderingContext!.fillText(text, centerX, centerY);
+			}
+		}
+	}
+}
+
+// 绘制画布
+function draw() {
+	if (renderingContext == null) return;
+
+	// 清空画布
+	renderingContext!.save();
+	renderingContext!.setTransform(1, 0, 0, 1, 0, 0);
+	renderingContext!.clearRect(
+		0,
+		0,
+		renderingContext!.canvas.width,
+		renderingContext!.canvas.height
+	);
+	renderingContext!.restore();
+
+	// 应用变换
+	renderingContext!.save();
+	renderingContext!.translate(translateX.value, translateY.value);
+	renderingContext!.scale(scale.value, scale.value);
+
+	// 绘制座位
+	seats.forEach((seat) => {
+		drawSeat(seat);
+	});
+
+	renderingContext!.restore();
+}
+
+// 转换触摸点数据
+function getTouches(touches: UniTouch[]): TouchPoint[] {
+	const result: TouchPoint[] = [];
+	for (let i = 0; i < touches.length; i++) {
+		const touch = touches[i];
+		result.push({
+			x: touch.clientX,
+			y: touch.clientY,
+			identifier: touch.identifier
+		} as TouchPoint);
+	}
+	return result;
+}
+
+// 更新画布偏移量
+function updateOffset() {
+	uni.createSelectorQuery()
+		.in(proxy)
+		.select("#seatCanvas")
+		.boundingClientRect((rect) => {
+			offsetTop = (rect as NodeInfo).top ?? 0;
+			offsetLeft = (rect as NodeInfo).left ?? 0;
+		})
+		.exec();
+}
+
+// 计算两点距离
+function getTouchDistance(p1: TouchPoint, p2: TouchPoint): number {
+	const dx = p2.x - p1.x;
+	const dy = p2.y - p1.y;
+	return Math.sqrt(dx * dx + dy * dy);
+}
+
+// 计算两点中心
+function getTouchCenter(p1: TouchPoint, p2: TouchPoint): TouchPoint {
+	return {
+		x: (p1.x + p2.x) / 2,
+		y: (p1.y + p2.y) / 2,
+		identifier: 0
+	};
+}
+
+// 根据坐标获取座位
+function getSeatAtPoint(screenX: number, screenY: number): ClSelectSeatItem | null {
+	// 转换为相对坐标
+	const relativeX = screenX - offsetLeft;
+	const relativeY = screenY - offsetTop - (isH5() ? 45 : 0);
+
+	// 转换为画布坐标
+	const canvasX = (relativeX - translateX.value) / scale.value;
+	const canvasY = (relativeY - translateY.value) / scale.value;
+
+	// 计算行列
+	const col = Math.floor(canvasX / (seatSize.value + props.seatGap));
+	const row = Math.floor(canvasY / (seatSize.value + props.seatGap));
+
+	// 检查有效性
+	if (row >= 0 && row < props.rows && col >= 0 && col < props.cols) {
+		const localX = canvasX - col * (seatSize.value + props.seatGap);
+		const localY = canvasY - row * (seatSize.value + props.seatGap);
+
+		// 检查是否在座位内(排除间隙)
+		if (localX >= 0 && localX <= seatSize.value && localY >= 0 && localY <= seatSize.value) {
+			const index = row * props.cols + col;
+			return seats[index];
+		}
+	}
+
+	return null;
+}
+
+// 设置座位
+function setSeat(row: number, col: number, data: UTSJSONObject) {
+	const index = row * props.cols + col;
+	if (index >= 0 && index < seats.length) {
+		assign(seats[index], data);
+		draw();
+	}
+}
+
+// 获取所有座位
+function getSeats(): ClSelectSeatItem[] {
+	return seats;
+}
+
+// 初始化 Canvas
+function initCanvas() {
+	uni.createCanvasContextAsync({
+		id: "seatCanvas",
+		component: proxy,
+		success: (context: CanvasContext) => {
+			renderingContext = context.getContext("2d")!;
+
+			// 适配高清屏
+			const dpr = getDevicePixelRatio();
+			renderingContext!.canvas.width = renderingContext!.canvas.offsetWidth * dpr;
+			renderingContext!.canvas.height = renderingContext!.canvas.offsetHeight * dpr;
+			renderingContext!.scale(dpr, dpr);
+
+			initSeats();
+			centerView();
+			draw();
+			updateOffset();
+		}
+	});
+}
+
+// 触摸开始
+function onTouchStart(e: UniTouchEvent) {
+	updateOffset();
+
+	touchPoints = getTouches(e.touches);
+	hasMoved = false;
+
+	// 记录起始触摸点
+	if (touchPoints.length == 1) {
+		startTouchX = touchPoints[0].x;
+		startTouchY = touchPoints[0].y;
+	}
+
+	// 记录双指初始状态
+	if (touchPoints.length == 2) {
+		hasMoved = true; // 双指操作直接标记为已移动
+		lastDistance = getTouchDistance(touchPoints[0], touchPoints[1]);
+		const center = getTouchCenter(touchPoints[0], touchPoints[1]);
+		lastCenterX = center.x;
+		lastCenterY = center.y;
+	}
+}
+
+// 触摸移动
+function onTouchMove(e: UniTouchEvent) {
+	const currentTouches = getTouches(e.touches);
+
+	// 双指缩放
+	if (currentTouches.length == 2 && touchPoints.length == 2) {
+		hasMoved = true;
+		const currentDistance = getTouchDistance(currentTouches[0], currentTouches[1]);
+		const currentCenter = getTouchCenter(currentTouches[0], currentTouches[1]);
+
+		// 计算缩放
+		const scaleChange = currentDistance / lastDistance;
+		const newScale = Math.max(
+			props.minScale,
+			Math.min(props.maxScale, scale.value * scaleChange)
+		);
+
+		// 以触摸中心为基准缩放
+		const scaleDiff = newScale - scale.value;
+		translateX.value -= (currentCenter.x - translateX.value) * (scaleDiff / scale.value);
+		translateY.value -= (currentCenter.y - translateY.value) * (scaleDiff / scale.value);
+
+		scale.value = newScale;
+		lastDistance = currentDistance;
+
+		// 计算平移
+		const dx = currentCenter.x - lastCenterX;
+		const dy = currentCenter.y - lastCenterY;
+		translateX.value += dx;
+		translateY.value += dy;
+
+		lastCenterX = currentCenter.x;
+		lastCenterY = currentCenter.y;
+	} else if (currentTouches.length == 1 && touchPoints.length == 1) {
+		// 单指拖动
+		const dx = currentTouches[0].x - touchPoints[0].x;
+		const dy = currentTouches[0].y - touchPoints[0].y;
+
+		translateX.value += dx;
+		translateY.value += dy;
+
+		// 判断是否超过拖动阈值
+		const totalDx = currentTouches[0].x - startTouchX;
+		const totalDy = currentTouches[0].y - startTouchY;
+		if (Math.abs(totalDx) > moveThreshold || Math.abs(totalDy) > moveThreshold) {
+			hasMoved = true;
+		}
+	}
+
+	// 限制平移范围
+	constrainTranslate();
+
+	// 绘制
+	draw();
+
+	// 触发移动事件
+	emit("move", {
+		translateX: translateX.value,
+		translateY: translateY.value,
+		scale: scale.value,
+		screenTranslateX: translateX.value - leftPadding + (props.width * (scale.value - 1)) / 2
+	});
+
+	// 更新触摸点
+	touchPoints = currentTouches;
+}
+
+// 触摸结束
+function onTouchEnd(e: UniTouchEvent) {
+	const changedTouches = getTouches(e.changedTouches);
+
+	// 单击选座(未发生拖动或缩放时才触发)
+	if (changedTouches.length == 1 && touchPoints.length == 1 && !hasMoved) {
+		const touch = changedTouches[0];
+		const seat = getSeatAtPoint(touch.x, touch.y);
+
+		if (seat != null && seat.disabled != true && seat.empty != true) {
+			let value: ClSelectSeatValue[] = [];
+
+			if (isSelected(seat.row, seat.col)) {
+				value = props.modelValue.filter(
+					(item) => !(item.row == seat.row && item.col == seat.col)
+				);
+			} else {
+				value = [
+					...props.modelValue,
+					{ row: seat.row, col: seat.col } as ClSelectSeatValue
+				];
+			}
+
+			emit("update:modelValue", value);
+			emit("seatClick", seat);
+		}
+	}
+
+	touchPoints = getTouches(e.touches);
+
+	// 所有手指抬起后重置状态
+	if (touchPoints.length == 0) {
+		hasMoved = false;
+	}
+}
+
+// 监听选中变化
+watch(
+	computed<ClSelectSeatValue[]>(() => props.modelValue),
+	() => {
+		draw();
+	},
+	{ deep: true }
+);
+
+// 监听暗色模式变化
+watch(
+	isDark,
+	() => {
+		draw();
+	},
+	{ deep: true }
+);
+
+// 监听座位数据变化
+watch(
+	computed<ClSelectSeatItem[]>(() => props.seats),
+	() => {
+		initSeats();
+		draw();
+	},
+	{ deep: true }
+);
+
+onMounted(() => {
+	initCanvas();
+});
+
+defineExpose({
+	setSeat,
+	getSeats,
+	draw
+});
+</script>
+
+<style lang="scss" scoped>
+.cl-select-seat {
+	@apply relative;
+
+	&__canvas {
+		@apply h-full w-full;
+	}
+
+	&__index {
+		@apply absolute top-0 left-1 z-10 rounded-xl;
+		background-color: rgba(0, 0, 0, 0.4);
+
+		&-item {
+			@apply flex flex-col items-center justify-center;
+			width: 36rpx;
+		}
+	}
+}
+</style>