cl-select-seat.uvue 16 KB

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