MarkBoardTrack.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. <template>
  2. <div
  3. v-if="store.currentTask"
  4. class="mark-board-track-container tw-flex tw-flex-col"
  5. :class="[
  6. {
  7. hide: store.isScoreBoardCollapsed && !props.modal,
  8. 'mark-board-track-container-in-dialog':
  9. store.isScoreBoardCollapsed && !props.modal,
  10. show: !store.isScoreBoardCollapsed,
  11. },
  12. ]"
  13. :style="{
  14. height: props.modal ? '100%' : 'auto',
  15. }"
  16. >
  17. <div
  18. class="tw-flex tw-rounded tw-justify-between tw-p-2 tw-pl-5 top-container tw-mb-4"
  19. >
  20. <div class="tw-flex tw-flex-col">
  21. <div class="tw-flex tw-items-center tw-gap-2">
  22. <img
  23. src="./images/totalscore.png"
  24. style="width: 13px; height: 16px"
  25. />
  26. 总分
  27. </div>
  28. <div class="total-score tw-ml-5 tw-font-bold" style="height: 50px">
  29. <transition-group name="score-number-animation" tag="span">
  30. <span
  31. :key="store.currentTask.markResult.markerScore || 0"
  32. class="tw-inline-block"
  33. >{{ store.currentTask.markResult.markerScore }}</span
  34. >
  35. </transition-group>
  36. </div>
  37. </div>
  38. <div class="tw-flex tw-place-content-center tw-items-center tw-gap-2">
  39. <div class="tw-flex tw-flex-col tw-gap-1">
  40. <a-popconfirm
  41. v-if="store.setting.enableAllZero && !store.setting.forceSpecialTag"
  42. title="确定给全零分?"
  43. :overlayStyle="{ width: '200px' }"
  44. @confirm="$emit('allZeroSubmit')"
  45. >
  46. <a-button
  47. type="primary"
  48. size="middle"
  49. class="all-zero-unselective-button"
  50. >
  51. <span>全零分</span>
  52. </a-button>
  53. </a-popconfirm>
  54. <a-popconfirm
  55. v-if="store.setting.selective"
  56. title="确定是未选做?"
  57. :overlayStyle="{ width: '200px' }"
  58. @confirm="$emit('unselectiveSubmit')"
  59. >
  60. <a-button
  61. type="primary"
  62. size="middle"
  63. class="all-zero-unselective-button"
  64. >
  65. <span>未选做</span>
  66. </a-button>
  67. </a-popconfirm>
  68. </div>
  69. <qm-button
  70. type="primary"
  71. shape="round"
  72. size="middle"
  73. style="height: 76px; border-radius: 10px; padding: 12px"
  74. @click="submit"
  75. >
  76. 提交
  77. </qm-button>
  78. </div>
  79. </div>
  80. <div
  81. style="
  82. height: calc(100% - 56px);
  83. overflow: hidden;
  84. user-select: none;
  85. position: relative;
  86. "
  87. >
  88. <div
  89. v-if="store.currentTask && store.currentTask.questionList"
  90. class="tw-flex tw-gap-2 tw-flex-wrap tw-justify-between tw-overflow-auto tw-content-start"
  91. :style="{ height: `${topPercent}%` }"
  92. >
  93. <template
  94. v-for="(question, index) in store.currentTask.questionList"
  95. :key="index"
  96. >
  97. <!-- <div
  98. :id="
  99. store.isScoreBoardCollapsed
  100. ? props.modal
  101. ? ['bq', question.mainNumber, question.subNumber].join('-')
  102. : ''
  103. : ['bq', question.mainNumber, question.subNumber].join('-')
  104. "
  105. class="question tw-rounded tw-cursor-pointer tw-relative tw-mb-2"
  106. :class="isCurrentQuestion(question) && 'current-question'"
  107. @click="chooseQuestion(question)"
  108. @mouseover="
  109. addFocusTrack(
  110. undefined,
  111. question.mainNumber,
  112. question.subNumber,
  113. true
  114. )
  115. "
  116. @mouseleave="removeFocusTrack"
  117. > -->
  118. <div
  119. :id="
  120. store.isScoreBoardCollapsed
  121. ? props.modal
  122. ? ['bq', question.mainNumber, question.subNumber].join('-')
  123. : ''
  124. : ['bq', question.mainNumber, question.subNumber].join('-')
  125. "
  126. class="question tw-rounded tw-cursor-pointer tw-relative tw-mb-2"
  127. :class="isCurrentQuestion(question) && 'current-question'"
  128. tabindex="0"
  129. outline="0"
  130. hidefocus="true"
  131. @click="chooseQuestion(question)"
  132. @contextmenu="onRightClick($event, index)"
  133. @blur="rightBlur"
  134. >
  135. <div
  136. v-if="activeRightMenuItem"
  137. class="tw-fixed right-menu-box"
  138. :style="tmpStyle"
  139. >
  140. <div class="right-menu-item" @click="positioning(question)">
  141. 定位
  142. </div>
  143. </div>
  144. <div v-if="!!question.questionName" @click="positioning(question)">
  145. {{ question.questionName }}
  146. </div>
  147. <div v-else @click="positioning(question)">
  148. {{ question.title }} {{ question.mainNumber }}-{{
  149. question.subNumber
  150. }}
  151. </div>
  152. <!-- 设置高度 避免动画跳动 -->
  153. <div style="height: 32px">
  154. <transition-group name="score-number-animation" tag="span">
  155. <span
  156. :key="store.currentTask?.markResult.scoreList[index] || 0"
  157. class="tw-font-medium tw-text-2xl score tw-inline-block"
  158. >
  159. <!-- 特殊的空格符号 -->
  160. <!-- eslint-disable-next-line no-irregular-whitespace -->
  161. {{ store.currentTask?.markResult.scoreList[index] ?? " " }}
  162. </span>
  163. </transition-group>
  164. </div>
  165. </div>
  166. </template>
  167. </div>
  168. <div
  169. ref="dragSpliter"
  170. style="
  171. width: 100%;
  172. height: 4px;
  173. border: 2px solid grey;
  174. background-color: grey;
  175. cursor: row-resize;
  176. "
  177. class="split-pane tw-flex tw-justify-evenly"
  178. >
  179. <div
  180. style="
  181. margin-top: -14px;
  182. width: 20px;
  183. height: 16px;
  184. text-align: center;
  185. clip-path: polygon(0 100%, 100% 100%, 50% 0);
  186. background-color: lightskyblue;
  187. cursor: pointer;
  188. "
  189. @click="topPercent = 20"
  190. ></div>
  191. <div
  192. style="
  193. margin-top: -3px;
  194. width: 20px;
  195. height: 16px;
  196. text-align: center;
  197. clip-path: polygon(0 0, 100% 0, 50% 100%);
  198. background-color: lightskyblue;
  199. cursor: pointer;
  200. "
  201. @click="topPercent = 90"
  202. ></div>
  203. </div>
  204. <div
  205. class="tw-flex tw-flex-wrap tw-mt-5 tw-overflow-auto tw-content-start"
  206. style="padding-bottom: 40px; gap: 8px"
  207. :style="{ height: `${100 - topPercent}%` }"
  208. >
  209. <div
  210. v-for="(s, i) in questionScoreSteps.slice(1)"
  211. :key="i"
  212. class="single-score tw-cursor-pointer tw-font-bold"
  213. :class="isCurrentScore(s) && 'current-score'"
  214. @click="chooseScore(s)"
  215. >
  216. {{ s }}
  217. </div>
  218. <div
  219. class="single-score tw-cursor-pointer tw-font-bold"
  220. :class="Object.is(store.currentScore, 0) && 'current-score'"
  221. @click="chooseScore(0)"
  222. >
  223. 0
  224. </div>
  225. <div
  226. class="single-score tw-cursor-pointer tw-font-bold"
  227. :class="Object.is(store.currentScore, -0) && 'current-score'"
  228. title="按 # 可以选中"
  229. @click="chooseScore(-0)"
  230. >
  231. </div>
  232. </div>
  233. </div>
  234. <div
  235. class="tw-flex tw-justify-between tw-mt-4"
  236. style="position: relative; bottom: 0px; right: 0px; width: 230px"
  237. >
  238. <qm-button
  239. type="primary"
  240. shape="round"
  241. size="large"
  242. style="
  243. background-color: var(--app-undo-button-bg-color);
  244. border-color: var(--app-undo-button-bg-color);
  245. "
  246. :clickTimeout="300"
  247. @click="clearLatestMarkOfCurrentQuetion"
  248. >
  249. 回退
  250. </qm-button>
  251. <qm-button
  252. type="primary"
  253. shape="round"
  254. size="large"
  255. :clickTimeout="300"
  256. data-test="clear-score"
  257. @click="clearAllMarksOfCurrentQuetion"
  258. >
  259. 清除本题
  260. </qm-button>
  261. </div>
  262. </div>
  263. </template>
  264. <script setup lang="ts">
  265. import type { Question } from "@/types";
  266. import { isNumber } from "lodash-es";
  267. import { onMounted, onUnmounted, watch, ref, reactive } from "vue";
  268. import { store } from "@/store/store";
  269. import { autoChooseFirstQuestion } from "./use/autoChooseFirstQuestion";
  270. import { dragSplitPane } from "./use/splitPane";
  271. import { addFocusTrack, removeFocusTrack } from "./use/focusTracks";
  272. import EventBus from "@/plugins/eventBus";
  273. import { cloneDeep } from "lodash-es";
  274. const props = defineProps<{ modal?: boolean }>();
  275. const emit = defineEmits(["submit", "allZeroSubmit", "unselectiveSubmit"]);
  276. const { dragSpliter, topPercent } = dragSplitPane();
  277. const activeRightMenuItem = ref<any>(null);
  278. const tmpStyle = reactive<any>({
  279. left: 0,
  280. top: 0,
  281. zIndex: 300,
  282. });
  283. const onRightClick = (e: any, index?: any) => {
  284. e.preventDefault();
  285. if (
  286. store.currentTask?.markResult.scoreList[index] ||
  287. store.currentTask?.markResult.scoreList[index] === 0
  288. ) {
  289. tmpStyle.left = e.clientX + "px";
  290. tmpStyle.top = e.clientY + "px";
  291. activeRightMenuItem.value = e;
  292. }
  293. };
  294. const rightBlur = () => {
  295. activeRightMenuItem.value = null;
  296. removeFocusTrack();
  297. };
  298. const positioning = (question: Question) => {
  299. let list =
  300. store.getMarkStatus === "正评" || store.getMarkStatus === "试评"
  301. ? sliceImagesWithTrackListCopy.value
  302. : undefined;
  303. addFocusTrack(undefined, question.mainNumber, question.subNumber, true, list);
  304. activeRightMenuItem.value = null;
  305. };
  306. watch(topPercent, () => {
  307. if (topPercent.value < 10) {
  308. topPercent.value = 10;
  309. }
  310. if (topPercent.value > 90) {
  311. topPercent.value = 90;
  312. }
  313. });
  314. const { chooseQuestion } = autoChooseFirstQuestion();
  315. let sliceImagesWithTrackListCopy = ref([]);
  316. EventBus.on("draw-change", (list: any) => {
  317. if (store.getMarkStatus === "正评" || store.getMarkStatus === "试评") {
  318. sliceImagesWithTrackListCopy.value = cloneDeep(list);
  319. } else {
  320. sliceImagesWithTrackListCopy.value = [];
  321. }
  322. });
  323. // 切换题目是清空上一题的分数
  324. watch(
  325. () => store.currentQuestion,
  326. () => (store.currentScore = undefined)
  327. );
  328. const questionScore = $computed(
  329. () =>
  330. store.currentTask &&
  331. store.currentQuestion &&
  332. store.currentTask.markResult.scoreList[store.currentQuestion.__index]
  333. );
  334. const questionScoreSteps = $computed(() => {
  335. const question = store.currentQuestion;
  336. if (!question) return [];
  337. const remainScore =
  338. Math.round(question.maxScore * 100 - (questionScore || 0) * 100) / 100;
  339. const steps = [];
  340. if (question.intervalScore <= 0) {
  341. console.warn(`question.intervalScore got: ${question.intervalScore}`);
  342. }
  343. for (
  344. let i = 0;
  345. i <= remainScore && question.intervalScore > 0;
  346. i = Math.round(i * 100 + question.intervalScore * 100) / 100
  347. ) {
  348. steps.push(i);
  349. }
  350. if (
  351. Math.round(remainScore * 100) % Math.round(question.intervalScore * 100) !==
  352. 0
  353. ) {
  354. steps.push(remainScore);
  355. }
  356. return steps;
  357. });
  358. function isCurrentQuestion(question: Question) {
  359. return (
  360. store.currentQuestion?.mainNumber === question.mainNumber &&
  361. store.currentQuestion?.subNumber === question.subNumber
  362. );
  363. }
  364. function isCurrentScore(score: number) {
  365. return store.currentScore === score;
  366. }
  367. function chooseScore(score: number) {
  368. if (store.currentScore === score) {
  369. store.currentScore = undefined;
  370. } else {
  371. store.currentScore = score;
  372. store.currentSpecialTag = undefined;
  373. }
  374. }
  375. let keyPressTimestamp = 0;
  376. let keys: string[] = [];
  377. function numberKeyListener(event: KeyboardEvent) {
  378. if (!store.currentQuestion) return;
  379. if (" jiklc".includes(event.key)) return;
  380. if (event.key === "#") {
  381. keys = [];
  382. store.currentScore = -0;
  383. return;
  384. }
  385. function indexOfCurrentQuestion() {
  386. return (
  387. store.currentTask?.questionList.findIndex(
  388. (q) =>
  389. q.mainNumber === store.currentQuestion?.mainNumber &&
  390. q.subNumber === store.currentQuestion.subNumber
  391. ) ?? -1
  392. );
  393. }
  394. // tab 循环答题列表
  395. if (event.key === "Tab") {
  396. const idx = indexOfCurrentQuestion();
  397. if (idx >= 0 && store.currentTask) {
  398. const len = store.currentTask.questionList.length;
  399. chooseQuestion(store.currentTask.questionList[(idx + 1) % len]);
  400. event.preventDefault();
  401. }
  402. return;
  403. }
  404. // 为了cypress可以加速时间
  405. if (Date.now() - keyPressTimestamp > 1 * 1000) {
  406. keys = [];
  407. }
  408. keyPressTimestamp = Date.now();
  409. keys.push(event.key);
  410. if (isNaN(parseFloat(keys.join("")))) {
  411. keys = [];
  412. }
  413. if (event.key === "Escape") {
  414. keys = [];
  415. store.currentScore = undefined;
  416. store.currentSpecialTag = undefined;
  417. return;
  418. }
  419. const score = parseFloat(keys.join(""));
  420. if (isNumber(score) && questionScoreSteps.includes(score)) {
  421. chooseScore(score);
  422. }
  423. }
  424. function submitListener(e: KeyboardEvent) {
  425. // if (import.meta.env.DEV && e.ctrlKey && e.key === "Enter") {
  426. if (e.ctrlKey && e.key === "Enter") {
  427. submit();
  428. }
  429. }
  430. onMounted(() => {
  431. document.addEventListener("keydown", numberKeyListener);
  432. document.addEventListener("keydown", submitListener);
  433. });
  434. onUnmounted(() => {
  435. document.removeEventListener("keydown", numberKeyListener);
  436. document.removeEventListener("keydown", submitListener);
  437. });
  438. watch(
  439. () => store.isScoreBoardCollapsed,
  440. () => {
  441. // 此处的逻辑是 MarkBoardTrackDialog 带来的,不然 numberKeyListener 在两个组件中多次触发有问题
  442. if (store.isScoreBoardCollapsed) {
  443. document.removeEventListener("keydown", numberKeyListener);
  444. document.removeEventListener("keydown", submitListener);
  445. } else {
  446. // 重复添加相同的function是没问题,不会重复触发
  447. document.addEventListener("keydown", numberKeyListener);
  448. document.addEventListener("keydown", submitListener);
  449. }
  450. },
  451. { immediate: true }
  452. );
  453. function clearLatestMarkOfCurrentQuetion() {
  454. if (!store.currentTask?.markResult || !store.currentQuestion) return;
  455. const { __index, mainNumber, subNumber } = store.currentQuestion;
  456. const markResult = store.currentTask.markResult;
  457. const ts = markResult.trackList.filter(
  458. (q) => q.mainNumber === mainNumber && q.subNumber === subNumber
  459. );
  460. if (ts.length === 0) {
  461. return;
  462. }
  463. const lastMark = ts.splice(-1)[0];
  464. store.removeScoreTracks = [lastMark];
  465. markResult.trackList = markResult.trackList.filter((t) => t !== lastMark);
  466. markResult.scoreList[__index] =
  467. ts.length === 0
  468. ? null
  469. : ts
  470. .map((t) => t.score)
  471. .reduce((acc, v) => (acc += Math.round(v * 100)), 0) / 100;
  472. }
  473. function clearAllMarksOfCurrentQuetion() {
  474. if (!store.currentTask?.markResult || !store.currentQuestion) return;
  475. const markResult = store.currentTask.markResult;
  476. store.removeScoreTracks = markResult.trackList.filter(
  477. (q) =>
  478. q.mainNumber === store.currentQuestion?.mainNumber &&
  479. q.subNumber === store.currentQuestion?.subNumber
  480. );
  481. markResult.trackList = markResult.trackList.filter(
  482. (q) => !store.removeScoreTracks.includes(q)
  483. );
  484. const { __index } = store.currentQuestion;
  485. markResult.scoreList[__index] = null;
  486. }
  487. function submit() {
  488. emit("submit");
  489. }
  490. const buttonHeightForSelective = $computed(() =>
  491. store.setting.selective &&
  492. store.setting.enableAllZero &&
  493. !store.setting.forceSpecialTag
  494. ? "36px"
  495. : "76px"
  496. );
  497. </script>
  498. <style lang="less" scoped>
  499. .right-menu-box {
  500. z-index: 10;
  501. background-color: #fff;
  502. border-radius: 4px;
  503. width: 80px;
  504. padding: 2px 0;
  505. box-shadow: 0 0 3px #ddd;
  506. .right-menu-item {
  507. height: 30px;
  508. line-height: 30px;
  509. font-size: 14px;
  510. padding: 0 10px;
  511. color: #333;
  512. &:hover {
  513. background: var(--app-main-bg-color);
  514. }
  515. }
  516. }
  517. .mark-board-track-container {
  518. max-width: 290px;
  519. min-width: 290px;
  520. padding: 20px;
  521. max-height: calc(100vh - 56px - 0px);
  522. overflow: auto;
  523. z-index: 1001;
  524. transition: margin-right 0.5s;
  525. color: var(--app-small-header-text-color);
  526. background-color: var(--app-main-bg-color);
  527. }
  528. .mark-board-track-container-in-dialog {
  529. max-width: 100%;
  530. min-width: 100%;
  531. height: 100%;
  532. }
  533. .mark-board-track-container.show {
  534. margin-right: 0;
  535. }
  536. .mark-board-track-container.hide {
  537. margin-right: -100%;
  538. }
  539. .top-container {
  540. background-color: var(--app-container-bg-color);
  541. }
  542. .total-score {
  543. color: var(--app-main-text-color);
  544. font-size: 32px;
  545. }
  546. .question {
  547. min-width: 110px;
  548. max-width: 110px;
  549. min-height: 72px;
  550. padding: 10px;
  551. background-color: var(--app-container-bg-color);
  552. position: relative;
  553. }
  554. .current-question {
  555. color: white;
  556. background-color: var(--app-score-color);
  557. }
  558. .single-score {
  559. position: relative;
  560. width: 32px;
  561. height: 32px;
  562. font-size: var(--app-secondary-font-size);
  563. display: grid;
  564. place-content: center;
  565. background-color: var(--app-container-bg-color);
  566. border-radius: 30px;
  567. }
  568. .current-score {
  569. background-color: var(--app-score-color);
  570. color: white;
  571. }
  572. .all-zero-unselective-button {
  573. height: v-bind(buttonHeightForSelective);
  574. border-radius: 10px;
  575. padding: 7px;
  576. background-color: #4db9ff;
  577. border: none;
  578. }
  579. </style>