| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784 |
- <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" :size="10">{{ 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"
- },
- selectedImage: {
- type: String,
- default: ""
- },
- image: {
- type: String,
- default: ""
- },
- 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 canvasContext: CanvasContext | null = null;
- // 画布偏移顶部
- let offsetTop = 0;
- // 画布偏移左侧
- let offsetLeft = 0;
- // 左侧边距
- let leftPadding = 0;
- // 座位数据
- let seats: ClSelectSeatItem[] = [];
- // 图片缓存
- const imageCache = new Map<string, Image>();
- // 画布变换参数
- 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,
- selectedImage: e.selectedImage,
- icon: e.icon,
- image: e.image,
- 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 loadImage(src: string): Promise<Image> {
- return new Promise((resolve, reject) => {
- if (imageCache.has(src)) {
- resolve(imageCache.get(src)!);
- return;
- }
- // 创建图片
- let img: Image;
- // 微信小程序环境创建图片
- // #ifdef MP-WEIXIN || APP-HARMONY
- img = canvasContext!.createImage();
- // #endif
- // 其他环境创建图片
- // #ifndef MP-WEIXIN || APP-HARMONY
- img = new Image();
- // #endif
- img.src = src;
- img.onload = () => {
- imageCache.set(src, img);
- resolve(img);
- };
- });
- }
- // 预加载所有图片
- async function preloadImages() {
- const imagesToLoad: string[] = [];
- // 收集所有需要加载的图片
- if (props.image != "") imagesToLoad.push(props.image);
- if (props.selectedImage != "") imagesToLoad.push(props.selectedImage);
- seats.forEach((seat) => {
- if (seat.image != null) imagesToLoad.push(seat.image);
- if (seat.selectedImage != null) imagesToLoad.push(seat.selectedImage);
- });
- // 去重并加载
- const uniqueImages = [...new Set(imagesToLoad)];
- await Promise.all(uniqueImages.map((src) => loadImage(src).catch(() => {})));
- }
- // 居中显示
- 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)) {
- // 优先使用图片,否则使用背景+图标
- const selectedImageSrc = seat.selectedImage ?? props.selectedImage;
- if (selectedImageSrc != "" && imageCache.has(selectedImageSrc)) {
- // 使用圆角裁剪绘制图片
- renderingContext!.save();
- drawRoundRect(
- renderingContext!,
- x,
- y,
- seatSize.value,
- seatSize.value,
- props.borderRadius
- );
- renderingContext!.clip();
- const img = imageCache.get(selectedImageSrc)!;
- renderingContext!.drawImage(img, x, y, seatSize.value, seatSize.value);
- renderingContext!.restore();
- } else {
- // 绘制选中背景
- 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 imageSrc = seat.image ?? props.image;
- if (imageSrc != "" && imageCache.has(imageSrc)) {
- // 使用圆角裁剪绘制图片
- renderingContext!.save();
- drawRoundRect(
- renderingContext!,
- x,
- y,
- seatSize.value,
- seatSize.value,
- props.borderRadius
- );
- renderingContext!.clip();
- const img = imageCache.get(imageSrc)!;
- renderingContext!.drawImage(img, x, y, seatSize.value, seatSize.value);
- renderingContext!.restore();
- } 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) => {
- canvasContext = context;
- 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();
- // 预加载图片后再绘制
- preloadImages().finally(() => {
- 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: 14px;
- }
- }
- }
- </style>
|