cl-select-seat.uvue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  1. <template>
  2. <view class="cl-select-seat" :style="{ width: width + 'px', height: height + 'px' }">
  3. <view
  4. class="cl-select-seat__index"
  5. :style="{
  6. transform: `translateY(${translateY}px)`
  7. }"
  8. >
  9. <view
  10. class="cl-select-seat__index-item"
  11. v-for="i in rows"
  12. :key="i"
  13. :style="{
  14. height: seatSize * scale + 'px',
  15. marginTop: i == 1 ? 0 : seatGap * scale + 'px'
  16. }"
  17. >
  18. <cl-text color="white" :size="10">{{ i }}</cl-text>
  19. </view>
  20. </view>
  21. <canvas
  22. id="seatCanvas"
  23. class="cl-select-seat__canvas"
  24. @touchstart.stop.prevent="onTouchStart"
  25. @touchmove.stop.prevent="onTouchMove"
  26. @touchend.stop.prevent="onTouchEnd"
  27. ></canvas>
  28. </view>
  29. </template>
  30. <script setup lang="ts">
  31. import { assign, getColor, getDevicePixelRatio, isDark, isH5 } from "@/.cool";
  32. import { computed, getCurrentInstance, onMounted, ref, watch } from "vue";
  33. import { getIcon } from "../cl-icon/utils";
  34. import type { PropType } from "vue";
  35. import type { ClSelectSeatItem, ClSelectSeatValue } from "../../types";
  36. defineOptions({
  37. name: "cl-select-seat"
  38. });
  39. type TouchPoint = {
  40. x: number;
  41. y: number;
  42. identifier: number;
  43. };
  44. const props = defineProps({
  45. modelValue: {
  46. type: Array as PropType<ClSelectSeatValue[]>,
  47. default: () => []
  48. },
  49. rows: {
  50. type: Number,
  51. default: 0
  52. },
  53. cols: {
  54. type: Number,
  55. default: 0
  56. },
  57. seatGap: {
  58. type: Number,
  59. default: 8
  60. },
  61. borderRadius: {
  62. type: Number,
  63. default: 8
  64. },
  65. borderWidth: {
  66. type: Number,
  67. default: 1
  68. },
  69. minScale: {
  70. type: Number,
  71. default: 1
  72. },
  73. maxScale: {
  74. type: Number,
  75. default: 3
  76. },
  77. color: {
  78. type: String,
  79. default: () => getColor("surface-300")
  80. },
  81. darkColor: {
  82. type: String,
  83. default: () => getColor("surface-500")
  84. },
  85. bgColor: {
  86. type: String,
  87. default: () => "#ffffff"
  88. },
  89. darkBgColor: {
  90. type: String,
  91. default: () => getColor("surface-800")
  92. },
  93. borderColor: {
  94. type: String,
  95. default: () => getColor("surface-200")
  96. },
  97. darkBorderColor: {
  98. type: String,
  99. default: () => getColor("surface-600")
  100. },
  101. selectedBgColor: {
  102. type: String,
  103. default: () => getColor("primary-500")
  104. },
  105. selectedColor: {
  106. type: String,
  107. default: () => "#ffffff"
  108. },
  109. selectedIcon: {
  110. type: String,
  111. default: "check-line"
  112. },
  113. selectedImage: {
  114. type: String,
  115. default: ""
  116. },
  117. image: {
  118. type: String,
  119. default: ""
  120. },
  121. width: {
  122. type: Number,
  123. default: 0
  124. },
  125. height: {
  126. type: Number,
  127. default: 0
  128. },
  129. seats: {
  130. type: Array as PropType<ClSelectSeatItem[]>,
  131. default: () => []
  132. }
  133. });
  134. const emit = defineEmits(["update:modelValue", "seatClick", "move"]);
  135. const { proxy } = getCurrentInstance()!;
  136. // 画布渲染上下文
  137. let renderingContext: CanvasRenderingContext2D | null = null;
  138. let canvasContext: CanvasContext | null = null;
  139. // 画布偏移顶部
  140. let offsetTop = 0;
  141. // 画布偏移左侧
  142. let offsetLeft = 0;
  143. // 左侧边距
  144. let leftPadding = 0;
  145. // 座位数据
  146. let seats: ClSelectSeatItem[] = [];
  147. // 图片缓存
  148. const imageCache = new Map<string, Image>();
  149. // 画布变换参数
  150. const scale = ref(1);
  151. const translateX = ref(0);
  152. const translateY = ref(0);
  153. // 触摸状态
  154. let touchPoints: TouchPoint[] = [];
  155. let lastDistance = 0;
  156. let lastCenterX = 0;
  157. let lastCenterY = 0;
  158. // 是否发生过拖动或缩放,用于防止误触选座
  159. let hasMoved = false;
  160. // 起始触摸点,用于判断是否发生拖动
  161. let startTouchX = 0;
  162. let startTouchY = 0;
  163. // 拖动阈值(像素)
  164. const moveThreshold = 10;
  165. // 根据视图大小计算座位大小
  166. const seatSize = computed(() => {
  167. const availableWidth =
  168. props.width - (props.cols - 1) * props.seatGap - props.borderWidth * props.cols;
  169. const seatWidth = availableWidth / props.cols;
  170. const availableHeight =
  171. props.height - (props.rows - 1) * props.seatGap - props.borderWidth * props.rows;
  172. const seatHeight = availableHeight / props.rows;
  173. return Math.min(seatWidth, seatHeight);
  174. });
  175. // 是否选中
  176. function isSelected(row: number, col: number): boolean {
  177. return props.modelValue.some((item) => item.row == row && item.col == col);
  178. }
  179. // 初始化座位数据
  180. function initSeats() {
  181. seats = [];
  182. if (props.seats.length > 0) {
  183. props.seats.forEach((e) => {
  184. seats.push({
  185. row: e.row,
  186. col: e.col,
  187. disabled: e.disabled,
  188. empty: e.empty,
  189. bgColor: e.bgColor,
  190. borderColor: e.borderColor,
  191. selectedBgColor: e.selectedBgColor,
  192. selectedColor: e.selectedColor,
  193. selectedIcon: e.selectedIcon,
  194. selectedImage: e.selectedImage,
  195. icon: e.icon,
  196. image: e.image,
  197. color: e.color
  198. } as ClSelectSeatItem);
  199. });
  200. } else {
  201. for (let row = 0; row < props.rows; row++) {
  202. for (let col = 0; col < props.cols; col++) {
  203. seats.push({
  204. row,
  205. col
  206. } as ClSelectSeatItem);
  207. }
  208. }
  209. }
  210. }
  211. // 加载图片
  212. function loadImage(src: string): Promise<Image> {
  213. return new Promise((resolve, reject) => {
  214. if (imageCache.has(src)) {
  215. resolve(imageCache.get(src)!);
  216. return;
  217. }
  218. // 创建图片
  219. let img: Image;
  220. // 微信小程序环境创建图片
  221. // #ifdef MP-WEIXIN || APP-HARMONY
  222. img = canvasContext!.createImage();
  223. // #endif
  224. // 其他环境创建图片
  225. // #ifndef MP-WEIXIN || APP-HARMONY
  226. img = new Image();
  227. // #endif
  228. img.src = src;
  229. img.onload = () => {
  230. imageCache.set(src, img);
  231. resolve(img);
  232. };
  233. });
  234. }
  235. // 预加载所有图片
  236. async function preloadImages() {
  237. const imagesToLoad: string[] = [];
  238. // 收集所有需要加载的图片
  239. if (props.image != "") imagesToLoad.push(props.image);
  240. if (props.selectedImage != "") imagesToLoad.push(props.selectedImage);
  241. seats.forEach((seat) => {
  242. if (seat.image != null) imagesToLoad.push(seat.image);
  243. if (seat.selectedImage != null) imagesToLoad.push(seat.selectedImage);
  244. });
  245. // 去重并加载
  246. const uniqueImages = [...new Set(imagesToLoad)];
  247. await Promise.all(uniqueImages.map((src) => loadImage(src).catch(() => {})));
  248. }
  249. // 居中显示
  250. function centerView() {
  251. if (renderingContext == null) return;
  252. const contentWidth = props.cols * (seatSize.value + props.seatGap) - props.seatGap;
  253. const contentHeight = props.rows * (seatSize.value + props.seatGap) - props.seatGap;
  254. translateX.value = (renderingContext!.canvas.offsetWidth - contentWidth) / 2;
  255. translateY.value = (renderingContext!.canvas.offsetHeight - contentHeight) / 2;
  256. leftPadding = translateX.value;
  257. }
  258. // 限制平移范围
  259. function constrainTranslate() {
  260. if (renderingContext == null) return;
  261. // 计算内容区(座位区域)宽高
  262. const contentWidth = props.cols * (seatSize.value + props.seatGap) - props.seatGap;
  263. const contentHeight = props.rows * (seatSize.value + props.seatGap) - props.seatGap;
  264. // 获取画布的显示区域宽高
  265. const viewWidth = renderingContext!.canvas.offsetWidth;
  266. const viewHeight = renderingContext!.canvas.offsetHeight;
  267. // 计算缩放后的实际宽高
  268. const scaledWidth = contentWidth * scale.value;
  269. const scaledHeight = contentHeight * scale.value;
  270. // 允许的最大空白比例(视图的 20%)
  271. const marginRatio = 0.2;
  272. const marginX = viewWidth * marginRatio;
  273. const marginY = viewHeight * marginRatio;
  274. // 水平方向边界限制:内容左边缘最多在视图左侧 20% 处,右边缘最多在视图右侧 80% 处
  275. const minTranslateX = viewWidth * (1 - marginRatio) - scaledWidth;
  276. const maxTranslateX = marginX;
  277. translateX.value = Math.max(minTranslateX, Math.min(maxTranslateX, translateX.value));
  278. // 垂直方向边界限制:内容上边缘最多在视图顶部 20% 处,下边缘最多在视图底部 80% 处
  279. const minTranslateY = viewHeight * (1 - marginRatio) - scaledHeight;
  280. const maxTranslateY = marginY;
  281. translateY.value = Math.max(minTranslateY, Math.min(maxTranslateY, translateY.value));
  282. }
  283. // 绘制圆角矩形
  284. function drawRoundRect(
  285. ctx: CanvasRenderingContext2D,
  286. x: number,
  287. y: number,
  288. width: number,
  289. height: number,
  290. radius: number
  291. ) {
  292. ctx.beginPath();
  293. ctx.moveTo(x + radius, y);
  294. ctx.lineTo(x + width - radius, y);
  295. ctx.arc(x + width - radius, y + radius, radius, Math.PI * 1.5, Math.PI * 2);
  296. ctx.lineTo(x + width, y + height - radius);
  297. ctx.arc(x + width - radius, y + height - radius, radius, 0, Math.PI * 0.5);
  298. ctx.lineTo(x + radius, y + height);
  299. ctx.arc(x + radius, y + height - radius, radius, Math.PI * 0.5, Math.PI);
  300. ctx.lineTo(x, y + radius);
  301. ctx.arc(x + radius, y + radius, radius, Math.PI, Math.PI * 1.5);
  302. ctx.closePath();
  303. }
  304. // 绘制单个座位
  305. function drawSeat(seat: ClSelectSeatItem) {
  306. if (renderingContext == null) return;
  307. // 如果是空位,不渲染但保留位置
  308. if (seat.empty == true) return;
  309. const x = seat.col * (seatSize.value + props.seatGap);
  310. const y = seat.row * (seatSize.value + props.seatGap);
  311. // 计算图标中心位置(使用整数避免亚像素渲染问题)
  312. const centerX = Math.round(x + seatSize.value / 2);
  313. const centerY = Math.round(y + seatSize.value / 2);
  314. const fontSize = Math.round(seatSize.value * 0.6);
  315. if (isSelected(seat.row, seat.col)) {
  316. // 优先使用图片,否则使用背景+图标
  317. const selectedImageSrc = seat.selectedImage ?? props.selectedImage;
  318. if (selectedImageSrc != "" && imageCache.has(selectedImageSrc)) {
  319. // 使用圆角裁剪绘制图片
  320. renderingContext!.save();
  321. drawRoundRect(
  322. renderingContext!,
  323. x,
  324. y,
  325. seatSize.value,
  326. seatSize.value,
  327. props.borderRadius
  328. );
  329. renderingContext!.clip();
  330. const img = imageCache.get(selectedImageSrc)!;
  331. renderingContext!.drawImage(img, x, y, seatSize.value, seatSize.value);
  332. renderingContext!.restore();
  333. } else {
  334. // 绘制选中背景
  335. renderingContext!.fillStyle = seat.selectedBgColor ?? props.selectedBgColor;
  336. drawRoundRect(
  337. renderingContext!,
  338. x,
  339. y,
  340. seatSize.value,
  341. seatSize.value,
  342. props.borderRadius
  343. );
  344. renderingContext!.fill();
  345. // 绘制选中图标
  346. const { text, font } = getIcon(seat.selectedIcon ?? props.selectedIcon);
  347. if (text != "") {
  348. renderingContext!.fillStyle = seat.selectedColor ?? props.selectedColor;
  349. renderingContext!.font = `${fontSize}px ${font}`;
  350. renderingContext!.textAlign = "center";
  351. renderingContext!.textBaseline = "middle";
  352. renderingContext!.fillText(text, centerX, centerY);
  353. }
  354. }
  355. } else {
  356. // 优先使用图片,否则使用背景+边框+图标
  357. const imageSrc = seat.image ?? props.image;
  358. if (imageSrc != "" && imageCache.has(imageSrc)) {
  359. // 使用圆角裁剪绘制图片
  360. renderingContext!.save();
  361. drawRoundRect(
  362. renderingContext!,
  363. x,
  364. y,
  365. seatSize.value,
  366. seatSize.value,
  367. props.borderRadius
  368. );
  369. renderingContext!.clip();
  370. const img = imageCache.get(imageSrc)!;
  371. renderingContext!.drawImage(img, x, y, seatSize.value, seatSize.value);
  372. renderingContext!.restore();
  373. } else {
  374. // 绘制未选中背景
  375. const bgColor = seat.bgColor ?? (isDark.value ? props.darkBgColor : props.bgColor);
  376. renderingContext!.fillStyle = bgColor;
  377. drawRoundRect(
  378. renderingContext!,
  379. x,
  380. y,
  381. seatSize.value,
  382. seatSize.value,
  383. props.borderRadius
  384. );
  385. renderingContext!.fill();
  386. // 绘制边框
  387. renderingContext!.strokeStyle =
  388. seat.borderColor ?? (isDark.value ? props.darkBorderColor : props.borderColor);
  389. renderingContext!.lineWidth = props.borderWidth;
  390. drawRoundRect(
  391. renderingContext!,
  392. x,
  393. y,
  394. seatSize.value,
  395. seatSize.value,
  396. props.borderRadius
  397. );
  398. renderingContext!.stroke();
  399. // 绘制默认图标
  400. if (seat.icon != null) {
  401. const { text, font } = getIcon(seat.icon);
  402. if (text != "") {
  403. renderingContext!.fillStyle =
  404. seat.color ?? (isDark.value ? props.darkColor : props.color);
  405. renderingContext!.font = `${fontSize}px ${font}`;
  406. renderingContext!.textAlign = "center";
  407. renderingContext!.textBaseline = "middle";
  408. renderingContext!.fillText(text, centerX, centerY);
  409. }
  410. }
  411. }
  412. }
  413. }
  414. // 绘制画布
  415. function draw() {
  416. if (renderingContext == null) return;
  417. // 清空画布
  418. renderingContext!.save();
  419. renderingContext!.setTransform(1, 0, 0, 1, 0, 0);
  420. renderingContext!.clearRect(
  421. 0,
  422. 0,
  423. renderingContext!.canvas.width,
  424. renderingContext!.canvas.height
  425. );
  426. renderingContext!.restore();
  427. // 应用变换
  428. renderingContext!.save();
  429. renderingContext!.translate(translateX.value, translateY.value);
  430. renderingContext!.scale(scale.value, scale.value);
  431. // 绘制座位
  432. seats.forEach((seat) => {
  433. drawSeat(seat);
  434. });
  435. renderingContext!.restore();
  436. }
  437. // 转换触摸点数据
  438. function getTouches(touches: UniTouch[]): TouchPoint[] {
  439. const result: TouchPoint[] = [];
  440. for (let i = 0; i < touches.length; i++) {
  441. const touch = touches[i];
  442. result.push({
  443. x: touch.clientX,
  444. y: touch.clientY,
  445. identifier: touch.identifier
  446. } as TouchPoint);
  447. }
  448. return result;
  449. }
  450. // 更新画布偏移量
  451. function updateOffset() {
  452. uni.createSelectorQuery()
  453. .in(proxy)
  454. .select("#seatCanvas")
  455. .boundingClientRect((rect) => {
  456. offsetTop = (rect as NodeInfo).top ?? 0;
  457. offsetLeft = (rect as NodeInfo).left ?? 0;
  458. })
  459. .exec();
  460. }
  461. // 计算两点距离
  462. function getTouchDistance(p1: TouchPoint, p2: TouchPoint): number {
  463. const dx = p2.x - p1.x;
  464. const dy = p2.y - p1.y;
  465. return Math.sqrt(dx * dx + dy * dy);
  466. }
  467. // 计算两点中心
  468. function getTouchCenter(p1: TouchPoint, p2: TouchPoint): TouchPoint {
  469. return {
  470. x: (p1.x + p2.x) / 2,
  471. y: (p1.y + p2.y) / 2,
  472. identifier: 0
  473. };
  474. }
  475. // 根据坐标获取座位
  476. function getSeatAtPoint(screenX: number, screenY: number): ClSelectSeatItem | null {
  477. // 转换为相对坐标
  478. const relativeX = screenX - offsetLeft;
  479. const relativeY = screenY - offsetTop - (isH5() ? 45 : 0);
  480. // 转换为画布坐标
  481. const canvasX = (relativeX - translateX.value) / scale.value;
  482. const canvasY = (relativeY - translateY.value) / scale.value;
  483. // 计算行列
  484. const col = Math.floor(canvasX / (seatSize.value + props.seatGap));
  485. const row = Math.floor(canvasY / (seatSize.value + props.seatGap));
  486. // 检查有效性
  487. if (row >= 0 && row < props.rows && col >= 0 && col < props.cols) {
  488. const localX = canvasX - col * (seatSize.value + props.seatGap);
  489. const localY = canvasY - row * (seatSize.value + props.seatGap);
  490. // 检查是否在座位内(排除间隙)
  491. if (localX >= 0 && localX <= seatSize.value && localY >= 0 && localY <= seatSize.value) {
  492. const index = row * props.cols + col;
  493. return seats[index];
  494. }
  495. }
  496. return null;
  497. }
  498. // 设置座位
  499. function setSeat(row: number, col: number, data: UTSJSONObject) {
  500. const index = row * props.cols + col;
  501. if (index >= 0 && index < seats.length) {
  502. assign(seats[index], data);
  503. draw();
  504. }
  505. }
  506. // 获取所有座位
  507. function getSeats(): ClSelectSeatItem[] {
  508. return seats;
  509. }
  510. // 初始化 Canvas
  511. function initCanvas() {
  512. uni.createCanvasContextAsync({
  513. id: "seatCanvas",
  514. component: proxy,
  515. success: (context: CanvasContext) => {
  516. canvasContext = context;
  517. renderingContext = context.getContext("2d")!;
  518. // 适配高清屏
  519. const dpr = getDevicePixelRatio();
  520. renderingContext!.canvas.width = renderingContext!.canvas.offsetWidth * dpr;
  521. renderingContext!.canvas.height = renderingContext!.canvas.offsetHeight * dpr;
  522. renderingContext!.scale(dpr, dpr);
  523. initSeats();
  524. centerView();
  525. // 预加载图片后再绘制
  526. preloadImages().finally(() => {
  527. draw();
  528. updateOffset();
  529. });
  530. }
  531. });
  532. }
  533. // 触摸开始
  534. function onTouchStart(e: UniTouchEvent) {
  535. updateOffset();
  536. touchPoints = getTouches(e.touches);
  537. hasMoved = false;
  538. // 记录起始触摸点
  539. if (touchPoints.length == 1) {
  540. startTouchX = touchPoints[0].x;
  541. startTouchY = touchPoints[0].y;
  542. }
  543. // 记录双指初始状态
  544. if (touchPoints.length == 2) {
  545. hasMoved = true; // 双指操作直接标记为已移动
  546. lastDistance = getTouchDistance(touchPoints[0], touchPoints[1]);
  547. const center = getTouchCenter(touchPoints[0], touchPoints[1]);
  548. lastCenterX = center.x;
  549. lastCenterY = center.y;
  550. }
  551. }
  552. // 触摸移动
  553. function onTouchMove(e: UniTouchEvent) {
  554. const currentTouches = getTouches(e.touches);
  555. // 双指缩放
  556. if (currentTouches.length == 2 && touchPoints.length == 2) {
  557. hasMoved = true;
  558. const currentDistance = getTouchDistance(currentTouches[0], currentTouches[1]);
  559. const currentCenter = getTouchCenter(currentTouches[0], currentTouches[1]);
  560. // 计算缩放
  561. const scaleChange = currentDistance / lastDistance;
  562. const newScale = Math.max(
  563. props.minScale,
  564. Math.min(props.maxScale, scale.value * scaleChange)
  565. );
  566. // 以触摸中心为基准缩放
  567. const scaleDiff = newScale - scale.value;
  568. translateX.value -= (currentCenter.x - translateX.value) * (scaleDiff / scale.value);
  569. translateY.value -= (currentCenter.y - translateY.value) * (scaleDiff / scale.value);
  570. scale.value = newScale;
  571. lastDistance = currentDistance;
  572. // 计算平移
  573. const dx = currentCenter.x - lastCenterX;
  574. const dy = currentCenter.y - lastCenterY;
  575. translateX.value += dx;
  576. translateY.value += dy;
  577. lastCenterX = currentCenter.x;
  578. lastCenterY = currentCenter.y;
  579. } else if (currentTouches.length == 1 && touchPoints.length == 1) {
  580. // 单指拖动
  581. const dx = currentTouches[0].x - touchPoints[0].x;
  582. const dy = currentTouches[0].y - touchPoints[0].y;
  583. translateX.value += dx;
  584. translateY.value += dy;
  585. // 判断是否超过拖动阈值
  586. const totalDx = currentTouches[0].x - startTouchX;
  587. const totalDy = currentTouches[0].y - startTouchY;
  588. if (Math.abs(totalDx) > moveThreshold || Math.abs(totalDy) > moveThreshold) {
  589. hasMoved = true;
  590. }
  591. }
  592. // 限制平移范围
  593. constrainTranslate();
  594. // 绘制
  595. draw();
  596. // 触发移动事件
  597. emit("move", {
  598. translateX: translateX.value,
  599. translateY: translateY.value,
  600. scale: scale.value,
  601. screenTranslateX: translateX.value - leftPadding + (props.width * (scale.value - 1)) / 2
  602. });
  603. // 更新触摸点
  604. touchPoints = currentTouches;
  605. }
  606. // 触摸结束
  607. function onTouchEnd(e: UniTouchEvent) {
  608. const changedTouches = getTouches(e.changedTouches);
  609. // 单击选座(未发生拖动或缩放时才触发)
  610. if (changedTouches.length == 1 && touchPoints.length == 1 && !hasMoved) {
  611. const touch = changedTouches[0];
  612. const seat = getSeatAtPoint(touch.x, touch.y);
  613. if (seat != null && seat.disabled != true && seat.empty != true) {
  614. let value: ClSelectSeatValue[] = [];
  615. if (isSelected(seat.row, seat.col)) {
  616. value = props.modelValue.filter(
  617. (item) => !(item.row == seat.row && item.col == seat.col)
  618. );
  619. } else {
  620. value = [
  621. ...props.modelValue,
  622. { row: seat.row, col: seat.col } as ClSelectSeatValue
  623. ];
  624. }
  625. emit("update:modelValue", value);
  626. emit("seatClick", seat);
  627. }
  628. }
  629. touchPoints = getTouches(e.touches);
  630. // 所有手指抬起后重置状态
  631. if (touchPoints.length == 0) {
  632. hasMoved = false;
  633. }
  634. }
  635. // 监听选中变化
  636. watch(
  637. computed<ClSelectSeatValue[]>(() => props.modelValue),
  638. () => {
  639. draw();
  640. },
  641. { deep: true }
  642. );
  643. // 监听暗色模式变化
  644. watch(
  645. isDark,
  646. () => {
  647. draw();
  648. },
  649. { deep: true }
  650. );
  651. // 监听座位数据变化
  652. watch(
  653. computed<ClSelectSeatItem[]>(() => props.seats),
  654. () => {
  655. initSeats();
  656. draw();
  657. },
  658. { deep: true }
  659. );
  660. onMounted(() => {
  661. initCanvas();
  662. });
  663. defineExpose({
  664. setSeat,
  665. getSeats,
  666. draw
  667. });
  668. </script>
  669. <style lang="scss" scoped>
  670. .cl-select-seat {
  671. @apply relative;
  672. &__canvas {
  673. @apply h-full w-full;
  674. }
  675. &__index {
  676. @apply absolute top-0 left-1 z-10 rounded-xl;
  677. background-color: rgba(0, 0, 0, 0.4);
  678. &-item {
  679. @apply flex flex-col items-center justify-center;
  680. width: 14px;
  681. }
  682. }
  683. }
  684. </style>