MarkBoardTrack.vue 14 KB

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