Przeglądaj źródła

feat: 消息以及提交去掉二次确认

zhangjie 10 godzin temu
rodzic
commit
fafa11cbd6

+ 5 - 0
src/components/shared/message/Message.vue

@@ -96,6 +96,11 @@ watch(
           sessionStorage.setItem('lastNewMsgMaxId', String(newMsgMaxId))
         }
       } else {
+        // 首次登录时,如果有未读消息也应该弹出
+        if (newMsgMaxId > 0 && val.newCount > 0) {
+          messageWindowType.value = 'view'
+          visibleMessageWindow.value = true
+        }
         sessionStorage.setItem('lastNewMsgMaxId', String(newMsgMaxId))
       }
     }

+ 286 - 0
src/modules/marking/mark/components/ScoringPanel.vue

@@ -0,0 +1,286 @@
+<template>
+  <scoring-panel-container
+    v-model="modalVisible"
+    title="键盘给分"
+    modal-class="no-mask"
+    :width="'388px'"
+    :modal="false"
+    :can-resize="'can-resize'"
+    class="keybord-dialog"
+    @close="onToggleClick"
+  >
+    <div v-loading="props.loading" class="scoring-panel-box" :class="getClass('modal-box')">
+      <template v-for="(question, index) in questionList" :key="question.mainNumber + question.subNumber">
+        <div v-if="dialogMode" class="flex dialog-name items-center">
+          <p>{{ question.mainNumber }} - {{ question.subNumber }}</p>
+          <p class="main-title">{{ question.mainTitle }}</p>
+        </div>
+        <scoring-panel-item
+          :id="props.id"
+          v-model:score="scoreValues[index]"
+          v-model:scoreValidFail="scoreValidFail[index]"
+          :active="activeIndex === index"
+          :modal="dialogMode"
+          :toggle-modal="props.toggleModal && index === questionList.length - 1"
+          :show-confirm-btn="index == questionList.length - 1"
+          :show-all-paper-btn="props.showAllPaperBtn"
+          :question="question"
+          :large="props.large"
+          :allow-submit="allowSubmit"
+          :cannot-toggle="props.cannotToggle"
+          :loading="props.loading"
+          @blur="() => onBlur(index)"
+          @enter="() => onEnter(index)"
+          @focused="() => onFocused(index)"
+          @toggle-click="onToggleClick"
+          @view-papers="() => onViewPapers()"
+        ></scoring-panel-item>
+      </template>
+    </div>
+    <template #footer>
+      <el-button type="primary" class="full-w" :disabled="!allowSubmit || buttonDisabled" @click="onEnter(0)">{{
+        buttonText
+      }}</el-button>
+    </template>
+  </scoring-panel-container>
+</template>
+
+<script setup lang="ts" name="ScoringPanel">
+import { watch, withDefaults, ref, defineComponent, useSlots, computed, nextTick, onMounted, onUnmounted } from 'vue'
+
+import { ElButton } from 'element-plus'
+import BaseDialog from '@/components/element/BaseDialog.vue'
+import ScoringPanelItem from './ScoringPanelItem.vue'
+import useVModel from '@/hooks/useVModel'
+import useVW from '@/hooks/useVW'
+import useFetch from '@/hooks/useFetch'
+import { sessionStorage } from '@/plugins/storage'
+import bus from '@/utils/bus'
+import useMarkStore from '@/store/mark'
+
+const markStore = useMarkStore()
+
+const props = withDefaults(
+  defineProps<{
+    /** 弹窗模式? */
+    modal?: boolean
+    /** 是否可以切换显示模式 */
+    toggleModal?: boolean
+    /** 显示隐藏 */
+    visible?: boolean
+    /** 分值 */
+    score: (number | string)[]
+    // mainNumber?: number | null
+    mainNumber?: any
+    subjectCode?: any
+    id?: any
+    autoVisible?: boolean | undefined
+    large?: boolean
+    cannotToggle?: boolean
+    loading?: boolean
+    showAllPaperBtn?: boolean
+  }>(),
+  {
+    modal: false,
+    toggleModal: true,
+    score: () => [],
+    mainNumber: null,
+    subjectCode: null,
+    id: null,
+    autoVisible: true,
+    large: true,
+    cannotToggle: false,
+    loading: false,
+    showAllPaperBtn: false,
+  }
+)
+
+const emits = defineEmits(['submit', 'update:score', 'update:visible', 'update:modal', 'view-papers'])
+const dialogModeBeforeSubmit = ref<boolean>(false)
+const dialogMode = ref<boolean>(props.modal)
+
+const LessRenderComponent = defineComponent({
+  name: 'LessRender',
+  inheritAttrs: false,
+  props: {
+    modelValue: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  render() {
+    return this.modelValue ? useSlots()?.default?.() : null
+  },
+})
+
+const ScoringPanelContainer = computed(() => {
+  return dialogMode.value ? BaseDialog : LessRenderComponent
+})
+
+const modalVisible = useVModel(props, 'visible')
+
+const scoreValues = useVModel(props, 'score')
+
+const activeIndex = ref<number | null>(0)
+
+const scoreValidFail = ref<boolean[]>([])
+
+watch(modalVisible, (val) => {
+  activeIndex.value = 0
+  let sessionKeyboardShowType = sessionStorage.get('dialogModeBeforeSubmit')
+  dialogMode.value =
+    typeof sessionKeyboardShowType === 'boolean' ? sessionKeyboardShowType : dialogModeBeforeSubmit.value
+})
+onMounted(() => {
+  let sessionKeyboardShowType = sessionStorage.get('dialogModeBeforeSubmit')
+  dialogMode.value =
+    typeof sessionKeyboardShowType === 'boolean' ? sessionKeyboardShowType : dialogModeBeforeSubmit.value
+})
+
+const { fetch: getQuestionStruct, reset: resetQuestionStruct, result: questionStruct } = useFetch('getQuestionStruct')
+
+watch([() => props.id, () => props.autoVisible], () => {
+  if (props.autoVisible) {
+    modalVisible.value = !!props.id
+  }
+  scoreValues.value = []
+})
+
+watch(
+  () => props.mainNumber,
+  () => {
+    /** reset scores */
+    scoreValues.value = []
+    if (props.mainNumber) {
+      resetQuestionStruct()
+      getQuestionStruct({ mainNumber: props.mainNumber, subjectCode: props.subjectCode })
+    }
+  },
+  { immediate: true }
+)
+
+watch(
+  () => questionStruct.value,
+  (val) => {
+    if (!val) return
+    markStore.setMarkDelay(val.markDelay)
+  }
+)
+
+watch(
+  () => markStore.curQuestionScore,
+  (val, oldVal) => {
+    if (!oldVal && (val || val === 0)) markStore.checkNeedCountDown(val)
+  }
+)
+
+// 倒计时相关计算属性
+const buttonText = computed(() => (markStore.countdownTime > 0 ? `${markStore.countdownTime}s` : '确定'))
+const buttonDisabled = computed(() => {
+  return markStore.countdownTime > 0
+})
+
+const questionList = computed(() => {
+  if (!questionStruct.value) {
+    return []
+  }
+  const { mainNumber, mainTitle, questionList = [] } = questionStruct.value
+
+  return questionList.map((q) => ({
+    ...q,
+    mainNumber,
+    mainTitle,
+  }))
+})
+
+const allowSubmit = computed(() => {
+  let filterArr = scoreValues.value.filter((score) => score != undefined && score !== '')
+  // return scoreValues.value?.length === questionList.value?.length
+  return filterArr.length === questionList.value?.length
+})
+
+const getClass = (val: string, callback?: string) => {
+  return dialogMode.value ? val : callback || ''
+}
+
+const onSubmit = () => {
+  if (!scoreValidFail.value.some((valid) => valid)) {
+    emits('submit', questionStruct.value)
+  }
+}
+
+const onEnter = (index: number) => {
+  dialogModeBeforeSubmit.value = dialogMode.value
+  sessionStorage.set('dialogModeBeforeSubmit', dialogModeBeforeSubmit.value)
+
+  nextTick(() => {
+    // console.log('index:', index)
+    // console.log('scoreValues.value.length:', scoreValues.value.length)
+    // console.log('questionList.value?.length:', questionList.value?.length)
+    let filterArr = scoreValues.value.filter((score) => score != undefined)
+    // if (scoreValues.value.length >= questionList.value?.length) {
+    if (filterArr.length >= questionList.value?.length) {
+      const nullScoreIndex = scoreValues.value?.findIndex((v) => !`${v}`)
+      const validFailIndexIndex = scoreValidFail.value?.findIndex((v) => !!v)
+      if (nullScoreIndex >= 0) {
+        activeIndex.value = nullScoreIndex
+      } else if (validFailIndexIndex >= 0) {
+        activeIndex.value = validFailIndexIndex
+      } else {
+        onSubmit()
+      }
+    } else {
+      activeIndex.value = index + 1
+    }
+  })
+}
+
+const onFocused = (index: number) => {
+  activeIndex.value = index
+}
+
+const onBlur = (index: number) => {
+  if (activeIndex.value === index) {
+    // activeIndex.value = null
+  }
+}
+
+const onViewPapers = () => {
+  emits('view-papers')
+}
+
+const onToggleClick = () => {
+  if (modalVisible.value) {
+    dialogModeBeforeSubmit.value = dialogMode.value ? false : true
+    sessionStorage.set('dialogModeBeforeSubmit', dialogModeBeforeSubmit.value)
+  }
+  dialogMode.value = props.toggleModal ? !dialogMode.value : dialogMode.value
+  if (!props.toggleModal) {
+    modalVisible.value = false
+  }
+}
+bus.on('mark-method-toggle', () => {
+  onToggleClick()
+})
+bus.on('openScoreDialogByMouseRight', () => {
+  if (!dialogMode.value) {
+    onToggleClick()
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.scoring-panel-box {
+  padding-bottom: 4px;
+  background-color: #fff;
+}
+.modal-box {
+  max-height: 50vh;
+  min-height: 8vw;
+  .dialog-name {
+    color: $color--primary;
+    font-weight: bold;
+    padding-left: 10px;
+  }
+}
+</style>

+ 516 - 0
src/modules/marking/mark/components/ScoringPanelItem.vue

@@ -0,0 +1,516 @@
+<template>
+  <div class="flex scoring-panel" :class="[getClass('modal-panel', 'sticky'), { mini: !props.large && !props.modal }]">
+    <toggle-dialog-render v-if="isMarkerPage">
+      <svg-icon name="question"></svg-icon>
+      <span class="m-l-mini" style="max-width: 200px"
+        >{{ question.mainNumber }} - {{ question.subNumber }}{{ question.mainTitle }}</span
+      >
+      <!-- <template #callback>
+        <div class="grid radius-base dialog-name">
+          <div class="text-center">
+            <p>{{ question.mainNumber }} - {{ question.subNumber }}</p>
+            <p>{{ question.mainTitle }}</p>
+          </div>
+        </div>
+      </template> -->
+    </toggle-dialog-render>
+    <div class="flex flex-1 flex-wrap score-list" :class="{ 'dialog-score-list': dialogMode }">
+      <div
+        v-for="scoreItem in scoreList"
+        :key="scoreItem"
+        class="score-span"
+        :class="[
+          getClass('m-b-mini'),
+          { active: parseFloat(`${currentScore}`) === parseFloat(`${scoreItem}`), mini: !props.large && !props.modal },
+        ]"
+        @click="onSetScore(scoreItem)"
+      >
+        {{ scoreItem }}
+      </div>
+      <toggle-dialog-render>
+        <div class="flex items-center">
+          <div class="flex items-center score-result" :class="{ 'score-valid-fail': scoreValidFail }">
+            <div class="flex-1 text-center" style="font-size: 12px">给分</div>
+            <input
+              ref="refInput1"
+              class="flex-1 text-center score-input score-num"
+              :value="currentScore"
+              :readonly="props.loading"
+              @focus="onInputFocus"
+              @blur="onBlur"
+              @keydown="onValidScoreDebounce"
+              @input="scoreChange"
+            />
+          </div>
+          <el-button
+            v-if="!!props.showConfirmBtn"
+            :disabled="!props.allowSubmit || buttonDisabled"
+            size="small"
+            type="primary"
+            style="min-width: 44px; margin-left: 5px; margin-bottom: 8px"
+            @click="confirmWithBtn"
+            >{{ buttonText }}</el-button
+          >
+          <el-button
+            v-if="!!props.showAllPaperBtn"
+            v-permBtn="'marking-allpaper'"
+            type="primary"
+            link
+            style="min-width: 44px; margin-left: 20px; margin-bottom: 8px"
+            @click="onViewPapers"
+            >调全卷</el-button
+          >
+        </div>
+      </toggle-dialog-render>
+    </div>
+    <toggle-dialog-render dialog>
+      <div class="grid dialog-score" :class="{ 'score-valid-fail': scoreValidFail }">
+        <input
+          ref="refInput2"
+          class="score-input radius-base"
+          :value="currentScore"
+          :readonly="props.loading"
+          @focus="onInputFocus"
+          @blur="onBlur"
+          @keydown="onValidScoreDebounce"
+          @input="scoreChange"
+        />
+      </div>
+    </toggle-dialog-render>
+    <toggle-dialog-render v-if="!props.cannotToggle">
+      <!-- <svg-icon
+        class="pointer toggle-icon"
+        :class="{ visible: props.toggleModal }"
+        name="toggle-panel"
+        @click="onToggleClick"
+      ></svg-icon> -->
+      <div class="toggle-icon-box" :class="{ visible: props.toggleModal }" @click="onToggleClick">
+        <img src="../../assets/images/tanchu.png" />
+        <p>弹出</p>
+      </div>
+    </toggle-dialog-render>
+  </div>
+</template>
+
+<script setup lang="ts" name="ScoringPanelItem">
+import { watch, computed, ref, nextTick, withDefaults, defineComponent, useSlots } from 'vue'
+import SvgIcon from '@/components/common/SvgIcon.vue'
+import useVModel from '@/hooks/useVModel'
+import { getNumbers } from '@/utils/common'
+import { debounce } from 'lodash-es'
+import { useRoute } from 'vue-router'
+import { ElButton } from 'element-plus'
+import useMarkStore from '@/store/mark'
+
+const markStore = useMarkStore()
+const route = useRoute()
+const isMarkerPage = computed(() => {
+  return route.path === '/marking/mark'
+})
+
+interface QuestionInfo {
+  mainNumber: number | string
+  mainTitle: string
+  subNumber: number
+  totalScore: number
+  intervalScore: number
+  // 当前试题最小评卷时间(秒)
+  markSpeedLimit?: number
+}
+
+const props = withDefaults(
+  defineProps<{
+    /** 弹窗模式? */
+    modal?: boolean
+    /** 是否可以切换显示模式 */
+    toggleModal?: boolean
+    /** 分值 */
+    score?: number | string | undefined
+    /** active 当前正在评分 */
+    active: boolean
+    scoreValidFail: boolean | undefined
+    question: QuestionInfo
+    large?: boolean
+    allowSubmit: boolean
+    showConfirmBtn?: boolean
+    showAllPaperBtn?: boolean
+    cannotToggle?: boolean
+    id?: any
+    loading?: any
+  }>(),
+  {
+    modal: false,
+    toggleModal: true,
+    score: void 0,
+    scoreValidFail: false,
+    showAllPaperBtn: false,
+    large: true,
+    cannotToggle: false,
+    id: null,
+    loading: false,
+  }
+)
+
+const emit = defineEmits(['focused', 'blur', 'toggle-click', 'enter', 'update:score', 'view-papers'])
+
+const dialogMode = ref<boolean>(props.modal)
+
+const currentScore = useVModel(props, 'score')
+
+const scoreValidFail = useVModel(props, 'scoreValidFail')
+
+const ToggleDialogRender = defineComponent({
+  name: 'DialogHideNode',
+  inheritAttrs: false,
+  props: {
+    dialog: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  render() {
+    const slots = useSlots()
+    const defaultSlot = slots?.default?.()
+    const callBackSlot = slots?.callback?.()
+    return (this.dialog ? dialogMode.value : !dialogMode.value) ? defaultSlot : callBackSlot
+  },
+})
+
+const question = computed(() => {
+  return props.question || ({} as QuestionInfo)
+})
+
+const scoreList = computed<number[]>(() => {
+  const { totalScore, intervalScore } = question.value
+  return getNumbers(totalScore || 0, intervalScore || 0)
+})
+
+const focused = ref<boolean>(false)
+
+const refInput1 = ref<HTMLInputElement>()
+const refInput2 = ref<HTMLInputElement>()
+
+watch(
+  () => props.active,
+  () => {
+    nextTick(() => {
+      if (props.active) {
+        inputFocus()
+      } else {
+        inputBlur()
+      }
+    })
+  },
+  { immediate: true }
+)
+watch(
+  () => props.id,
+  (val: any) => {
+    !!val && inputFocus()
+  }
+)
+
+// 倒计时相关计算属性
+const buttonText = computed(() => (markStore.countdownTime > 0 ? `${markStore.countdownTime}s` : '确定'))
+const buttonDisabled = computed(() => {
+  return markStore.countdownTime > 0
+})
+
+const getClass = (val: string, callback?: string) => {
+  return dialogMode.value ? val : callback || ''
+}
+
+const onSetScore = (v: number | string) => {
+  onInputFocus()
+  currentScore.value = v
+  markStore.setCurQuestionScore(currentScore.value === '' ? null : Number(currentScore.value))
+}
+
+const onInputFocus = () => {
+  focused.value = true
+  emit('focused')
+}
+
+const onBlur = (e: Event) => {
+  focused.value = false
+  const target = e.target as HTMLInputElement
+  target.value && scoreStrictValidFail(target.value)
+  emit('blur')
+}
+
+const inputFocus = () => {
+  nextTick(() => {
+    refInput1?.value?.focus()
+    refInput2?.value?.focus()
+  })
+}
+
+const inputBlur = () => {
+  nextTick(() => {
+    refInput1?.value?.blur()
+    refInput2?.value?.blur()
+  })
+}
+
+const KEY_WHITE_LIST1 = ['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Enter']
+const KEY_WHITE_LIST2 = ['\\.', '[0-9]', '[+-]']
+const KEY_VALID_REG = new RegExp(
+  KEY_WHITE_LIST1.map((k) => `\\b${k}\\b`)
+    .concat(KEY_WHITE_LIST2)
+    .join('|')
+)
+
+const validScore = (score: number | string) => {
+  return scoreList.value.some((s) => `${s}`.startsWith(`${score}`))
+}
+
+const scoreStrictValidFail = (score: number | string) => {
+  scoreValidFail.value = !scoreList.value.some((s) => `${s}` === `${score}`)
+  return scoreValidFail.value
+}
+
+const deleteStringChart = (str: string, index: number) => {
+  return str.substring(0, index) + str.substring(index + 1)
+}
+
+const joinStringChart = (str: string, index: number, chart: string) => {
+  return str.substring(0, index) + chart + str.substring(index + 1)
+}
+const confirmWithBtn = () => {
+  emit('enter')
+}
+const onViewPapers = () => {
+  emit('view-papers')
+}
+const onValidScore = (e: any) => {
+  const target = e.target as HTMLInputElement
+  const oldScore = `${currentScore.value ?? ''}`
+  const start = target.selectionStart || 0
+
+  if (!KEY_VALID_REG.test(e.key)) {
+    console.log('valid:', e.key)
+
+    e.preventDefault()
+    return
+  }
+  if (['ArrowLeft', 'ArrowRight'].includes(e.key)) {
+    return
+  }
+  console.log('e.key:', e.key)
+
+  if (e.key === '+' && Number(currentScore.value || 0) < scoreList.value[scoreList.value.length - 1]) {
+    currentScore.value = Number(currentScore.value || 0) + 1 + ''
+  }
+  if (e.key === '-' && Number(currentScore.value || 0) > scoreList.value[0]) {
+    currentScore.value = Number(currentScore.value || 0) - 1 + ''
+  }
+  if ('Enter' === e.key) {
+    let targetInputValue = e.target?.value
+    if (!targetInputValue) {
+      return
+    }
+    if (oldScore && !scoreStrictValidFail(oldScore)) {
+      nextTick(() => {
+        emit('enter')
+      })
+    }
+    e.preventDefault()
+    return
+  }
+
+  if ('Backspace' === e.key) {
+    if (!validScore(deleteStringChart(oldScore, start - 1))) {
+      e.preventDefault()
+    }
+    return
+  }
+  if ('Delete' === e.key) {
+    if (!validScore(deleteStringChart(oldScore, start))) {
+      e.preventDefault()
+    }
+    return
+  }
+  if (!validScore(joinStringChart(oldScore, start, e.key))) {
+    e.preventDefault()
+  }
+}
+const onValidScoreDebounce = debounce(onValidScore, 50)
+
+const scoreChange = (e: Event) => {
+  const target = e.target as HTMLInputElement
+  const oldScore = `${currentScore.value || ''}`
+  if (validScore(target.value)) {
+    onSetScore(target.value)
+  } else {
+    target.value = oldScore
+  }
+}
+
+const onToggleClick = () => {
+  emit('toggle-click')
+}
+</script>
+
+<style scoped lang="scss">
+.scoring-panel {
+  // background-color: $MainLayoutHeaderBg;
+  background-color: #fff;
+  font-size: $MediumFont;
+  // margin-bottom: 6px;
+  &.mini {
+    padding: 2px 5px !important;
+  }
+  &.modal-panel {
+    padding: 10px 0;
+  }
+  &.sticky {
+    align-items: center;
+    // padding: 12px 20px;
+    padding: 2px 20px;
+    // height: 50px;
+  }
+
+  // .dialog-name {
+  //   width: 98px;
+  //   place-items: center;
+  //   background: #f5f5f5;
+  // }
+
+  .score-list {
+    font-size: $BaseFont;
+    margin-bottom: -8px;
+    // margin-left: 8px;
+    &.dialog-score-list {
+      margin-bottom: 0;
+    }
+    .score-span {
+      width: 46px;
+      height: 32px;
+      line-height: 32px;
+      text-align: center;
+      border-radius: 4px;
+      border: 1px solid #e5e5e5;
+      color: #666666;
+      margin-left: 8px;
+      margin-bottom: 8px;
+      cursor: pointer;
+      &.mini {
+        width: 32px;
+        font-size: 12px;
+      }
+      &.active,
+      &:hover {
+        background-color: $color--primary;
+        color: $color--white;
+      }
+    }
+
+    .score-result {
+      background-color: $color--primary;
+      color: $color--white;
+      margin-left: 8px;
+      border-radius: 6px;
+      padding: 2px;
+      height: 32px;
+      line-height: 32px;
+      min-width: 84px;
+      margin-bottom: 8px;
+
+      .score-num {
+        width: 40px;
+        height: 100%;
+        line-height: 28px;
+        background-color: $color--white;
+        color: #e02020;
+        border-radius: 0 6px 6px 0;
+        outline: none;
+      }
+    }
+  }
+
+  .dialog-score {
+    // width: 80px;
+    width: 70px;
+    // place-items: center;
+    // background: $color--primary;
+    color: $color--white;
+    // font-size: 32px;
+    outline: none;
+    margin-left: 6px;
+    overflow: hidden;
+    position: relative;
+    top: -2px;
+    .score-input {
+      background: inherit;
+      color: inherit;
+      font-size: inherit;
+      width: 100% !important;
+      // height: 100%;
+      height: calc(100% - 4px);
+      background: $color--primary;
+      font-size: 24px;
+    }
+  }
+
+  .score-input {
+    border: none;
+    outline: none;
+    font-weight: bold;
+    text-align: center;
+  }
+
+  .score-valid-fail {
+    position: relative;
+    // background-color: $DangerColor !important;
+    border: 1px solid $DangerColor;
+    box-shadow: 0 0 4px $DangerColor;
+    &:before {
+      content: '无效分值';
+      position: absolute;
+      width: 100%;
+      height: 1em;
+      line-height: 1;
+      text-align: center;
+      top: -1.2em;
+      left: 50%;
+      transform: translateX(-50%);
+      font-size: 10px;
+      color: $DangerColor;
+    }
+  }
+
+  // .toggle-icon {
+  //   align-self: flex-start;
+  //   margin-top: 6px;
+  //   margin-left: 6px;
+  //   opacity: 0;
+  //   &.visible {
+  //     opacity: 1;
+  //   }
+  // }
+  .toggle-icon-box {
+    cursor: pointer;
+    background-color: #0091ff;
+    width: 70px;
+    border-radius: 6px;
+    text-align: center;
+    color: #fff;
+    opacity: 0;
+    height: 100%;
+    transition: all 0.3s;
+    &:hover {
+      background-color: rgba(0, 145, 255, 0.8);
+    }
+    &.visible {
+      opacity: 1;
+    }
+    img {
+      height: 20px;
+      margin-top: 3px;
+    }
+    p {
+      font-size: 12px;
+    }
+  }
+}
+</style>

+ 223 - 0
src/modules/marking/mark/components/ScoringPanelWithConfirm.vue

@@ -0,0 +1,223 @@
+<template>
+  <scoring-panel
+    v-bind="attrs"
+    :id="props.id"
+    v-model:score="modelScore"
+    v-model:visible="modalVisible"
+    :main-number="props.mainNumber"
+    :subject-code="props.subjectCode"
+    :large="props.large ?? false"
+    :cannot-toggle="props.cannotToggle"
+    :loading="props.loading"
+    :show-all-paper-btn="props.showAllPaperBtn"
+    @submit="onSubmit"
+    @view-papers="onViewPapers"
+  ></scoring-panel>
+  <base-dialog v-model="submitModalVisible" unless :width="260" center>
+    <div class="text-center question-name">{{ questionInfo?.mainNumber }} {{ questionInfo?.mainTitle }}</div>
+    <div class="fill-lighter p-t-base text-center">
+      <div class="total-score-title">— 总分 —</div>
+      <div class="m-t-extra-small">
+        <span class="score-value" style="color: red">{{ !modelScore?.length ? '' : totalScore }}</span>
+        <span class="score-unit">分</span>
+      </div>
+    </div>
+    <div class="text-center m-t-base confirm-text">确认提交?</div>
+    <template #footer>
+      <div class="flex items-center justify-between">
+        <el-button
+          ref="confirmButtonRef"
+          :disabled="buttonDisabled"
+          class="confirm-button"
+          type="primary"
+          @click="onConfirmSubmit"
+        >
+          {{ buttonText }}
+        </el-button>
+        <el-button ref="cancelButtonRef" class="confirm-button" plain @click="onCancelSubmit">否(N)</el-button>
+      </div>
+    </template>
+  </base-dialog>
+  <image-list-preview
+    v-if="imageList?.length"
+    v-model="previewModalVisible"
+    :image-list="imageList"
+  ></image-list-preview>
+</template>
+
+<script setup lang="ts" name="ScoringPanelWithConfirm">
+import { computed, ref, useAttrs, watch, nextTick, onMounted, onBeforeUnmount, unref, onUnmounted } from 'vue'
+import { add } from '@/utils/common'
+import { ElButton } from 'element-plus'
+import ScoringPanel from './ScoringPanel.vue'
+import BaseDialog from '@/components/element/BaseDialog.vue'
+import ImageListPreview from '@/components/shared/ImageListPreview.vue'
+import useVModel from '@/hooks/useVModel'
+import useVW from '@/hooks/useVW'
+import { cloneDeep } from 'lodash-es'
+import bus from '@/utils/bus'
+import { useRoute } from 'vue-router'
+import type { ExtractApiResponse } from '@/api/api'
+import useMarkStore from '@/store/mark'
+
+const route = useRoute()
+
+interface MarkDelayItem {
+  startScore: number
+  endScore: number
+  minMarkTime: number
+}
+
+const props = defineProps<{
+  /** 显示隐藏 */
+  visible?: boolean
+  /** 分值 */
+  score: number[]
+  /** 大题号 */
+  mainNumber?: number | string
+  subjectCode?: any
+  large?: boolean
+  cannotToggle?: boolean
+  id?: any
+  loading?: any
+  imageList?: string[]
+  showAllPaperBtn?: boolean
+  // 是否显示分数段倒计时
+  showLimitTime?: boolean
+}>()
+
+const attrs = useAttrs()
+
+const emit = defineEmits<{
+  (e: 'submit', data: { question: ExtractApiResponse<'getQuestionStruct'>; scores: number[]; totalScore: number }): void
+  (e: 'update:visible', visible: boolean): void
+  (e: 'update:score', scores: number[]): void
+}>()
+
+const markStore = useMarkStore()
+
+/** 给分面板显示隐藏 */
+const modalVisible = useVModel(props, 'visible')
+
+/** 给分列表 */
+const modelScore = useVModel(props, 'score')
+
+/** 确认给分 */
+const submitModalVisible = ref<boolean>(false)
+
+/** 确认给分按钮 */
+const confirmButtonRef = ref<typeof ElButton>()
+const cancelButtonRef = ref<typeof ElButton>()
+
+watch(submitModalVisible, () => {
+  if (submitModalVisible.value) {
+    nextTick(() => {
+      ;(confirmButtonRef.value?.ref as unknown as HTMLButtonElement).focus()
+    })
+  }
+})
+const yesOrNo = (e: any) => {
+  if (e.key === 'ArrowLeft') {
+    ;(confirmButtonRef.value?.ref as unknown as HTMLButtonElement)?.focus()
+  }
+  if (e.key === 'ArrowRight') {
+    ;(cancelButtonRef.value?.ref as unknown as HTMLButtonElement)?.focus()
+  }
+}
+//新增需求:鼠标点击试卷右键,弹出给分板的弹框模式
+const addEventToPaperImg = () => {
+  const dom: any = document.querySelector('.img-wrap .paper-img')
+  dom.oncontextmenu = (e: any) => {
+    e.preventDefault()
+    bus.emit('openScoreDialogByMouseRight')
+  }
+}
+onMounted(() => {
+  document.addEventListener('keyup', yesOrNo)
+  if (route.path === '/inquiry-result') {
+    addEventToPaperImg()
+  }
+})
+onBeforeUnmount(() => {
+  document.removeEventListener('keyup', yesOrNo)
+})
+
+/** 总分 */
+const totalScore = computed(() => {
+  return add(...modelScore.value.map((v) => v || 0))
+})
+
+const questionInfo = ref<ExtractApiResponse<'getQuestionStruct'>>()
+
+/** 提交 */
+const onSubmit = (data: ExtractApiResponse<'getQuestionStruct'>) => {
+  questionInfo.value = data
+  onConfirmSubmit()
+}
+
+/** 取消提交 */
+const onCancelSubmit = () => {
+  modalVisible.value = true
+  submitModalVisible.value = false
+}
+
+/** 确认提交 */
+const onConfirmSubmit = () => {
+  if (!questionInfo.value) return
+  modalVisible.value = true
+  submitModalVisible.value = false
+  let confirmScoreArr = cloneDeep(unref(modelScore.value))
+  emit('submit', { question: questionInfo.value, scores: confirmScoreArr, totalScore: totalScore.value })
+  modelScore.value = []
+}
+
+// 看全卷
+const previewModalVisible = ref<boolean>(false)
+const onViewPapers = () => {
+  previewModalVisible.value = true
+}
+
+watch(
+  () => props.id,
+  (newVal, oldVal) => {
+    if (newVal === oldVal) return
+    markStore.initMarkSpent()
+  },
+  { immediate: true }
+)
+
+onUnmounted(() => {
+  if (!props.showLimitTime) return
+  markStore.clearCountdown()
+})
+</script>
+
+<style scoped lang="scss">
+.question-name {
+  font-size: $BaseFont;
+  color: $NormalColor;
+  margin-bottom: 10px;
+}
+.total-score-title {
+  font-size: $MediumFont;
+  font-weight: bold;
+  color: $NormalColor;
+}
+.confirm-text {
+  font-size: $BaseFont;
+  color: $NormalColor;
+}
+.score-value {
+  font-size: 48px;
+  font-weight: 900;
+  letter-spacing: 4px;
+  color: $NormalColor;
+}
+.score-unit {
+  font-size: $BaseFont;
+  color: $NormalColor;
+}
+.confirm-button {
+  width: 90px;
+}
+</style>

+ 1 - 1
src/modules/marking/mark/index.vue

@@ -144,7 +144,7 @@ import useSpentTime from '@/hooks/useSpentTime'
 import useMarkHeader from '@/hooks/useMarkHeader'
 import BaseDialog from '@/components/element/BaseDialog.vue'
 import MarkHeader from '@/components/shared/MarkHeader.vue'
-import ScoringPanelWithConfirm from '@/components/shared/ScoringPanelWithConfirm.vue'
+import ScoringPanelWithConfirm from './components/ScoringPanelWithConfirm.vue'
 import ImagePreview from '@/components/shared/ImagePreview.vue'
 import RemarkListModal from '@/components/shared/RemarkListModal.vue'
 import CurrentTime from '@/components/shared/CurrentTime.vue'

+ 48 - 0
src/store/mark.ts

@@ -1,11 +1,23 @@
 import { defineStore } from 'pinia'
+import useSpentTime from '@/hooks/useSpentTime'
 
+interface MarkDelayItem {
+  startScore: number
+  endScore: number
+  minMarkTime: number
+}
 interface MarkStoreState {
   // 前端评卷试题的分值
   curQuestionScore: number | null
   // 倒计时相关状态
   countdownTime: number
   countdownTimer: NodeJS.Timeout | null
+  // 评卷延时配置
+  markDelay: MarkDelayItem[]
+  markSpent: {
+    startTime: number
+    endTime: number
+  }
 }
 
 interface MarkStoreActions {
@@ -13,6 +25,9 @@ interface MarkStoreActions {
   startCountdown: (markSpeedLimit?: number) => void
   clearCountdown: () => void
   updateCountdownTime: (time: number) => void
+  checkNeedCountDown: (score: number) => void
+  initMarkSpent: () => void
+  setMarkDelay: (val: string) => void
 }
 
 const useMarkStore = defineStore<'mark', MarkStoreState, Record<string, any>, MarkStoreActions>('mark', {
@@ -21,6 +36,11 @@ const useMarkStore = defineStore<'mark', MarkStoreState, Record<string, any>, Ma
       curQuestionScore: null,
       countdownTime: 0,
       countdownTimer: null,
+      markDelay: [],
+      markSpent: {
+        startTime: 0,
+        endTime: 0,
+      },
     }
   },
   getters: {
@@ -28,6 +48,34 @@ const useMarkStore = defineStore<'mark', MarkStoreState, Record<string, any>, Ma
     buttonText: (state: MarkStoreState) => (state.countdownTime > 0 ? `${state.countdownTime}s` : '确定'),
   },
   actions: {
+    initMarkSpent() {
+      this.markSpent = {
+        startTime: Date.now(),
+        endTime: 0,
+      }
+    },
+    setMarkDelay(val: string) {
+      const markDelay = val ? (JSON.parse(val) as MarkDelayItem[]) : []
+      this.markDelay = markDelay
+    },
+    getMarkLevelSpeedLimit(score: number): number {
+      if (this.markDelay.length) {
+        const speedLimit = this.markDelay.find((limit) => {
+          return limit.endScore >= score && limit.startScore <= score
+        })
+        return speedLimit?.minMarkTime || 0
+      } else {
+        return 0
+      }
+    },
+    checkNeedCountDown(score: number) {
+      if (!this.markSpent.startTime) return
+      this.markSpent.endTime = Date.now()
+      const markLevelSpeedLimit = this.getMarkLevelSpeedLimit(score)
+      const waitTime = markLevelSpeedLimit - Math.round((this.markSpent.endTime - this.markSpent.startTime) / 1000)
+      this.startCountdown(Math.max(waitTime, 0))
+    },
+
     setCurQuestionScore(val: number | null) {
       this.curQuestionScore = val
     },