ScoringPanelItem.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. <template>
  2. <div class="flex scoring-panel" :class="[getClass('modal-panel', 'sticky'), { mini: !props.large && !props.modal }]">
  3. <toggle-dialog-render v-if="isMarkerPage">
  4. <svg-icon name="question"></svg-icon>
  5. <span class="m-l-mini" style="max-width: 200px"
  6. >{{ question.mainNumber }} - {{ question.subNumber }}{{ question.mainTitle }}</span
  7. >
  8. <!-- <template #callback>
  9. <div class="grid radius-base dialog-name">
  10. <div class="text-center">
  11. <p>{{ question.mainNumber }} - {{ question.subNumber }}</p>
  12. <p>{{ question.mainTitle }}</p>
  13. </div>
  14. </div>
  15. </template> -->
  16. </toggle-dialog-render>
  17. <div class="flex flex-1 flex-wrap score-list">
  18. <div
  19. v-for="scoreItem in scoreList"
  20. :key="scoreItem"
  21. class="score-span"
  22. :class="[
  23. getClass('m-b-mini'),
  24. { active: parseFloat(`${currentScore}`) === parseFloat(`${scoreItem}`), mini: !props.large && !props.modal },
  25. ]"
  26. @click="onSetScore(scoreItem)"
  27. >
  28. {{ scoreItem }}
  29. </div>
  30. <toggle-dialog-render>
  31. <div class="flex items-center score-result" :class="{ 'score-valid-fail': scoreValidFail }">
  32. <div class="flex-1 text-center">给分</div>
  33. <input
  34. ref="refInput1"
  35. class="flex-1 text-center score-input score-num"
  36. :value="currentScore"
  37. @focus="onInputFocus"
  38. @blur="onBlur"
  39. @keydown="onValidScoreDebounce"
  40. @input="scoreChange"
  41. />
  42. </div>
  43. </toggle-dialog-render>
  44. </div>
  45. <toggle-dialog-render dialog>
  46. <div class="grid radius-base dialog-score" :class="{ 'score-valid-fail': scoreValidFail }">
  47. <input
  48. ref="refInput2"
  49. class="score-input"
  50. :value="currentScore"
  51. @focus="onInputFocus"
  52. @blur="onBlur"
  53. @keydown="onValidScoreDebounce"
  54. @input="scoreChange"
  55. />
  56. </div>
  57. </toggle-dialog-render>
  58. <toggle-dialog-render>
  59. <!-- <svg-icon
  60. class="pointer toggle-icon"
  61. :class="{ visible: props.toggleModal }"
  62. name="toggle-panel"
  63. @click="onToggleClick"
  64. ></svg-icon> -->
  65. <div class="toggle-icon-box" :class="{ visible: props.toggleModal }" @click="onToggleClick">
  66. <img src="../../assets/images/tanchu.png" />
  67. <p>弹出</p>
  68. </div>
  69. </toggle-dialog-render>
  70. </div>
  71. </template>
  72. <script setup lang="ts" name="ScoringPanelItem">
  73. import { watch, computed, ref, nextTick, withDefaults, defineComponent, useSlots } from 'vue'
  74. import SvgIcon from '@/components/common/SvgIcon.vue'
  75. import useVModel from '@/hooks/useVModel'
  76. import { getNumbers } from '@/utils/common'
  77. import { debounce } from 'lodash-es'
  78. import { useRoute } from 'vue-router'
  79. const route = useRoute()
  80. const isMarkerPage = computed(() => {
  81. return route.path === '/marking/mark'
  82. })
  83. interface QuestionInfo {
  84. mainNumber: number
  85. mainTitle: string
  86. subNumber: number
  87. totalScore: number
  88. intervalScore: number
  89. }
  90. const props = withDefaults(
  91. defineProps<{
  92. /** 弹窗模式? */
  93. modal?: boolean
  94. /** 是否可以切换显示模式 */
  95. toggleModal?: boolean
  96. /** 分值 */
  97. score?: number | string | undefined
  98. /** active 当前正在评分 */
  99. active: boolean
  100. scoreValidFail: boolean | undefined
  101. question: QuestionInfo
  102. large?: boolean
  103. }>(),
  104. { modal: false, toggleModal: true, score: void 0, scoreValidFail: false, large: true }
  105. )
  106. const emit = defineEmits(['focused', 'blur', 'toggle-click', 'enter', 'update:score'])
  107. const dialogMode = ref<boolean>(props.modal)
  108. const currentScore = useVModel(props, 'score')
  109. const scoreValidFail = useVModel(props, 'scoreValidFail')
  110. const ToggleDialogRender = defineComponent({
  111. name: 'DialogHideNode',
  112. inheritAttrs: false,
  113. props: {
  114. dialog: {
  115. type: Boolean,
  116. default: false,
  117. },
  118. },
  119. render() {
  120. const slots = useSlots()
  121. const defaultSlot = slots?.default?.()
  122. const callBackSlot = slots?.callback?.()
  123. return (this.dialog ? dialogMode.value : !dialogMode.value) ? defaultSlot : callBackSlot
  124. },
  125. })
  126. const question = computed(() => {
  127. return props.question || ({} as QuestionInfo)
  128. })
  129. const scoreList = computed<number[]>(() => {
  130. const { totalScore, intervalScore } = question.value
  131. return getNumbers(totalScore || 0, intervalScore || 0)
  132. })
  133. const focused = ref<boolean>(false)
  134. const refInput1 = ref<HTMLInputElement>()
  135. const refInput2 = ref<HTMLInputElement>()
  136. watch(
  137. () => props.active,
  138. () => {
  139. nextTick(() => {
  140. if (props.active) {
  141. inputFocus()
  142. } else {
  143. inputBlur()
  144. }
  145. })
  146. },
  147. { immediate: true }
  148. )
  149. const getClass = (val: string, callback?: string) => {
  150. return dialogMode.value ? val : callback || ''
  151. }
  152. const onSetScore = (v: number | string) => {
  153. onInputFocus()
  154. currentScore.value = v
  155. }
  156. const onInputFocus = () => {
  157. focused.value = true
  158. emit('focused')
  159. }
  160. const onBlur = (e: Event) => {
  161. focused.value = false
  162. const target = e.target as HTMLInputElement
  163. target.value && scoreStrictValidFail(target.value)
  164. emit('blur')
  165. }
  166. const inputFocus = () => {
  167. nextTick(() => {
  168. refInput1?.value?.focus()
  169. refInput2?.value?.focus()
  170. })
  171. }
  172. const inputBlur = () => {
  173. nextTick(() => {
  174. refInput1?.value?.blur()
  175. refInput2?.value?.blur()
  176. })
  177. }
  178. const KEY_WHITE_LIST1 = ['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Enter']
  179. const KEY_WHITE_LIST2 = ['\\.', '[0-9]', '[+-]']
  180. const KEY_VALID_REG = new RegExp(
  181. KEY_WHITE_LIST1.map((k) => `\\b${k}\\b`)
  182. .concat(KEY_WHITE_LIST2)
  183. .join('|')
  184. )
  185. const validScore = (score: number | string) => {
  186. return scoreList.value.some((s) => `${s}`.startsWith(`${score}`))
  187. }
  188. const scoreStrictValidFail = (score: number | string) => {
  189. scoreValidFail.value = !scoreList.value.some((s) => `${s}` === `${score}`)
  190. return scoreValidFail.value
  191. }
  192. const deleteStringChart = (str: string, index: number) => {
  193. return str.substring(0, index) + str.substring(index + 1)
  194. }
  195. const joinStringChart = (str: string, index: number, chart: string) => {
  196. return str.substring(0, index) + chart + str.substring(index + 1)
  197. }
  198. const onValidScore = (e: any) => {
  199. const target = e.target as HTMLInputElement
  200. const oldScore = `${currentScore.value ?? ''}`
  201. const start = target.selectionStart || 0
  202. if (!KEY_VALID_REG.test(e.key)) {
  203. console.log('valid:', e.key)
  204. e.preventDefault()
  205. return
  206. }
  207. if (['ArrowLeft', 'ArrowRight'].includes(e.key)) {
  208. return
  209. }
  210. console.log('e.key:', e.key)
  211. if (e.key === '+' && Number(currentScore.value || 0) < scoreList.value[scoreList.value.length - 1]) {
  212. currentScore.value = Number(currentScore.value || 0) + 1 + ''
  213. }
  214. if (e.key === '-' && Number(currentScore.value || 0) > scoreList.value[0]) {
  215. currentScore.value = Number(currentScore.value || 0) - 1 + ''
  216. }
  217. if ('Enter' === e.key) {
  218. let targetInputValue = e.target?.value
  219. if (!targetInputValue) {
  220. return
  221. }
  222. if (oldScore && !scoreStrictValidFail(oldScore)) {
  223. nextTick(() => {
  224. emit('enter')
  225. })
  226. }
  227. e.preventDefault()
  228. return
  229. }
  230. if ('Backspace' === e.key) {
  231. if (!validScore(deleteStringChart(oldScore, start - 1))) {
  232. e.preventDefault()
  233. }
  234. return
  235. }
  236. if ('Delete' === e.key) {
  237. if (!validScore(deleteStringChart(oldScore, start))) {
  238. e.preventDefault()
  239. }
  240. return
  241. }
  242. if (!validScore(joinStringChart(oldScore, start, e.key))) {
  243. e.preventDefault()
  244. }
  245. }
  246. const onValidScoreDebounce = debounce(onValidScore, 50)
  247. const scoreChange = (e: Event) => {
  248. const target = e.target as HTMLInputElement
  249. const oldScore = `${currentScore.value || ''}`
  250. if (validScore(target.value)) {
  251. onSetScore(target.value)
  252. } else {
  253. target.value = oldScore
  254. }
  255. }
  256. const onToggleClick = () => {
  257. emit('toggle-click')
  258. }
  259. </script>
  260. <style scoped lang="scss">
  261. .scoring-panel {
  262. // background-color: $MainLayoutHeaderBg;
  263. background-color: #fff;
  264. font-size: $MediumFont;
  265. margin-bottom: 6px;
  266. &.mini {
  267. padding: 12px 5px !important;
  268. }
  269. &.modal-panel {
  270. padding: 10px 0;
  271. }
  272. &.sticky {
  273. align-items: center;
  274. // padding: 12px 20px;
  275. padding: 5px 20px;
  276. height: 56px;
  277. }
  278. // .dialog-name {
  279. // width: 98px;
  280. // place-items: center;
  281. // background: #f5f5f5;
  282. // }
  283. .score-list {
  284. font-size: $BaseFont;
  285. margin-bottom: -8px;
  286. // margin-left: 8px;
  287. .score-span {
  288. width: 46px;
  289. height: 32px;
  290. line-height: 32px;
  291. text-align: center;
  292. border-radius: 4px;
  293. border: 1px solid #e5e5e5;
  294. color: #666666;
  295. margin-left: 8px;
  296. margin-bottom: 8px;
  297. cursor: pointer;
  298. &.mini {
  299. width: 32px;
  300. font-size: 12px;
  301. }
  302. &.active,
  303. &:hover {
  304. background-color: $color--primary;
  305. color: $color--white;
  306. }
  307. }
  308. .score-result {
  309. background-color: $color--primary;
  310. color: $color--white;
  311. margin-left: 8px;
  312. border-radius: 6px;
  313. padding: 2px;
  314. height: 32px;
  315. line-height: 32px;
  316. min-width: 84px;
  317. margin-bottom: 8px;
  318. .score-num {
  319. width: 40px;
  320. height: 100%;
  321. line-height: 28px;
  322. background-color: $color--white;
  323. color: #e02020;
  324. border-radius: 0 6px 6px 0;
  325. outline: none;
  326. }
  327. }
  328. }
  329. .dialog-score {
  330. width: 80px;
  331. place-items: center;
  332. background: $color--primary;
  333. color: $color--white;
  334. font-size: 32px;
  335. outline: none;
  336. margin-left: 6px;
  337. overflow: hidden;
  338. .score-input {
  339. background: inherit;
  340. color: inherit;
  341. font-size: inherit;
  342. width: 100% !important;
  343. height: 100%;
  344. }
  345. }
  346. .score-input {
  347. border: none;
  348. outline: none;
  349. font-weight: bold;
  350. text-align: center;
  351. }
  352. .score-valid-fail {
  353. position: relative;
  354. // background-color: $DangerColor !important;
  355. border: 1px solid $DangerColor;
  356. box-shadow: 0 0 4px $DangerColor;
  357. &:before {
  358. content: '无效分值';
  359. position: absolute;
  360. width: 100%;
  361. height: 1em;
  362. line-height: 1;
  363. text-align: center;
  364. top: -1.2em;
  365. left: 50%;
  366. transform: translateX(-50%);
  367. font-size: 10px;
  368. color: $DangerColor;
  369. }
  370. }
  371. // .toggle-icon {
  372. // align-self: flex-start;
  373. // margin-top: 6px;
  374. // margin-left: 6px;
  375. // opacity: 0;
  376. // &.visible {
  377. // opacity: 1;
  378. // }
  379. // }
  380. .toggle-icon-box {
  381. cursor: pointer;
  382. background-color: #0091ff;
  383. width: 70px;
  384. border-radius: 6px;
  385. text-align: center;
  386. color: #fff;
  387. opacity: 0;
  388. height: 100%;
  389. transition: all 0.3s;
  390. &:hover {
  391. background-color: rgba(0, 145, 255, 0.8);
  392. }
  393. &.visible {
  394. opacity: 1;
  395. }
  396. img {
  397. height: 20px;
  398. margin-top: 4px;
  399. }
  400. p {
  401. font-size: 12px;
  402. }
  403. }
  404. }
  405. </style>