CommonMarkBody.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. <template>
  2. <div
  3. class="mark-body-container tw-flex-auto tw-p-2 tw-relative"
  4. ref="dragContainer"
  5. >
  6. <div
  7. v-if="!store.currentTask"
  8. class="
  9. tw-text-center
  10. empty-task
  11. tw-flex tw-flex-col tw-place-items-center tw-justify-center
  12. "
  13. >
  14. <img src="./images/empty-task.png" />
  15. {{ store.message }}
  16. </div>
  17. <div
  18. v-else-if="store.setting.examType === 'SCAN_IMAGE'"
  19. :style="{ width: answerPaperScale }"
  20. :class="[`rotate-board-${rotateBoard}`]"
  21. >
  22. <div
  23. v-for="(item, index) in sliceImagesWithTrackList"
  24. :key="index"
  25. class="single-image-container"
  26. >
  27. <img
  28. :src="item.url"
  29. @click="(event) => innerMakeTrack(event, item)"
  30. draggable="false"
  31. @contextmenu="showBigImage"
  32. />
  33. <MarkDrawTrack
  34. :track-list="item.trackList"
  35. :special-tag-list="item.tagList"
  36. :slice-image-width="item.sliceImageWidth"
  37. :slice-image-height="item.sliceImageHeight"
  38. :dx="item.dx"
  39. :dy="item.dy"
  40. />
  41. <hr class="image-seperator" />
  42. </div>
  43. </div>
  44. <div v-else-if="store.setting.examType === 'MULTI_MEDIA'">
  45. <MultiMediaMarkBody />
  46. </div>
  47. <div v-else>impossible</div>
  48. <div v-if="markStatus" class="status-container">
  49. {{ markStatus }}
  50. <div class="double-triangle"></div>
  51. </div>
  52. <ZoomPaper v-if="isScanImage() && store.currentTask" :store="store" />
  53. </div>
  54. <slot name="slot-cursor" />
  55. </template>
  56. <script setup lang="ts">
  57. import {
  58. computed,
  59. onMounted,
  60. onUnmounted,
  61. reactive,
  62. ref,
  63. watch,
  64. watchEffect,
  65. } from "vue";
  66. import { getMarkStatus, isScanImage } from "./store";
  67. import MarkDrawTrack from "./MarkDrawTrack.vue";
  68. import type {
  69. MarkResult,
  70. MarkStore,
  71. SliceImage,
  72. SpecialTag,
  73. Track,
  74. } from "@/types";
  75. import { useTimers } from "@/setups/useTimers";
  76. import {
  77. getDataUrlForSliceConfig,
  78. getDataUrlForSplitConfig,
  79. loadImage,
  80. } from "@/utils/utils";
  81. import { dragImage } from "./use/draggable";
  82. import MultiMediaMarkBody from "./MultiMediaMarkBody.vue";
  83. import "viewerjs/dist/viewer.css";
  84. import Viewer from "viewerjs";
  85. import ZoomPaper from "@/components/ZoomPaper.vue";
  86. const props = defineProps<{
  87. useMarkResult?: boolean;
  88. makeTrack: Function;
  89. store: MarkStore; // 实际上不是同一个store!!! 最新的统一成一个相同的store了
  90. }>();
  91. const emit = defineEmits(["error"]);
  92. const { useMarkResult = false, makeTrack, store } = props;
  93. // start: 图片拖动。在轨迹模式下,仅当没有选择分数时可用。
  94. const { dragContainer } = dragImage();
  95. // end: 图片拖动
  96. const { addTimeout } = useTimers();
  97. let rotateBoard = $ref(0);
  98. // start: 缩略图定位
  99. watch(
  100. () => [store.minimapScrollToX, store.minimapScrollToY],
  101. () => {
  102. const container = document.querySelector(
  103. ".mark-body-container"
  104. ) as HTMLDivElement;
  105. addTimeout(() => {
  106. if (
  107. container &&
  108. typeof store.minimapScrollToX === "number" &&
  109. typeof store.minimapScrollToY === "number"
  110. ) {
  111. const { scrollWidth, scrollHeight } = container;
  112. container.scrollTo({
  113. top: scrollHeight * store.minimapScrollToY,
  114. left: scrollWidth * store.minimapScrollToX,
  115. behavior: "smooth",
  116. });
  117. }
  118. }, 10);
  119. }
  120. );
  121. // end: 缩略图定位
  122. // start: 快捷键定位
  123. const scrollContainerByKey = (e: KeyboardEvent) => {
  124. const container = document.querySelector(
  125. ".mark-body-container"
  126. ) as HTMLDivElement;
  127. if (e.key === "w") {
  128. container.scrollBy({ top: -100, behavior: "smooth" });
  129. } else if (e.key === "s") {
  130. container.scrollBy({ top: 100, behavior: "smooth" });
  131. } else if (e.key === "a") {
  132. container.scrollBy({ left: -100, behavior: "smooth" });
  133. } else if (e.key === "d") {
  134. container.scrollBy({ left: 100, behavior: "smooth" });
  135. }
  136. };
  137. onMounted(() => {
  138. document.addEventListener("keypress", scrollContainerByKey);
  139. });
  140. onUnmounted(() => {
  141. document.removeEventListener("keypress", scrollContainerByKey);
  142. });
  143. // end: 快捷键定位
  144. // start: 计算裁切图和裁切图上的分数轨迹和特殊标记轨迹
  145. let sliceImagesWithTrackList: Array<SliceImage> = reactive([]);
  146. let maxSliceWidth = 0; // 最大的裁切块宽度,图片容器以此为准
  147. let theFinalHeight = 0; // 最终宽度,用来定位轨迹在第几张图片,不包括image-seperator高度
  148. async function getImageUsingDataUrl(
  149. dataUrl: string
  150. ): Promise<HTMLImageElement> {
  151. return new Promise((resolve, reject) => {
  152. const image = new Image();
  153. image.src = dataUrl;
  154. image.onload = function () {
  155. resolve(image);
  156. };
  157. image.onerror = reject;
  158. });
  159. }
  160. async function processSliceConfig() {
  161. let markResult = store.currentTask?.markResult as MarkResult;
  162. if (useMarkResult) {
  163. // check if have MarkResult for currentTask
  164. if (!markResult) return;
  165. }
  166. if (!store.currentTask) return;
  167. const images = [];
  168. // 必须要先加载一遍,把“选择整图”的宽高重置后,再算总高度
  169. for (const sliceConfig of store.currentTask.sliceConfig) {
  170. const url = store.currentTask.sliceUrls[sliceConfig.i - 1];
  171. const image = await loadImage(url);
  172. images[sliceConfig.i] = image;
  173. if (sliceConfig.w === 0 && sliceConfig.h === 0) {
  174. // 选择整图时,w/h 为0
  175. sliceConfig.w = image.naturalWidth;
  176. sliceConfig.h = image.naturalHeight;
  177. }
  178. }
  179. theFinalHeight = store.currentTask.sliceConfig
  180. .map((v) => v.h)
  181. .reduce((acc, v) => (acc += v));
  182. maxSliceWidth = Math.max(...store.currentTask.sliceConfig.map((v) => v.w));
  183. // 用来保存sliceImage在整个图片容器中(不包括image-seperator)的高度范围
  184. let accumTopHeight = 0;
  185. let accumBottomHeight = 0;
  186. const tempSliceImagesWithTrackList = [] as Array<SliceImage>;
  187. for (const sliceConfig of store.currentTask.sliceConfig) {
  188. accumBottomHeight += sliceConfig.h;
  189. const url = store.currentTask.sliceUrls[sliceConfig.i - 1];
  190. const indexInSliceUrls = sliceConfig.i;
  191. const image = images[sliceConfig.i];
  192. const dataUrl = await getDataUrlForSliceConfig(
  193. image,
  194. sliceConfig,
  195. maxSliceWidth,
  196. url
  197. );
  198. let trackLists = [] as Array<Track>;
  199. if (useMarkResult) {
  200. trackLists = markResult.trackList;
  201. } else {
  202. trackLists = store.currentTask.questionList
  203. .map((q) => q.trackList)
  204. .reduce((acc, t) => {
  205. acc = acc.concat(t);
  206. return acc;
  207. }, [] as Array<Track>);
  208. }
  209. const thisImageTrackList = trackLists.filter(
  210. (t) => t.offsetIndex === indexInSliceUrls
  211. );
  212. let tagLists = [] as Array<SpecialTag>;
  213. if (useMarkResult) {
  214. tagLists = markResult.specialTagList ?? [];
  215. } else {
  216. tagLists = store.currentTask.specialTagList ?? [];
  217. }
  218. const thisImageTagList = tagLists.filter(
  219. (t) => t.offsetIndex === indexInSliceUrls
  220. );
  221. const sliceImageRendered = await getImageUsingDataUrl(dataUrl!);
  222. tempSliceImagesWithTrackList.push({
  223. url: dataUrl!,
  224. indexInSliceUrls: sliceConfig.i,
  225. // 通过positionY来定位是第几张slice的还原,并过滤出相应的track
  226. trackList: thisImageTrackList.filter(
  227. (t) =>
  228. t.positionY >= accumTopHeight / theFinalHeight &&
  229. t.positionY < accumBottomHeight / theFinalHeight
  230. ),
  231. tagList: thisImageTagList.filter(
  232. (t) =>
  233. t.positionY >= accumTopHeight / theFinalHeight &&
  234. t.positionY < accumBottomHeight / theFinalHeight
  235. ),
  236. // originalImageWidth: image.naturalWidth,
  237. // originalImageHeight: image.naturalHeight,
  238. sliceImageWidth: sliceImageRendered.naturalWidth,
  239. sliceImageHeight: sliceImageRendered.naturalHeight,
  240. dx: sliceConfig.x,
  241. dy: sliceConfig.y,
  242. accumTopHeight,
  243. effectiveWidth: sliceConfig.w,
  244. });
  245. accumTopHeight = accumBottomHeight;
  246. }
  247. // console.log("render: ", store.currentTask.secretNumber);
  248. rotateBoard = 1;
  249. setTimeout(() => {
  250. sliceImagesWithTrackList.splice(0);
  251. sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
  252. setTimeout(() => {
  253. rotateBoard = 0;
  254. }, 300);
  255. }, 300);
  256. }
  257. async function processSplitConfig() {
  258. let markResult = store.currentTask?.markResult as MarkResult;
  259. if (useMarkResult) {
  260. // check if have MarkResult for currentTask
  261. if (!markResult) return;
  262. }
  263. if (!store.currentTask) return;
  264. const images = [];
  265. for (const url of store.currentTask.sliceUrls) {
  266. const image = await loadImage(url);
  267. images.push(image);
  268. }
  269. // 裁切块,可能是一块,两块,三块... [start, width ...] => [0, 0.3] | [0, 0.55, 0.45, 0.55] | [0, 0.35, 0.33, 0.35, 0.66, 0.35]
  270. const splitConfigPairs = store.setting.splitConfig
  271. .map((v, index, ary) => (index % 2 === 0 ? [v, ary[index + 1]] : false))
  272. .filter((v) => v) as unknown as Array<[number, number]>;
  273. // 最大的 splitConfig 的宽度
  274. const maxSplitConfig = Math.max(
  275. ...store.setting.splitConfig.filter((v, i) => i % 2)
  276. );
  277. maxSliceWidth =
  278. Math.max(...images.map((v) => v.naturalWidth)) * maxSplitConfig;
  279. theFinalHeight =
  280. splitConfigPairs.length *
  281. images.reduce((acc, v) => (acc += v.naturalHeight), 0);
  282. // 高度比宽度大的图片不裁切
  283. const imagesOfBiggerHeight = images.filter(
  284. (v) => v.naturalHeight > v.naturalWidth
  285. );
  286. if (imagesOfBiggerHeight.length > 0) {
  287. maxSliceWidth = Math.max(
  288. maxSliceWidth,
  289. ...imagesOfBiggerHeight.map((v) => v.naturalWidth)
  290. );
  291. // 不裁切的图剪切多加的高度
  292. theFinalHeight -=
  293. imagesOfBiggerHeight.map((v) => v.naturalHeight).reduce((p, c) => p + c) *
  294. (splitConfigPairs.length - 1);
  295. }
  296. let accumTopHeight = 0;
  297. let accumBottomHeight = 0;
  298. const tempSliceImagesWithTrackList = [] as Array<SliceImage>;
  299. for (const url of store.currentTask.sliceUrls) {
  300. for (const config of splitConfigPairs) {
  301. const indexInSliceUrls = store.currentTask.sliceUrls.indexOf(url) + 1;
  302. const image = images[indexInSliceUrls - 1];
  303. let shouldBreak = false;
  304. if (image.naturalHeight > image.naturalWidth) {
  305. config[0] = 0;
  306. config[1] = 1;
  307. shouldBreak = true;
  308. }
  309. accumBottomHeight += image.naturalHeight;
  310. const dataUrl = await getDataUrlForSplitConfig(
  311. image,
  312. config,
  313. maxSliceWidth,
  314. url
  315. );
  316. let trackLists = [] as Array<Track>;
  317. if (useMarkResult) {
  318. trackLists = markResult.trackList;
  319. } else {
  320. // 成绩查询 questionList 可能为空
  321. trackLists = (store.currentTask.questionList || [])
  322. .map((q) => q.trackList)
  323. .reduce((acc, t) => {
  324. acc = acc.concat(t);
  325. return acc;
  326. }, [] as Array<Track>);
  327. }
  328. const thisImageTrackList = trackLists.filter(
  329. (t) => t.offsetIndex === indexInSliceUrls
  330. );
  331. let tagLists = [] as Array<SpecialTag>;
  332. if (useMarkResult) {
  333. tagLists = markResult.specialTagList ?? [];
  334. } else {
  335. tagLists = store.currentTask.specialTagList ?? [];
  336. }
  337. const thisImageTagList = tagLists.filter(
  338. (t) => t.offsetIndex === indexInSliceUrls
  339. );
  340. const sliceImageRendered = await getImageUsingDataUrl(dataUrl!);
  341. tempSliceImagesWithTrackList.push({
  342. url: dataUrl!,
  343. indexInSliceUrls: store.currentTask.sliceUrls.indexOf(url) + 1,
  344. trackList: thisImageTrackList.filter(
  345. (t) =>
  346. t.positionY >= accumTopHeight / theFinalHeight &&
  347. t.positionY < accumBottomHeight / theFinalHeight
  348. ),
  349. tagList: thisImageTagList.filter(
  350. (t) =>
  351. t.positionY >= accumTopHeight / theFinalHeight &&
  352. t.positionY < accumBottomHeight / theFinalHeight
  353. ),
  354. // originalImageWidth: image.naturalWidth,
  355. // originalImageHeight: image.naturalHeight,
  356. sliceImageWidth: sliceImageRendered.naturalWidth,
  357. sliceImageHeight: sliceImageRendered.naturalHeight,
  358. dx: image.naturalWidth * config[0],
  359. dy: 0,
  360. accumTopHeight,
  361. effectiveWidth: image.naturalWidth * config[1],
  362. });
  363. accumTopHeight = accumBottomHeight;
  364. // 如果本图高比宽大,不该裁切,则跳过多次裁切
  365. if (shouldBreak) {
  366. break;
  367. }
  368. }
  369. }
  370. rotateBoard = 1;
  371. addTimeout(() => {
  372. sliceImagesWithTrackList.splice(0);
  373. sliceImagesWithTrackList.push(...tempSliceImagesWithTrackList);
  374. addTimeout(() => {
  375. rotateBoard = 0;
  376. }, 300);
  377. }, 300);
  378. }
  379. // should not render twice at the same time
  380. let renderLock = false;
  381. const renderPaperAndMark = async () => {
  382. if (!store) return;
  383. if (!isScanImage()) return;
  384. if (renderLock) {
  385. console.log("上个任务还未渲染完毕,稍等一秒再尝试渲染");
  386. await new Promise((res) => setTimeout(res, 1000));
  387. await renderPaperAndMark();
  388. return;
  389. }
  390. renderLock = true;
  391. // for (const s of sliceImagesWithTrackList) {
  392. // // console.log("revoke", s.url);
  393. // URL.revokeObjectURL(s.url);
  394. // }
  395. // sliceImagesWithTrackList.splice(0);
  396. // check if have MarkResult for currentTask
  397. let markResult = store.currentTask?.markResult;
  398. if ((useMarkResult && !markResult) || !store.currentTask) {
  399. renderLock = false;
  400. return;
  401. }
  402. try {
  403. store.globalMask = true;
  404. const hasSliceConfig = store.currentTask?.sliceConfig?.length;
  405. if (hasSliceConfig) {
  406. await processSliceConfig();
  407. } else {
  408. await processSplitConfig();
  409. }
  410. await new Promise((res) => setTimeout(res, 700));
  411. const container = <HTMLDivElement>(
  412. document.querySelector(".mark-body-container")
  413. );
  414. addTimeout(() => {
  415. container?.scrollTo({
  416. top: 0,
  417. left: 0,
  418. behavior: "smooth",
  419. });
  420. }, 10);
  421. } catch (error) {
  422. sliceImagesWithTrackList.splice(0);
  423. console.trace("render error ", error);
  424. // 图片加载出错,自动加载下一个任务
  425. emit("error");
  426. } finally {
  427. renderLock = false;
  428. store.globalMask = false;
  429. }
  430. };
  431. // watchEffect(renderPaperAndMark);
  432. // 在阻止渲染的情况下,watchEffect收集不到 store.currentTask 的依赖,会导致本组件不再更新
  433. watch(
  434. () => store.currentTask,
  435. () => renderPaperAndMark()
  436. );
  437. // end: 计算裁切图和裁切图上的分数轨迹和特殊标记轨迹
  438. // start: 放大缩小和之后的滚动
  439. const answerPaperScale = computed(() => {
  440. // 放大、缩小不影响页面之前的滚动条定位
  441. let percentWidth = 0;
  442. let percentTop = 0;
  443. const container = document.querySelector(
  444. ".mark-body-container"
  445. ) as HTMLDivElement;
  446. if (container) {
  447. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  448. percentWidth = scrollLeft / scrollWidth;
  449. percentTop = scrollTop / scrollHeight;
  450. }
  451. addTimeout(() => {
  452. if (container) {
  453. const { scrollWidth, scrollHeight } = container;
  454. container.scrollTo({
  455. left: scrollWidth * percentWidth,
  456. top: scrollHeight * percentTop,
  457. });
  458. }
  459. }, 10);
  460. const scale = store.setting.uiSetting["answer.paper.scale"];
  461. return scale * 100 + "%";
  462. });
  463. // end: 放大缩小和之后的滚动
  464. // start: 显示评分状态和清除轨迹
  465. let markStatus = $ref("");
  466. if (useMarkResult) {
  467. watch(
  468. () => store.currentTask,
  469. () => {
  470. markStatus = getMarkStatus();
  471. }
  472. );
  473. // 清除分数轨迹
  474. watchEffect(() => {
  475. for (const track of store.removeScoreTracks) {
  476. for (const sliceImage of sliceImagesWithTrackList) {
  477. sliceImage.trackList = sliceImage.trackList.filter(
  478. (t) =>
  479. !(
  480. t.mainNumber === track.mainNumber &&
  481. t.subNumber === track.subNumber &&
  482. t.number === track.number
  483. )
  484. );
  485. }
  486. }
  487. // 清除后,删除,否则会影响下次切换
  488. store.removeScoreTracks.splice(0);
  489. });
  490. // 清除特殊标记轨迹
  491. watchEffect(() => {
  492. for (const sliceImage of sliceImagesWithTrackList) {
  493. sliceImage.tagList = sliceImage.tagList.filter((t) =>
  494. store.currentTask?.markResult.specialTagList.find(
  495. (st) =>
  496. st.offsetIndex === t.offsetIndex &&
  497. st.offsetX === t.offsetX &&
  498. st.offsetY === t.offsetY
  499. )
  500. );
  501. }
  502. if (store.currentTask?.markResult.specialTagList.length === 0) {
  503. for (const sliceImage of sliceImagesWithTrackList) {
  504. sliceImage.tagList = [];
  505. }
  506. }
  507. });
  508. }
  509. // end: 显示评分状态和清除轨迹
  510. // start: 评分
  511. const innerMakeTrack = (event: MouseEvent, item: SliceImage) => {
  512. makeTrack && makeTrack(event, item, maxSliceWidth, theFinalHeight);
  513. };
  514. // end: 评分
  515. // start: 显示大图,供查看和翻转
  516. const showBigImage = (event: MouseEvent) => {
  517. event.preventDefault();
  518. // console.log(event);
  519. let viewer: Viewer = null as unknown as Viewer;
  520. viewer && viewer.destroy();
  521. viewer = new Viewer(event.target as HTMLElement, {
  522. // inline: true,
  523. viewed() {
  524. viewer.zoomTo(1);
  525. },
  526. hidden() {
  527. viewer.destroy();
  528. },
  529. zIndex: 1000000,
  530. });
  531. viewer.show();
  532. };
  533. // end: 显示大图,供查看和翻转
  534. // onRenderTriggered(({ key, target, type }) => {
  535. // console.log({ key, target, type });
  536. // });
  537. </script>
  538. <style scoped>
  539. .mark-body-container {
  540. position: relative;
  541. min-height: calc(100vh - 56px);
  542. height: calc(100vh - 56px);
  543. overflow: auto;
  544. /* background-size: 8px 8px;
  545. background-image: linear-gradient(to right, #e7e7e7 4px, transparent 4px),
  546. linear-gradient(to bottom, transparent 4px, #e7e7e7 4px); */
  547. background-color: var(--app-container-bg-color);
  548. background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
  549. linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
  550. linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
  551. linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
  552. background-size: 20px 20px;
  553. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  554. transform: inherit;
  555. }
  556. .mark-body-container img {
  557. width: 100%;
  558. }
  559. .empty-task {
  560. width: calc(100%);
  561. height: calc(100%);
  562. font-size: 20px;
  563. overflow: hidden;
  564. background-color: var(--app-container-bg-color);
  565. }
  566. .empty-task img {
  567. display: block;
  568. width: 288px;
  569. height: 225px;
  570. clip-path: polygon(0 0, 0 80%, 100% 80%, 100% 0);
  571. }
  572. .single-image-container {
  573. position: relative;
  574. }
  575. .image-seperator {
  576. border: 2px solid transparent;
  577. }
  578. .status-container {
  579. position: fixed;
  580. top: 56px;
  581. right: 340px;
  582. color: white;
  583. pointer-events: none;
  584. font-size: var(--app-title-font-size);
  585. background-color: #ef7c78;
  586. width: 30px;
  587. height: 50px;
  588. text-align: center;
  589. z-index: 1000;
  590. }
  591. .double-triangle {
  592. background-color: #ef7c78;
  593. width: 30px;
  594. height: 6px;
  595. clip-path: polygon(0 0, 0 6px, 50% 0, 100% 0, 100% 6px, 50% 0);
  596. position: absolute;
  597. bottom: -5px;
  598. }
  599. @keyframes rotate {
  600. 0% {
  601. transform: rotateY(0deg);
  602. opacity: 1;
  603. }
  604. 50% {
  605. transform: rotateY(90deg);
  606. }
  607. 100% {
  608. transform: rotateY(0deg);
  609. opacity: 0;
  610. }
  611. }
  612. .rotate-board-1 {
  613. animation: rotate 0.6s ease-in-out;
  614. }
  615. </style>