zhangjie 5 өдөр өмнө
parent
commit
7adbf516d2

+ 8 - 0
src/api/api-types/question.d.ts

@@ -66,6 +66,12 @@ export namespace Question {
     { mainNumber: number | string; mainTitle: string; questionList: SubQuestionStruct[] }
   >
 
+  export interface MarkLevelSpeedLimitItem {
+    startScore: number | undefined
+    endScore: number | undefined
+    minMarkTime: number | undefined
+  }
+
   /** 大题设置 (新增/编辑) */
   interface MainQuestionMeta {
     /** 大题号 */
@@ -102,6 +108,8 @@ export namespace Question {
     category?: QuestionCategory
     relationMainNumber?: any
     markSpeedLimit?: any
+    // 分数段评卷时间
+    markLevelSpeedLimitList?: MarkLevelSpeedLimitItem[]
   }
 
   /** 新增大题 */

+ 218 - 0
src/components/common/ScoreRangeTimeEditor.vue

@@ -0,0 +1,218 @@
+<template>
+  <div class="score-range-time-editor">
+    <div class="range-list">
+      <div v-for="(range, index) in ranges" :key="index" class="flex items-center range-row">
+        <div class="flex items-center range-input">
+          <el-input-number
+            v-model="range.startScore"
+            class="m-r-mini"
+            size="small"
+            :min="0"
+            :max="999"
+            :controls="false"
+            :precision="0"
+            style="width: 70px"
+            placeholder="起始分"
+            @change="validateRange(index)"
+          ></el-input-number>
+          <span class="m-r-mini m-l-mini">~</span>
+          <el-input-number
+            v-model="range.endScore"
+            class="m-r-mini"
+            size="small"
+            :min="0"
+            :max="999"
+            :controls="false"
+            :precision="0"
+            style="width: 70px"
+            placeholder="结束分"
+            @change="validateRange(index)"
+          ></el-input-number>
+          <span class="m-r-mini m-l-mini">分</span>
+        </div>
+        <div class="flex items-center time-input">
+          <el-input-number
+            v-model="range.minMarkTime"
+            class="m-r-mini"
+            size="small"
+            :min="1"
+            :max="9999"
+            :controls="false"
+            :precision="0"
+            style="width: 110px"
+            placeholder="最小评卷时间"
+            @change="validateRange(index)"
+          ></el-input-number>
+          <span class="m-r-mini">秒</span>
+        </div>
+        <div class="flex items-center actions">
+          <el-button type="danger" size="small" @click="removeRange(index)"> 删除 </el-button>
+        </div>
+      </div>
+      <div v-if="errorMessage" class="error-message">
+        {{ errorMessage }}
+      </div>
+    </div>
+    <div class="actions-bar">
+      <el-button type="primary" size="small" @click="addRange">+ 添加分数段</el-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="ScoreRangeTimeEditor">
+import { ref, watch, computed } from 'vue'
+import { ElInputNumber, ElButton } from 'element-plus'
+
+interface ScoreRange {
+  startScore: number | undefined
+  endScore: number | undefined
+  minMarkTime: number | undefined
+}
+
+interface Props {
+  modelValue?: ScoreRange[]
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: ScoreRange[]): void
+  (e: 'change', value: ScoreRange[]): void
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  modelValue: () => [],
+})
+
+const emit = defineEmits<Emits>()
+
+const ranges = ref<ScoreRange[]>(props.modelValue.length > 0 ? [...props.modelValue] : [])
+
+const errorMessage = ref<string>('')
+
+const isNull = (value: any) => value === null || value === undefined
+// 初始化分数段
+
+// 验证分数段是否有效
+const validateRange = (index: number) => {
+  const range = ranges.value[index]
+  errorMessage.value = ''
+
+  if (isNull(range.startScore) || isNull(range.endScore)) {
+    errorMessage.value = '请输入分数段'
+    return false
+  }
+
+  if (isNull(range.minMarkTime) || range.minMarkTime < 1) {
+    errorMessage.value = '最小评卷时间必须大于0'
+    return false
+  }
+
+  // 检查起始分是否大于结束分
+  if (!isNull(range.startScore) && !isNull(range.endScore)) {
+    if (range.startScore > range.endScore) {
+      errorMessage.value = '起始分不能大于结束分'
+      return false
+    }
+  }
+
+  // 检查是否与其他分数段交叉
+  for (let i = 0; i < ranges.value.length; i++) {
+    if (i === index) continue
+
+    const otherRange = ranges.value[i]
+    if (
+      !isNull(range.startScore) &&
+      !isNull(range.endScore) &&
+      !isNull(otherRange.startScore) &&
+      !isNull(otherRange.endScore)
+    ) {
+      // 检查是否有交叉
+      if (
+        (range.startScore >= otherRange.startScore && range.startScore <= otherRange.endScore) ||
+        (range.endScore >= otherRange.startScore && range.endScore <= otherRange.endScore) ||
+        (range.startScore <= otherRange.startScore && range.endScore >= otherRange.endScore)
+      ) {
+        errorMessage.value = '分数段不能交叉'
+        return false
+      }
+    }
+  }
+
+  return true
+}
+
+// 验证所有分数段
+const validateAllRanges = () => {
+  for (let i = 0; i < ranges.value.length; i++) {
+    if (!validateRange(i)) {
+      return false
+    }
+  }
+  return true
+}
+
+// 添加分数段
+const addRange = () => {
+  ranges.value.push({ startScore: undefined, endScore: undefined, minMarkTime: undefined })
+}
+
+// 删除分数段
+const removeRange = (index: number) => {
+  ranges.value.splice(index, 1)
+  errorMessage.value = ''
+}
+
+// 监听变化并向父组件发送更新
+watch(
+  ranges,
+  (newRanges) => {
+    // validateAllRanges()
+    emit('update:modelValue', [...newRanges])
+    emit('change', [...newRanges])
+  },
+  { deep: true }
+)
+
+// 监听外部传入的值变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (newValue && newValue.length > 0) {
+      ranges.value = [...newValue]
+    }
+  },
+  { deep: true }
+)
+
+defineExpose({
+  validateAllRanges,
+})
+</script>
+
+<style scoped lang="scss">
+.score-range-time-editor {
+  .range-list {
+    .range-row {
+      margin-bottom: 8px;
+
+      .range-input {
+        min-width: 200px;
+      }
+
+      .time-input {
+        margin-left: 20px;
+        min-width: 200px;
+      }
+
+      .actions {
+        margin-left: 20px;
+      }
+    }
+
+    .error-message {
+      color: #f56c6c;
+      font-size: 12px;
+      line-height: 20px;
+    }
+  }
+}
+</style>

+ 345 - 0
src/components/shared/ImageListPreview.vue

@@ -0,0 +1,345 @@
+<template>
+  <div
+    v-if="visible"
+    ref="imageListPreview"
+    v-customDialogResizeImg="resizeKey"
+    class="preview-custom-dialog"
+    :class="[resizeKey]"
+  >
+    <div class="preview-head">
+      <span>查看全卷</span>
+      <div class="head-btn-box flex justify-center items-center" @click="closeDialog">
+        <el-icon><close /></el-icon>
+      </div>
+    </div>
+    <div class="preview-body">
+      <div class="control-box">
+        <el-icon :size="30" color="#eee" class="zoom-icon" @click="add"><zoom-in /></el-icon>
+        <el-icon :size="30" color="#eee" class="zoom-icon" @click="minus"><zoom-out /></el-icon>
+      </div>
+
+      <!-- Tab导航 -->
+      <div class="tab-navigation">
+        <div class="tab-container">
+          <div
+            v-for="(image, index) in imageList"
+            :key="index"
+            class="tab-item"
+            :class="{ active: currentIndex === index }"
+            @click="switchImage(index)"
+          >
+            <span>第{{ index + 1 }}页</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 当前图片显示 -->
+      <div class="image-container">
+        <img
+          v-if="currentImageUrl"
+          v-show="!!currentImageUrl"
+          class="current-img small-img"
+          :src="currentImageUrl"
+          alt=""
+        />
+        <div v-else class="no-image">
+          <span>暂无图片</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="ImageListPreview">
+import useVModel from '@/hooks/useVModel'
+import { ref, watch, computed } from 'vue'
+import { Close, ZoomIn, ZoomOut } from '@element-plus/icons-vue'
+import { ElIcon, ElButton } from 'element-plus'
+import { localKeyMap } from '@/directives/customDialogResizeImg'
+
+interface ImageItem {
+  url: string
+  title?: string
+}
+
+const imageListPreview = ref()
+const currentIndex = ref(0)
+
+const add = () => {
+  const w = imageListPreview.value?.clientWidth
+  if (w > window.innerWidth) return
+  if (w) {
+    imageListPreview.value.style.width =
+      (Math.floor(w * 1.1) > window.innerWidth ? window.innerWidth : Math.floor(w * 1.1)) + 'px'
+    let l = parseFloat(imageListPreview.value?.style?.left)
+    l && (imageListPreview.value.style.left = (l - Math.floor(w * 0.05) < 0 ? 0 : l - Math.floor(w * 0.05)) + 'px')
+    localStorage.setItem(
+      localKeyMap.positions[props.resizeKey],
+      JSON.stringify({
+        left: l + 'px',
+        top: imageListPreview.value?.style?.top,
+      })
+    )
+    localStorage.setItem(
+      localKeyMap.resize[props.resizeKey],
+      JSON.stringify({
+        width: imageListPreview.value.style.width || 'auto',
+        height: imageListPreview.value?.style?.height || 'auto',
+      })
+    )
+  }
+}
+
+const minus = () => {
+  const w = imageListPreview.value?.clientWidth
+  if (w <= 290) return
+  if (w) {
+    imageListPreview.value.style.width = (Math.floor(w * 0.9) < 290 ? 290 : Math.floor(w * 0.9)) + 'px'
+    let l = parseFloat(imageListPreview.value?.style?.left)
+    l && (imageListPreview.value.style.left = l + Math.floor(w * 0.05) + 'px')
+    localStorage.setItem(
+      localKeyMap.positions[props.resizeKey],
+      JSON.stringify({
+        left: l + 'px',
+        top: imageListPreview.value?.style?.top,
+      })
+    )
+    localStorage.setItem(
+      localKeyMap.resize[props.resizeKey],
+      JSON.stringify({
+        width: imageListPreview.value.style.width || 'auto',
+        height: imageListPreview.value?.style?.height || 'auto',
+      })
+    )
+  }
+}
+
+const props = withDefaults(
+  defineProps<{
+    modelValue: boolean
+    imageList?: (string | ImageItem)[]
+    resizeKey?: string
+    defaultIndex?: number
+  }>(),
+  {
+    modelValue: false,
+    imageList: () => [],
+    resizeKey: 'can-resize-normal-img',
+    defaultIndex: 0,
+  }
+)
+
+const emit = defineEmits(['update:modelValue', 'change'])
+
+const visible = useVModel(props)
+
+// 标准化图片列表
+const imageList = computed(() => {
+  return props.imageList.map((item, index) => {
+    if (typeof item === 'string') {
+      return { url: item, title: `第${index + 1}页` }
+    }
+    return { ...item, title: item.title || `第${index + 1}页` }
+  })
+})
+
+// 当前图片URL
+const currentImageUrl = computed(() => {
+  return imageList.value[currentIndex.value]?.url || ''
+})
+
+// 切换图片
+const switchImage = (index: number) => {
+  if (index >= 0 && index < imageList.value.length) {
+    currentIndex.value = index
+    emit('change', index, imageList.value[index])
+  }
+}
+
+// 上一页
+const prevImage = () => {
+  if (currentIndex.value > 0) {
+    switchImage(currentIndex.value - 1)
+  }
+}
+
+// 下一页
+const nextImage = () => {
+  if (currentIndex.value < imageList.value.length - 1) {
+    switchImage(currentIndex.value + 1)
+  }
+}
+
+// 关闭对话框
+const closeDialog = () => {
+  visible.value = false
+}
+
+// 监听默认索引变化
+watch(
+  () => props.defaultIndex,
+  (newIndex) => {
+    if (newIndex >= 0 && newIndex < imageList.value.length) {
+      currentIndex.value = newIndex
+    }
+  },
+  { immediate: true }
+)
+
+// 监听图片列表变化
+watch(
+  () => props.imageList,
+  () => {
+    if (currentIndex.value >= imageList.value.length) {
+      currentIndex.value = Math.max(0, imageList.value.length - 1)
+    }
+  }
+)
+
+// 监听可见性变化
+watch(
+  () => visible.value,
+  (newVisible) => {
+    if (newVisible) {
+      currentIndex.value = props.defaultIndex
+    }
+  }
+)
+</script>
+
+<style scoped lang="scss">
+.preview-custom-dialog {
+  display: flex;
+  flex-direction: column;
+  position: fixed;
+  z-index: 500;
+  border-radius: 6px;
+  box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08);
+  max-width: 100vw;
+  max-height: 100vh;
+  overflow: auto;
+
+  .preview-head {
+    background-color: #f8f8f8;
+    border-radius: 6px 6px 0 0;
+    color: #333;
+    font-size: 14px;
+    height: 44px;
+    line-height: 44px;
+    padding: 0 10px;
+    position: relative;
+    flex-shrink: 0;
+
+    .head-btn-box {
+      position: absolute;
+      right: 0;
+      top: 0;
+      width: 44px;
+      height: 44px;
+      z-index: 1;
+      cursor: pointer;
+      &:hover {
+        :deep(i) {
+          color: $color--primary;
+        }
+      }
+    }
+  }
+
+  .preview-body {
+    flex: 1;
+    padding: 2px;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+    position: relative;
+
+    &:hover {
+      .control-box {
+        width: 42px;
+      }
+    }
+
+    .control-box {
+      transition: all 0.2s;
+      overflow: hidden;
+      width: 0;
+      display: flex;
+      flex-direction: column;
+      justify-content: space-around;
+      align-items: center;
+      position: absolute;
+      right: 10px;
+      top: 65px;
+      z-index: 10;
+      border-radius: 6px;
+      background: rgba(0, 0, 0, 0.7);
+      padding: 8px 0;
+      height: 100px;
+
+      .zoom-icon {
+        cursor: pointer;
+        &:hover {
+          color: #fff;
+        }
+      }
+    }
+
+    .tab-navigation {
+      flex-shrink: 0;
+      padding: 10px;
+      border-bottom: 1px solid #e4e7ed;
+
+      .tab-container {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 8px;
+        max-height: 120px;
+        overflow-y: auto;
+
+        .tab-item {
+          padding: 6px 12px;
+          border: 1px solid #dcdfe6;
+          border-radius: 4px;
+          cursor: pointer;
+          font-size: 12px;
+          color: #606266;
+          background-color: #fff;
+          transition: all 0.3s;
+
+          &:hover {
+            border-color: $color--primary;
+            color: $color--primary;
+          }
+
+          &.active {
+            background-color: $color--primary;
+            border-color: $color--primary;
+            color: #fff;
+          }
+        }
+      }
+    }
+
+    .image-container {
+      flex: 1;
+      overflow: auto;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background-color: #f5f5f5;
+
+      .current-img {
+        max-width: 100%;
+        max-height: 100%;
+        object-fit: contain;
+      }
+
+      .no-image {
+        color: #909399;
+        font-size: 14px;
+      }
+    }
+  }
+}
+</style>

+ 31 - 2
src/components/shared/ScoringPanel.vue

@@ -23,6 +23,7 @@
           :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"
@@ -32,6 +33,7 @@
           @enter="() => onEnter(index)"
           @focused="() => onFocused(index)"
           @toggle-click="onToggleClick"
+          @view-papers="() => onViewPapers()"
         ></scoring-panel-item>
       </template>
     </div>
@@ -70,6 +72,7 @@ const props = withDefaults(
     large?: boolean
     cannotToggle?: boolean
     loading?: boolean
+    showAllPaperBtn?: boolean
   }>(),
   {
     modal: false,
@@ -82,10 +85,11 @@ const props = withDefaults(
     large: true,
     cannotToggle: false,
     loading: false,
+    showAllPaperBtn: false,
   }
 )
 
-const emits = defineEmits(['submit', 'update:score', 'update:visible', 'update:modal'])
+const emits = defineEmits(['submit', 'update:score', 'update:visible', 'update:modal', 'view-papers'])
 const dialogModeBeforeSubmit = ref<boolean>(false)
 const dialogMode = ref<boolean>(props.modal)
 
@@ -154,7 +158,28 @@ const questionList = computed(() => {
     return []
   }
   const { mainNumber, mainTitle, questionList = [] } = questionStruct.value
-  return questionList.map((q) => ({ ...q, mainNumber, mainTitle }))
+
+  // 更新分数端评卷时间
+  const markLevelSpeedLimitList = questionStruct.value.markLevelSpeedLimitList
+    ? JSON.parse(questionStruct.value.markLevelSpeedLimitList)
+    : []
+  const getMarkLevelSpeedLimit = (score: number): number => {
+    if (markLevelSpeedLimitList.length) {
+      const speedLimit = item.markLevelSpeedLimitList.find((limit) => {
+        return limit.endScore >= score && limit.startScore <= score
+      })
+      return speedLimit?.minMarkTime || 0
+    } else {
+      return 0
+    }
+  }
+
+  return questionList.map((q) => ({
+    ...q,
+    mainNumber,
+    mainTitle,
+    markSpeedLimit: getMarkLevelSpeedLimit(q.totalScore),
+  }))
 })
 
 const allowSubmit = computed(() => {
@@ -209,6 +234,10 @@ const onBlur = (index: number) => {
   }
 }
 
+const onViewPapers = () => {
+  emits('view-papers')
+}
+
 const onToggleClick = () => {
   if (modalVisible.value) {
     dialogModeBeforeSubmit.value = dialogMode.value ? false : true

+ 79 - 4
src/components/shared/ScoringPanelItem.vue

@@ -44,12 +44,21 @@
           </div>
           <el-button
             v-if="!!props.showConfirmBtn"
-            :disabled="!props.allowSubmit"
+            :disabled="buttonDisabled"
             size="small"
             type="primary"
             style="min-width: 44px; margin-left: 5px; margin-bottom: 8px"
             @click="confirmWithBtn"
-            >确定</el-button
+            >{{ buttonText }}</el-button
+          >
+          <el-button
+            v-if="!!props.showAllPaperBtn"
+            v-permBtn="'analysis-marking_progress_province_export'"
+            type="primary"
+            link
+            style="min-width: 44px; margin-left: 20px; margin-bottom: 8px"
+            @click="onViewPapers"
+            >调全卷</el-button
           >
         </div>
       </toggle-dialog-render>
@@ -84,14 +93,16 @@
 </template>
 
 <script setup lang="ts" name="ScoringPanelItem">
-import { watch, computed, ref, nextTick, withDefaults, defineComponent, useSlots } from 'vue'
+import { watch, computed, ref, nextTick, withDefaults, defineComponent, useSlots, onUnmounted } 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'
@@ -103,6 +114,8 @@ interface QuestionInfo {
   subNumber: number
   totalScore: number
   intervalScore: number
+  // 当前试题最小评卷时间(秒)
+  markSpeedLimit?: number
 }
 
 const props = withDefaults(
@@ -120,6 +133,7 @@ const props = withDefaults(
     large?: boolean
     allowSubmit: boolean
     showConfirmBtn?: boolean
+    showAllPaperBtn?: boolean
     cannotToggle?: boolean
     id?: any
     loading?: any
@@ -129,6 +143,7 @@ const props = withDefaults(
     toggleModal: true,
     score: void 0,
     scoreValidFail: false,
+    showAllPaperBtn: false,
     large: true,
     cannotToggle: false,
     id: null,
@@ -136,7 +151,7 @@ const props = withDefaults(
   }
 )
 
-const emit = defineEmits(['focused', 'blur', 'toggle-click', 'enter', 'update:score'])
+const emit = defineEmits(['focused', 'blur', 'toggle-click', 'enter', 'update:score', 'view-papers'])
 
 const dialogMode = ref<boolean>(props.modal)
 
@@ -175,6 +190,60 @@ const focused = ref<boolean>(false)
 const refInput1 = ref<HTMLInputElement>()
 const refInput2 = ref<HTMLInputElement>()
 
+// 倒计时相关状态
+const countdownTime = ref<number>(0)
+const countdownTimer = ref<NodeJS.Timeout | null>(null)
+const isCountingDown = computed(() => countdownTime.value > 0)
+
+// 按钮文本和可点击状态
+const buttonText = computed(() => {
+  return isCountingDown.value ? `${countdownTime.value}s` : '确定'
+})
+
+const buttonDisabled = computed(() => {
+  return isCountingDown.value || !props.allowSubmit
+})
+
+// 启动倒计时
+const startCountdown = () => {
+  // 清除之前的计时器
+  if (countdownTimer.value) {
+    clearInterval(countdownTimer.value)
+    countdownTimer.value = null
+  }
+
+  const markSpeedLimit = question.value.markSpeedLimit
+  if (!markSpeedLimit || markSpeedLimit <= 0) {
+    countdownTime.value = 0
+    return
+  }
+
+  countdownTime.value = markSpeedLimit
+  countdownTimer.value = setInterval(() => {
+    countdownTime.value--
+    if (countdownTime.value <= 0) {
+      clearInterval(countdownTimer.value!)
+      countdownTimer.value = null
+    }
+  }, 1000)
+}
+
+// 监听question变化,重新启动倒计时
+watch(
+  () => props.question,
+  () => {
+    startCountdown()
+  },
+  { immediate: true, deep: true }
+)
+
+// 组件卸载时清除计时器
+onUnmounted(() => {
+  if (countdownTimer.value) {
+    clearInterval(countdownTimer.value)
+  }
+})
+
 watch(
   () => props.active,
   () => {
@@ -202,6 +271,7 @@ const getClass = (val: string, callback?: string) => {
 const onSetScore = (v: number | string) => {
   onInputFocus()
   currentScore.value = v
+  markStore.setCurQuestionScore(currentScore.value === '' ? null : Number(currentScore.value))
 }
 
 const onInputFocus = () => {
@@ -256,6 +326,10 @@ const joinStringChart = (str: string, index: number, chart: string) => {
 }
 const confirmWithBtn = () => {
   emit('enter')
+  markStore.setCurQuestionScore(null)
+}
+const onViewPapers = () => {
+  emit('view-papers')
 }
 const onValidScore = (e: any) => {
   const target = e.target as HTMLInputElement
@@ -287,6 +361,7 @@ const onValidScore = (e: any) => {
     if (oldScore && !scoreStrictValidFail(oldScore)) {
       nextTick(() => {
         emit('enter')
+        markStore.setCurQuestionScore(null)
       })
     }
     e.preventDefault()

+ 16 - 0
src/components/shared/ScoringPanelWithConfirm.vue

@@ -9,7 +9,9 @@
     :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>
@@ -36,6 +38,11 @@
       </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">
@@ -44,6 +51,7 @@ import { add } from '@/utils/common'
 import { ElButton } from 'element-plus'
 import ScoringPanel from '@/components/shared/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'
@@ -64,6 +72,8 @@ const props = defineProps<{
   cannotToggle?: boolean
   id?: any
   loading?: any
+  imageList?: string[]
+  showAllPaperBtn?: boolean
 }>()
 
 const attrs = useAttrs()
@@ -153,6 +163,12 @@ const onConfirmSubmit = () => {
   emit('submit', { question: questionInfo.value, scores: confirmScoreArr, totalScore: totalScore.value })
   modelScore.value = []
 }
+
+// 看全卷
+const previewModalVisible = ref<boolean>(false)
+const onViewPapers = () => {
+  previewModalVisible.value = true
+}
 </script>
 
 <style scoped lang="scss">

+ 18 - 0
src/modules/admin-subject/edit-main-question/index.vue

@@ -34,6 +34,9 @@
             <el-option value="0" label="不启用"></el-option>
           </el-select>
         </template>
+        <template #form-item-levelMarkTime>
+          <score-range-time-editor ref="scoreRangeTimeEditorRef" v-model="model.markLevelSpeedLimitList" />
+        </template>
       </base-form>
     </el-card>
   </div>
@@ -77,6 +80,8 @@ import { omit } from 'lodash-es'
 import ConfirmButton from '@/components/common/ConfirmButton.vue'
 import BaseDialog from '@/components/element/BaseDialog.vue'
 import BaseForm from '@/components/element/BaseForm.vue'
+import ScoreRangeTimeEditor from '@/components/common/ScoreRangeTimeEditor.vue'
+
 import useFetch from '@/hooks/useFetch'
 import useForm from '@/hooks/useForm'
 import useVW from '@/hooks/useVW'
@@ -91,6 +96,7 @@ const props = defineProps<{ subjectCode: string; mainNumber?: number | string }>
 getMainQuestionList({ subjectCode: props.subjectCode, excludeNumber: props.mainNumber || undefined })
 
 const isEdit = !!props.mainNumber
+const scoreRangeTimeEditorRef = ref<InstanceType<typeof ScoreRangeTimeEditor>>()
 
 const mainQuestionOptions = computed(() => {
   return (
@@ -157,6 +163,7 @@ const model = reactive<ExtractApiParams<'addMainQuestion'>>({
   startNumber: 1,
   relationMainNumber: void 0,
   markSpeedLimit: false,
+  markLevelSpeedLimitList: [],
 })
 const compareChange = (val: string) => {
   if (val == '0') {
@@ -229,6 +236,7 @@ const groups = computed<FormGroup[]>(() => {
 })
 
 const Span8 = defineColumn(_, _, { span: 8 })
+const Span16 = defineColumn(_, _, { span: 16 })
 
 const items = computed<any>(() =>
   [
@@ -398,6 +406,13 @@ const items = computed<any>(() =>
       },
       'row-14'
     ),
+    Span16(
+      {
+        label: '分数段评卷时间',
+        slotName: 'levelMarkTime',
+      },
+      'row-14'
+    ),
     compare.value == '1'
       ? Span8(
           {
@@ -444,6 +459,8 @@ const onSetLevelRangeSubmit = () => {
 
 const onSubmit = async () => {
   try {
+    if (!scoreRangeTimeEditorRef.value?.validateAllRanges()) return
+
     const valid = await elFormRef?.value?.validate().catch((error: object) => {
       if (
         !expand.value &&
@@ -457,6 +474,7 @@ const onSubmit = async () => {
         { ...model, levelRange: model.levelRange || [], category: model.category || void 0 },
         { remarkNumber: model.remarkType === 'TIME' ? (model.remarkNumber || 0) * 60 : model.remarkNumber }
       )
+      data.markLevelSpeedLimitList = JSON.stringify(data.markLevelSpeedLimitList || [])
       await (isEdit ? editMainQuestion(data) : addMainQuestion(data))
       ElMessage.success('保存成功')
       back()

+ 74 - 5
src/modules/analysis/personnel-statistics/components/StatisticsGroup.vue

@@ -12,7 +12,61 @@
       highlight-current-row
       @current-change="onCurrentChange"
       @sort-change="sortChange"
+      @row-dblclick="onRowDblHandle"
     >
+      <template #column-marker="{ row }">
+        <span v-if="row.markerId">
+          <span
+            :style="{
+              backgroundColor: row.paused ? '#E6A23C' : row.online ? '#00B42A' : '#ddd',
+              display: 'inline-block',
+              width: '10px',
+              height: '10px',
+              marginRight: '4px',
+              borderRadius: '2px',
+            }"
+          ></span>
+          {{ row.markerName }}
+        </span>
+        <span v-else-if="row.markingGroupNumber == 0">全体</span>
+        <span v-else title="双击展开本组">
+          <template v-if="row.isGroupTotalRow">
+            <el-tooltip effect="dark" content="双击收缩本组" placement="top">
+              <el-button size="small" link @click.stop="onExpendRow(row)">
+                <minus
+                  style="
+                    width: 1em;
+                    height: 1em;
+                    margin-right: 5px;
+                    margin-top: -3px;
+                    vertical-align: middle;
+                    color: #0091ff;
+                  "
+                />
+              </el-button>
+            </el-tooltip>
+            <span>全部</span>
+          </template>
+          <template v-else>
+            <el-tooltip effect="dark" content="双击展开本组" placement="top">
+              <el-button size="small" link @click.stop="onExpendRow(row)">
+                <plus
+                  style="
+                    width: 1em;
+                    height: 1em;
+                    margin-right: 5px;
+                    margin-top: -3px;
+                    vertical-align: middle;
+                    color: #0091ff;
+                  "
+                />
+              </el-button>
+            </el-tooltip>
+
+            <span>第{{ row.markingGroupNumber }}组</span>
+          </template>
+        </span>
+      </template>
     </base-table>
   </div>
   <!-- <div v-if="!!current" v-loading="loading1 || loading2" class="flex justify-between m-t-base charts-box">
@@ -29,6 +83,7 @@
 <script setup lang="tsx" name="StatisticsGroup">
 /** 人员数据统计-按小组 */
 import { watch, computed, ref } from 'vue'
+import { Plus, Minus } from '@element-plus/icons-vue'
 import BaseTable from '@/components/element/BaseTable.vue'
 import VueECharts from 'vue-echarts'
 import useVW, { usePX } from '@/hooks/useVW'
@@ -45,8 +100,11 @@ const props = defineProps<{
   filterColumns?: any
   result?: any
 }>()
+const emits = defineEmits<{
+  (e: 'expend-row', value: number): void
+}>()
 const rowClassName = (obj: any) => {
-  if (obj.row.markingGroupNumber === 0) {
+  if (obj.row.markingGroupNumber === 0 || obj.row.isGroupTotalRow) {
     return 'fixed-row'
   }
 }
@@ -69,7 +127,11 @@ const columns = computed(() => {
       width: 60,
       fixed: 'left',
       formatter(row: any) {
-        return row.markingGroupNumber === 0 ? '全部' : `${row.markingGroupNumber}`
+        return row.markingGroupNumber === 0
+          ? '全部'
+          : row.isGroupTotalRow
+          ? `第${row.markingGroupNumber}组`
+          : `${row.markingGroupNumber}`
       },
     },
     {
@@ -78,9 +140,6 @@ const columns = computed(() => {
       minWidth: 100,
       fixed: 'left',
       slotName: 'marker',
-      formatter(row: any) {
-        return row.markingGroupNumber === 0 ? '全体' : `第${row.markingGroupNumber}组`
-      },
     },
     { align: 'center', label: '份数', prop: 'markingPaperCount', minWidth: 84 },
     { align: 'center', label: '平均分', prop: 'avg', minWidth: 66 },
@@ -250,6 +309,16 @@ const sortChange = (params: any) => {
 //   return data
 // })
 
+const onExpendRow = (row: any) => {
+  emits('expend-row', row.markingGroupNumber)
+}
+
+const onRowDblHandle = (row: any) => {
+  if (row.markerDetails) {
+    onExpendRow(row)
+  }
+}
+
 const {
   fetch: getStatisticObjectiveByGroup,
   result: objectiveByGroup,

+ 23 - 1
src/modules/analysis/personnel-statistics/index.vue

@@ -31,6 +31,7 @@
         :params="fetchModel"
         :filter-columns="filterColumns"
         :result="result"
+        @expend-row="onExpendRow"
       ></component>
     </div>
     <base-dialog
@@ -115,6 +116,15 @@ let columnsByLocal = getUserConfigByType('personnelStatisticsColumns')
 const checkedList = columnsByLocal ? ref(columnsByLocal) : ref(JSON.parse(JSON.stringify(labelList)))
 const checkboxList = ref(labelList.map((item: any) => ({ value: item, content: columnMap[item] })))
 const filterColumns = ref(JSON.parse(JSON.stringify(checkedList.value)))
+const expendGroupNumbers = ref<number[]>([])
+
+function onExpendRow(markingGroupNumber: number) {
+  if (expendGroupNumbers.value.includes(markingGroupNumber)) {
+    expendGroupNumbers.value = expendGroupNumbers.value.filter((v) => v !== markingGroupNumber)
+  } else {
+    expendGroupNumbers.value.push(markingGroupNumber)
+  }
+}
 let initCheckedList: any = []
 function dialogOpen() {
   initCheckedList = checkedList.value
@@ -207,6 +217,10 @@ const data = computed<ExtractApiResponse<'getStatisticsByGroup'>>(() => {
   let expandData = groupList
     .filter((v) => v.markingGroupNumber !== 0)
     .reduce((total, cur) => {
+      if (!fetchModel.value.expand && !expendGroupNumbers.value.includes(cur.markingGroupNumber)) {
+        total.push({ ...cur, isGroupTotalRow: false })
+        return total
+      }
       total.push({ ...cur, isGroupTotalRow: true })
       let details = cur.markerDetails || []
       details.sort((a: any, b: any) => a.markerId - b.markerId)
@@ -215,9 +229,10 @@ const data = computed<ExtractApiResponse<'getStatisticsByGroup'>>(() => {
     }, [] as ExtractApiResponse<'getStatisticsByGroup'>)
     .concat(totalIndex >= 0 ? groupList[totalIndex] : [])
 
+  // console.log('expandData', expandData)
   return fetchModel.value.expand
     ? expandData
-    : groupList.sort((a: any, b: any) => (a.markingGroupNumber as number) - (b.markingGroupNumber as number))
+    : expandData.sort((a: any, b: any) => (a.markingGroupNumber as number) - (b.markingGroupNumber as number))
 })
 
 /** 刷新按钮 */
@@ -280,6 +295,13 @@ watchEffect(() => {
     pause()
   }
 })
+watch(
+  () => fetchModel.value.expand,
+  (val) => {
+    expendGroupNumbers.value = []
+  }
+)
+
 onBeforeUnmount(() => {
   pause()
 })

+ 8 - 1
src/modules/marking/inquiry-result/index.vue

@@ -63,6 +63,8 @@
             :subject-code="query.subjectCode"
             modal
             :auto-visible="false"
+            :image-list="currentImageList"
+            show-all-paper-btn
             @submit="onSubmit"
           ></scoring-panel-with-confirm>
         </pane>
@@ -165,7 +167,7 @@
 import { ref, computed, watch, reactive, nextTick, provide } from 'vue'
 import { useRoute } from 'vue-router'
 import { ElButton, ElPagination, ElMessage } from 'element-plus'
-import { add } from '@/utils/common'
+import { add, getTaskImageUrl } from '@/utils/common'
 import { useSetImgBg } from '@/hooks/useSetImgBg'
 import useFetch from '@/hooks/useFetch'
 import useTable from '@/hooks/useTable'
@@ -571,6 +573,11 @@ onRefresh()
 const rejectIds = computed(() => {
   return isMult.value ? multipleSelection.value.map((item: any) => item.taskId) : [current.value?.taskId]
 })
+
+// full paper view
+const currentImageList = computed(() => {
+  return getTaskImageUrl(current.value?.filePath)
+})
 </script>
 
 <style scoped lang="scss">

+ 28 - 2
src/modules/marking/mark/index.vue

@@ -44,6 +44,9 @@
         <span class="preview" :style="{ left: previewLeft }" @click="onPreview">
           <svg-icon name="preview"></svg-icon>
         </span>
+        <div v-if="markStore.curQuestionScore !== null" class="img-score" :style="imgScoreStyle">
+          {{ markStore.curQuestionScore }}
+        </div>
         <div ref="imgWrap" :class="{ 'text-center': center }" class="img-wrap scroll-auto">
           <img
             ref="paperImg"
@@ -129,7 +132,7 @@
 
 <script setup lang="ts" name="MarkingMark">
 /** 阅卷-正式评卷 */
-import { computed, nextTick, ref, watch, onBeforeUnmount, unref } from 'vue'
+import { computed, nextTick, ref, watch, onBeforeUnmount, unref, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { ElButton, ElRadioGroup, ElRadioButton, ElRadio, ElMessage, ElDialog } from 'element-plus'
 import { minus } from '@/utils/common'
@@ -157,6 +160,9 @@ import { preloadImg } from '@/utils/common'
 import type { SetImgBgOption } from '@/hooks/useSetImgBg'
 import type { ExtractApiResponse } from '@/api/api'
 import type { MarkHeaderInstance } from 'global-type'
+import useMarkStore from '@/store/mark'
+
+const markStore = useMarkStore()
 const mainLayoutStore = useMainLayoutStore()
 const mainStore = useMainStore()
 const { push, replace } = useRouter()
@@ -662,9 +668,16 @@ const previewLeft = computed(() => {
   return currentTaskType.value === 'SAMPLE_A' || currentTaskType.value === 'SAMPLE_B' ? '124px' : '96px'
 })
 // const previewLeft = ref('-1000px')
-const imgLoaded = () => {
+const imgScoreLeft = ref(400)
+const imgScoreStyle = computed(() => {
+  return {
+    left: imgScoreLeft.value + 'px',
+  }
+})
+const imgLoaded = (e: Event) => {
   // previewLeft.value = paperImg.value.width - 40 + 'px'
   showPreviewBtn.value = true
+  imgScoreLeft.value = (e.target as HTMLElement).offsetWidth - 80
 }
 
 const handleTaskPool = () => {
@@ -672,6 +685,9 @@ const handleTaskPool = () => {
     preloadImg(task?.url)
   })
 }
+onUnmounted(() => {
+  markStore.setCurQuestionScore(null)
+})
 </script>
 
 <style scoped lang="scss">
@@ -697,6 +713,16 @@ const handleTaskPool = () => {
       // height: 100%;
       // overflow: auto;
     }
+
+    .img-score {
+      position: absolute;
+      top: 38px;
+      left: 400px;
+      font-size: 40px;
+      color: red;
+      font-weight: 600;
+      z-index: 100;
+    }
     .mark-status {
       display: inline-block;
       // width: 24px;

+ 13 - 0
src/modules/marking/problem/index.vue

@@ -361,6 +361,19 @@ const imgOption = computed<SetImgBgOption>(() => {
 })
 
 const { drawing, dataUrl } = useSetImgBg(imgOption, frontColor, setFrontColor)
+
+// const currentProblemImageList = computed(() => {
+//   const filePath = currentProblem.value?.filePath
+//   if (!filePath) {
+//     return []
+//   }
+//   // /file/slice/1/1/10101/1/013/1/101011221201303-1.jpg
+//   const [fbasename, fext] = filePath.split('.')
+//   const fbname = fbasename || ''
+//   const fpath = fbname.substring(0, fbname.length - 2)
+//   if (!fpath) return []
+//   return [`${fpath}-1.${fext}`, `${fpath}-2.${fext}`]
+// })
 </script>
 
 <style scoped lang="scss">

+ 24 - 0
src/store/mark.ts

@@ -0,0 +1,24 @@
+import { defineStore } from 'pinia'
+
+interface MarkStoreState {
+  // 前端评卷试题的分值
+  curQuestionScore: number | null
+}
+
+interface MarkStoreActions {
+  setCurQuestionScore: (val: number | null) => void
+}
+const useMarkStore = defineStore<'mark', MarkStoreState, Record<string, any>, MarkStoreActions>('mark', {
+  state() {
+    return {
+      curQuestionScore: null,
+    }
+  },
+  actions: {
+    setCurQuestionScore(val: number | null) {
+      this.curQuestionScore = val
+    },
+  },
+})
+
+export default useMarkStore

+ 12 - 0
src/utils/common.ts

@@ -333,3 +333,15 @@ export const setUserConfigByType = (type: string, value: any) => {
     localStorage.set('USER_CONFIG', userConfig)
   }
 }
+
+export const getTaskImageUrl = (filePath: string) => {
+  if (!filePath) {
+    return []
+  }
+  // /file/slice/1/1/10101/1/013/1/101011221201303-1.jpg
+  const [fbasename, fext] = filePath.split('.')
+  const fbname = fbasename || ''
+  const fpath = fbname.substring(0, fbname.length - 2)
+  if (!fpath) return []
+  return [`${fpath}-1.${fext}`, `${fpath}-2.${fext}`]
+}