Selaa lähdekoodia

Merge remote-tracking branch 'origin/refactor-evaluation' into refactor-admin

白白 1 vuosi sitten
vanhempi
commit
05dac8542b

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 469 - 160
package-lock.json


+ 4 - 1
package.json

@@ -14,7 +14,9 @@
   "dependencies": {
     "@onlyoffice/document-editor-vue": "^1.4.0",
     "@vue-office/docx": "^1.6.2",
+    "@vueup/vue-quill": "^1.0.0-alpha.40",
     "axios": "^1.7.2",
+    "dayjs": "^1.11.13",
     "docx-preview": "^0.3.2",
     "element-plus": "^2.8.1",
     "mitt": "^3.0.1",
@@ -22,7 +24,8 @@
     "pinia-plugin-persistedstate": "^3.2.1",
     "vue": "^3.4.29",
     "vue-demi": "^0.14.10",
-    "vue-router": "^4.3.3"
+    "vue-router": "^4.3.3",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@rushstack/eslint-patch": "^1.8.0",

+ 50 - 0
src/apis/evaluation.ts

@@ -0,0 +1,50 @@
+import axiosInstance from "@/apis/axios.config";
+
+const EVALUATION_PREFIX = "/api/evaluation"
+
+const evaluationApis = {
+    overAllEvaluation(assignmentId: number, studentId: number){
+        return axiosInstance.post(`${EVALUATION_PREFIX}/overall`, {}, {
+            params: {
+                assignmentId,
+                studentId
+            }
+        })
+    },
+    sentenceEvaluation(assignmentId: number, studentId: number){
+        return axiosInstance.post(`${EVALUATION_PREFIX}/sentence`, {}, {
+            params: {
+                assignmentId,
+                studentId
+            }
+        })
+    },
+    getOverall(assignmentId: number, studentId: number, version: number){
+        return axiosInstance.get(`${EVALUATION_PREFIX}/select/overall`, {
+            params: {
+                assignmentId,
+                studentId,
+                version
+            }
+        })
+    },
+    getSentence(assignmentId: number, studentId: number, version: number){
+        return axiosInstance.get(`${EVALUATION_PREFIX}/select/sentence`, {
+            params: {
+                assignmentId,
+                studentId,
+                version
+            }
+        })
+    },
+    getEvaluationVersionList(assignmentId: number, studentId: number){
+        return axiosInstance.get(`${EVALUATION_PREFIX}/record/list`, {
+            params: {
+                assignmentId,
+                studentId
+            }
+        })
+    },
+}
+
+export default evaluationApis

+ 3 - 1
src/apis/index.ts

@@ -1,4 +1,3 @@
-import authApis from "@/apis/auth";
 import userApis from "@/apis/user";
 import testApis from "@/apis/test";
 import editorApis from "@/apis/editor";
@@ -8,7 +7,9 @@ import engagementApis from "@/apis/engagement";
 import assignmentApis from "@/apis/assignment";
 import courseApis from "@/apis/course";
 import fileApis from "@/apis/file";
+import evaluationApis from "@/apis/evaluation";
 import emailApis from "@/apis/email";
+import authApis from "@/apis/auth";
 
 
 const apis = {
@@ -22,6 +23,7 @@ const apis = {
     ...assignmentApis,
     ...courseApis,
     ...fileApis,
+    ...evaluationApis,
     ...emailApis
 }
 

+ 3 - 3
src/components/AIChatter/AIChatter.vue

@@ -199,7 +199,7 @@
   watch(() => props.messages, (newMessages:any) => {
     if (newMessages)
       data.messages = newMessages;
-    console.log(newMessages)
+    // console.log(newMessages)
     handleMessageList()
     nextTick(scrollToEnd);
   });
@@ -282,7 +282,7 @@
 
   // 处理ai对话显示异常问题
   const handleMessageList = () => {
-    console.log(data.messages)
+    // console.log(data.messages)
     let count1 = 0
     let count2 = 1
     let userNum = 0
@@ -324,7 +324,7 @@
         }
       }
     }
-    console.log(messageList)
+    // console.log(messageList)
   }
 
   // const recordKeyUp = (event:any) => {

+ 59 - 62
src/components/QuillEditor/Editor.vue

@@ -5,7 +5,6 @@
                  v-model:content="data.content"
                  :options="data.editorOption"
                  contentType="delta"
-                 @update:content="setValue()"
     />
   </div>
 </template>
@@ -22,7 +21,6 @@
 
   const content = ref('')
   const myQuillEditorRef = ref()
-  const fileBtn = ref()
 
   const data = reactive({
     content: ``,
@@ -45,50 +43,83 @@
     }
   })
 
-  // 抛出更改内容
-  const setValue = () => {
-    const html = toRaw(myQuillEditorRef.value).getHTML()
-    emitter.emit('get-html', html)
-    const text = toRaw(myQuillEditorRef.value).getText()
-    emitter.emit('get-text', text)
-    const content1 = toRaw(myQuillEditorRef.value).getContents()
-    emitter.emit('get-quill-content', content1)
+  /**
+   * 合并所有insert内容,因为存在attribute的缘故,原内容被分割成很多个insert
+   * @param ops
+   */
+  const processOps = (ops) => {
+    let combinedInsert = '';
+    ops.forEach(item => {
+      if (item.insert) {
+        combinedInsert += item.insert;
+      }
+    });
+    return combinedInsert
   }
 
 
   //初始化Quill及行为监控
   const initQuill = () => {
+
+    // 获取quill
     const quill = toRaw(myQuillEditorRef.value).getQuill()
+    // 加入工具栏
     if (myQuillEditorRef.value) {
       quill.getModule('toolbar')
     }
 
     // 文本变化监控
     quill.on(Quill.events.TEXT_CHANGE, (...args) => {
-      console.log("文本变化监控")
-      console.log(args)
+      const [delta, oldDelta, source] = args;
+      const deleteOps = delta.ops.filter(op => 'delete' in op);
+      // 直接将 ops 数组中的属性合并到一个对象
+      const opsObj = {};
+      delta.ops.forEach(op => {
+        Object.assign(opsObj, op);
+      });
+      delete opsObj.attributes;
+
       Object.assign(props.onTextChangeRef, {...args})
-      emitter.emit('change-record', {type: "TEXT_CHANGE", ...args["0"], time: dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss.SSS")})
-    })
+
+      if(deleteOps.length > 0){ //删除操作
+        // 获取删除操作的位置和长度
+        const deleteStart = delta.ops.find(op => 'delete' in op).retain || 0;   // 删除起始位置
+        const deleteLength = delta.ops.find(op => 'delete' in op).delete;       // 删除长度
+        const oldContent = processOps(args["1"].ops)// 原内容
+        // 提取被删除的具体内容
+        const deletedContent = oldContent.slice(deleteStart, deleteStart + deleteLength);
+
+        emitter.emit('change-record', {
+          type: "delete",
+          retain: 0,
+          ...opsObj,
+          oldContent,
+          deletedContent,
+          docLength: quill.getLength() - 1,
+          letterCount: (quill.getText().match(/[a-zA-Z\d\u4e00-\u9fa5]/g) || []).length
+        })
+      } else {
+        emitter.emit('change-record', {
+          type: "insert",
+          retain: 0,
+          ...opsObj,
+          docLength: quill.getLength() - 1,
+          letterCount: (quill.getText().match(/[a-zA-Z\d\u4e00-\u9fa5]/g) || []).length,
+          key: args["0"].ops.find(op => 'insert' in op).insert,
+        })
+      }
+    });
 
     // 光标变化监控
     quill.on(Quill.events.SELECTION_CHANGE, (...args) => {
-      console.log("光标变化监控")
-      console.log(args)
       Object.assign(props.onSelectionChangeRef, {...args})
-      emitter.emit('change-record', {type: "SELECTION_CHANGE", ...args["0"], time: dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss.SSS")})
+      emitter.emit('change-record', {
+        type: "cursor_move",
+        retain: args["0"].index,  // 将 index 属性重命名为 retain
+        length: args["0"].length, // 保留 length 属性
+        time: dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss.SSS")
+      });
     });
-
-    // 粘贴行为监控
-    quill.root.addEventListener('paste', function (event) {
-      console.log("粘贴行为监控")
-      var clipboardData = event.clipboardData;
-      if (clipboardData && clipboardData.types && clipboardData.types.includes('text/plain')) {
-        var pastedText = clipboardData.getData('text/plain');
-        console.log('粘贴的纯文本内容:', pastedText);
-        emitter.emit('change-record', {type: "COPY", ops: pastedText, time: dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss.SSS")})
-      }
-    })
   }
 
 // 初始化编辑器
@@ -96,40 +127,6 @@
     initQuill()
   });
 
-  /**
-   * 暂不启用
-   * 用于处理图片
-   * @param state
-   */
-  const imgHandler = (state) => {
-    if (state) {
-      fileBtn.value.click()
-    }
-  }
-
-  /**
-   * 暂不启用
-   * 用于文件上传
-   * @param e
-   */
-  const handleUpload = (e) => {
-    const files = Array.prototype.slice.call(e.target.files)
-    if (!files) {
-      return
-    }
-    const formdata = new FormData()
-    formdata.append('file', files[0])
-    backsite.uploadFile(formdata)  // 此处使用服务端提供上传接口
-        .then(res => {
-          if (res.data.url) {
-            const quill = toRaw(myQuillEditor.value).getQuill()
-            const length = quill.getSelection().index
-            quill.insertEmbed(length, 'image', res.data.url)
-            quill.setSelection(length + 1)
-          }
-        })
-  }
-
 </script>
 
 <style scoped lang="scss">

+ 215 - 51
src/components/QuillEditor/QuillEditor.vue

@@ -4,62 +4,226 @@
   Created Date: 2025-1-8
 -->
 <template>
-  <div class="editor-container">
-    <Editor :onTextChangeRef="lastChange" :onSelectionChangeRef="range" :onGetHTML="getHTML" :onGetText="getText" @updateValue="getMsg"/>
+  <div class="editor-container" @keydown="handleKeydown" @keyup="handleKeyup">
+    <Editor :onTextChangeRef="lastChange" :onSelectionChangeRef="range" :onGetHTML="getHTML" :onGetText="getText"/>
+    <el-button @click="download">导出</el-button>
   </div>
 </template>
 
 <script setup lang="ts">
-import "./QuillEditor.scss"
-import Editor from './Editor.vue';
-import { onUnmounted, reactive, ref, watch } from 'vue';
-import emitter from '@/utils/emitter';
-// import * as XLSX from 'xlsx'
-
-let range = reactive({})
-let lastChange = reactive({})
-let getHTML = ref("")
-let getText = ref("")
-let getQuillContent = reactive({})
-// 行为记录
-let changeRecordList = reactive([])
-
-
-const emailForm = reactive({
-  test_msg: '1'
-})
-
-const getMsg = (val) => {
-  emailForm.test_msg = val
-}
+  import "./QuillEditor.scss"
+  import Editor from './Editor.vue';
+  import { onUnmounted, reactive, ref, watch } from 'vue';
+  import emitter from '@/utils/emitter';
+  import dayjs from "dayjs";
+  import * as XLSX from 'xlsx'
+
+  let range = reactive({})
+  let lastChange = reactive({})
+  let getHTML = ref("")
+  let getText = ref("")
+  let getQuillContent = reactive({})
+
+  // 新增:用于存储每个按键的down事件信息
+  let keyEventMap = reactive({})
+  let keyEventRecordList = reactive([])
+  // 行为记录
+  let changeRecordList = reactive([])
+
+  emitter.on('change-record', (value) => {
+    changeRecordList.push(value)
+  })
+
+  const handleKeydown = (event: KeyboardEvent) => {
+    // 增加对Shift组合键的识别
+    // 在handleKeyup中增加对组合键的识别
+    let key = event.key
+    if (event.shiftKey && /^[0-9]$/.test(key)) {
+      key = "!@#$%^&*()"[parseInt(key) - 1]; // 映射Shift+数字到符号
+    } else if(event.shiftKey && /^[a-z]$/i.test(event.key) ){
+      key = event.key.toUpperCase()
+    }
+
+    // 存储down事件信息到keyEventMap中
+    if (!keyEventMap[key]) {
+      keyEventMap[key] = [];
+    }
+
+    // 将每次down事件信息存入数组
+    keyEventMap[key].push({
+      downKey: event.key,
+      startStamp: event.timeStamp,
+      startTime: dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss.SSS")
+    });
+
+  }
+
+  function formatMilliseconds(milliseconds) {
+    // 把毫秒数转换为秒
+    const totalSeconds = Math.floor(milliseconds / 1000);
+    // 计算小时数
+    const hours = Math.floor(totalSeconds / 3600);
+    // 计算剩余的秒数
+    const remainingSecondsAfterHours = totalSeconds % 3600;
+    // 计算分钟数
+    const minutes = Math.floor(remainingSecondsAfterHours / 60);
+    // 计算最终的秒数
+    const seconds = remainingSecondsAfterHours % 60;
+
+    // 格式化小时、分钟和秒,不足两位前面补 0
+    const formattedHours = String(hours).padStart(2, '0');
+    const formattedMinutes = String(minutes).padStart(2, '0');
+    const formattedSeconds = String(seconds).padStart(2, '0');
+
+    // 返回格式化后的字符串
+    return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
+  }
+
+  const getPause = (i: number) => {
+    let result = 0
+    for(let k = i-1; k >= 0; k--){
+      if('startStamp' in changeRecordList[k]){
+        result = changeRecordList[k].startStamp
+        break
+      }
+    }
+    return result
+  }
+
+  const handleKeyup = (event: KeyboardEvent) => {
+    // console.log("keyup")
+    // console.log(event)
+    const realTime = dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss.SSS")
+    const key = event.key;
+    // 处理所有与该按键key有关的keydown行为
+    while (keyEventMap[key] && keyEventMap[key].length > 0) {
+      // 取得keydown事件信息
+      const downEvent = keyEventMap[key].pop();
+      // console.log("keydown事件存储的信息")
+      // console.log(downEvent)
+      // 将keydown信息与keyup整合
+      const combinedEvent = {
+        timeKey: key,
+        startStamp: downEvent.startStamp | 0,
+        startClock: formatMilliseconds(downEvent.startStamp | 0),
+        startTime: downEvent.startTime,
+        endStamp: event.timeStamp | 0,
+        endClock: formatMilliseconds(event.timeStamp | 0),
+        endTime: realTime,
+        actionTime: (event.timeStamp | 0) - (downEvent.startStamp | 0)
+      };
+      // 将得到的key事件的完整信息保存
+      keyEventRecordList.push(combinedEvent);
+
+      // 根据key的信息对行为进行分析记录
+      // 包括
+      // 1.单个字母的键入
+      // 2.删除按键
+      // 3.复制
+      // 4.粘贴
+      // 5.换行
+
+      for (let i = changeRecordList.length - 1; i >= 0; i--) {
+        // 删除按键的记录
+        if (key === 'Backspace') {
+          if (changeRecordList[i].type === 'delete') {
+            changeRecordList[i] = {
+              ...changeRecordList[i],
+              ...combinedEvent,
+              pauseTime: combinedEvent.startStamp - getPause(i)
+            }
+            break
+          } else {
+            continue
+          }
+
+        }
+        // 粘贴
+        else if ((key === 'c' || key === 'C') && event.ctrlKey === true) {
+          changeRecordList[i] = {
+            ...changeRecordList[i],
+            ...combinedEvent,
+            pauseTime: combinedEvent.startStamp - getPause(i),
+            type: 'paste'
+          }
+          break
+        }
+        // 复制
+        else if ((key === 'v' || key === 'V') && event.ctrlKey === true) {
+          changeRecordList[i] = {
+            ...changeRecordList[i],
+            ...combinedEvent,
+            pauseTime: combinedEvent.startStamp - getPause(i),
+            type: 'copy'
+          }
+          break
+        }
+        // 换行
+        else if (key === 'Enter') {
+          changeRecordList[i] = {
+            ...changeRecordList[i],
+            ...combinedEvent,
+            pauseTime: combinedEvent.startStamp - getPause(i),
+          }
+          break
+        }
+        // 字母的键入
+        else if ('key' in changeRecordList[i] && changeRecordList[i].key === combinedEvent.timeKey) {
+          changeRecordList[i] = {
+            ...changeRecordList[i],
+            ...combinedEvent,
+            pauseTime: combinedEvent.startStamp - getPause(i)
+          }
+          break
+        }
+        // TODO: 处理输入法键入
+      }
+
+      // 所有key相关信息处理完毕,删除map
+      if (keyEventMap[key].length === 0) {
+        delete keyEventMap[key];
+      }
+    }
+  }
+
+  const download = () => {
+    //   const ws = XLSX.utils.json_to_sheet(flattenOps(changeRecordList))
+    const ws = XLSX.utils.json_to_sheet(changeRecordList)
+    /* 新建空workbook */
+    const wb = XLSX.utils.book_new()
+    /* 添加worksheet,当然你可以添加多个,这里我只添加一个 */
+    XLSX.utils.book_append_sheet(wb, ws, 'result')
+
+    const wbout = XLSX.write(wb, {
+      bookType: 'xlsx',
+      bookSST: true,
+      type: 'array'
+    })
+
+    let url = window.URL.createObjectURL(new Blob([wbout]))
+    let link = document.createElement('a')
+    link.style.display = 'none'
+    link.href = url
+    link.setAttribute('download', '测试数据' + '.xls')
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link) //下载完成移除元素
+    window.URL.revokeObjectURL(url) //释放掉blob对象
+
+  }
+
+  // 监听 changeRecordList 数组变化
+  watch(
+      () => changeRecordList,
+      (newValue, oldValue) => {
+        console.log(changeRecordList);
+      },
+      { deep: true }
+  )
 
-emitter.on('get-html', (value: string) => {
-  console.log(value)
-  getHTML.value = value
-})
-
-emitter.on('get-text', (value: string) => {
-  console.log(value)
-  getText.value = value
-})
-
-emitter.on('get-quill-content', (value: Object) => {
-  console.log(value)
-  Object.assign(getQuillContent, value)
-})
-
-// 接收行为数据
-emitter.on('change-record', (value) => {
-  console.log(value)
-  changeRecordList.push(value)
-})
-
-onUnmounted(() => {
-  emitter.off('get-html')
-  emitter.off('get-text')
-  emitter.off('change-record')
-  emitter.off('get-quill-content')
-})
+  onUnmounted(() => {
+    emitter.off("change-record")
+  })
 
 </script>
 

+ 5 - 0
src/router/index.ts

@@ -84,6 +84,11 @@ const router = createRouter({
           name: 'edit',
           component: () => import('../views/EditPage/EditPage.vue')
         },
+        {
+          path: 'assignment/:assignmentId/AIEvaluation',
+          name: 'AIEvaluation',
+          component: () => import('../views/AIEvaluationPage/AIEvaluationPage.vue')
+        },
         {
           path: 'assignment/:assignmentId/correct',
           name: 'correct',

+ 30 - 0
src/types/evaluation.ts

@@ -0,0 +1,30 @@
+// 定义 Evaluation 接口
+interface Evaluation {
+    id?: number; // 使用可选属性,因为可能是自增 ID
+    studentId: number;
+    assignmentId: number;
+    version?: number; // 使用可选属性,根据需求可能在创建时不指定
+}
+
+// 定义 OverallEvaluation 接口
+interface OverallEvaluation {
+    id?: number; // 使用可选属性,因为可能是自增 ID
+    studentId: number;
+    assignmentId: number;
+    content: string;
+    version?: number; // 使用可选属性,根据需求可能在创建时不指定
+    evaluation?: string; // 使用可选属性,根据需求可能在创建时不指定
+}
+
+// 定义 SentenceEvaluation 接口
+interface SentenceEvaluation {
+    id?: number; // 使用可选属性,因为可能是自增 ID
+    studentId: number;
+    assignmentId: number;
+    no: number;
+    content: string;
+    sentence: string;
+    evaluation?: string; // 使用可选属性,根据需求可能在创建时不指定
+    version?: number; // 使用可选属性,根据需求可能在创建时不指定
+    type?: string; // 使用可选属性,根据需求可能在创建时不指定
+}

+ 377 - 0
src/views/AIEvaluationPage/AIEvaluationPage.vue

@@ -0,0 +1,377 @@
+<template>
+  <div style="display: flex">
+    <!-- 1.作文展示区域 -->
+    <div style="width: 40%; height: 90vh; margin: 10px; position: relative;">
+      <el-breadcrumb :separator-icon="ArrowRight" style="height: 30px; align-content: center; margin-left: 6px">
+        <el-breadcrumb-item :to="{ path: `/home/Assignment/${assignmentId}/edit` }">写作</el-breadcrumb-item>
+        <el-breadcrumb-item><span style="color: rgba(22,119,255,0.62); font-weight: bold; cursor:pointer;">智能批改</span></el-breadcrumb-item>
+      </el-breadcrumb>
+
+      <el-card  shadow="never">
+        <!-- 原文标识 -->
+        <div style="display: flex;justify-content: center;align-items: center;width: 100px; height: 50px; background-color:var(--system-color); position: absolute; top: 40px; left: 0; clip-path: polygon(0 0, 100% 0, 80% 50%, 100% 100%, 0 100%);">
+          <span style="margin-left: -20px;color: white">原文</span>
+        </div>
+        <!-- 作文内容展示区域 -->
+        <div style="white-space: pre-wrap; margin-top: 80px; line-height: 30px">
+          <el-scrollbar height="760px">
+          <span style="position: relative;">
+            <template v-if="data.sentenceEvaluationList?.length != 0">
+              <span v-for="sentenceEvaluation in data.sentenceEvaluationList"
+                    :key="sentenceEvaluation.id"
+                    :style="{ 'text-decoration': 'underline', 'text-decoration-color': 'pink', 'text-decoration-thickness': '3px', 'text-decoration-style': 'dashed', 'background-color': sentenceEvaluation.id === highlightSentenceId? 'rgba(255, 192, 203, 0.3)' : 'transparent' }">
+                {{ sentenceEvaluation.sentence }}
+              </span>
+            </template>
+            <template v-else>
+              <span
+                  :style="{ 'text-decoration': 'underline', 'text-decoration-color': 'pink', 'text-decoration-thickness': '3px', 'text-decoration-style': 'dashed'}">
+                {{ data.overallEvaluation?.content }}
+              </span>
+            </template>
+
+          </span>
+          </el-scrollbar>
+        </div>
+      </el-card>
+    </div>
+
+
+    <!-- 2.AI评价区域 -->
+    <div style="width: 40%; margin: 10px; height: 89vh;">
+      <div class="combined-box">
+        <div style="display: flex">
+          <div
+              class="trapezoid"
+              :class="{ active: selected === 'trapezoid' }"
+              @click="selected = 'trapezoid'"
+          >
+            整体评价
+          </div>
+          <div
+              class="trapezoid2"
+              :class="{ active: selected === 'trapezoid2' }"
+              @click="selected = 'trapezoid2'"
+          >
+            逐句评价
+          </div>
+        </div>
+        <div class="rectangle">
+          <template v-if="selected === 'trapezoid'">
+            <el-scrollbar height="820px">
+              <div style="display: flex;align-items: flex-start;margin-bottom: 15px;">
+                <div>
+                  <!--展示头像-->
+                  <!--<el-avatar src="@/image/seecoderLogo.png" :size="32" :shape="'circle'">-->
+                  <!--</el-avatar>-->
+                  <div style="border-radius: 50%; background-color:#fff; width: 40px; height: 40px;display: flex;justify-content: center;align-items: center;">
+                    <img src="@/image/seecoderLogo.png" width="32" >
+                  </div>
+                </div>
+                <div style="margin-left: 15px">
+                  <!--名字-->
+                  <div style="margin-bottom: 2px">SEEAI英语助手</div>
+                  <!--内容-->
+                  <div style="background-color: #f0f0f0;padding: 8px 12px;border-radius: 16px;white-space: pre-line;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);">
+                    {{ data.overallEvaluation?.evaluation }}
+                  </div>
+                </div>
+              </div>
+            </el-scrollbar>
+          </template>
+          <template v-else>
+              <el-scrollbar height="820px">
+                <div style="display: flex; flex-direction: column;">
+                  <template v-if="data.sentenceEvaluationList?.length == 0">
+                    暂无逐句评价
+                  </template>
+                  <el-card v-for="sentenceEvaluation in data.sentenceEvaluationList"
+                           :key="sentenceEvaluation.id"
+                           style="cursor: pointer; margin-bottom: 10px; position: relative;" shadow="hover"
+                           @mouseenter="highlightSentenceId = sentenceEvaluation.id"
+                           @mouseleave="highlightSentenceId = sentenceEvaluation.id"
+                  >
+                    <div style="display: flex;justify-content: center;align-items: center;width: 20px; height: 100%; background-color: pink; position: absolute; top: 0; left: calc(100% - 20px)">
+                    </div>
+                    <div style="padding-right: 30px">
+                      {{ sentenceEvaluation.evaluation }}
+                    </div>
+                  </el-card>
+                </div>
+              </el-scrollbar>
+          </template>
+        </div>
+      </div>
+    </div>
+
+    <!-- 3.历史版本选择区域 -->
+    <el-card
+        style="
+          width: 15%;  /* 固定宽度代替百分比 */
+          height: 90vh;
+          margin: 10px 0 10px auto;  /* 关键:左侧自动边距 */
+          border-left: 1px solid #e4e7ed;
+          border-radius: 0 8px 8px 0;
+          box-shadow: -2px 0 4px rgba(0,0,0,0.05);
+          /*position: sticky;  !* 新增固定定位 *!*/
+          right: 0;
+      "
+        shadow="never"
+    >
+      <div style="
+        position: relative;
+        height: 100%;
+        padding: 20px 10px;
+      ">
+        <!-- 版本选择标题 -->
+        <div style="
+          position: absolute;
+          top: 0;
+          left: -20px;
+          width: 80px;
+          height: 40px;
+          background: var(--system-color);
+          clip-path: polygon(0 0, 100% 0, 90% 50%, 100% 100%, 0 100%);
+          color: white;
+          display: flex;
+          align-items: center;
+          padding-left: 12px;
+          font-size: 13px;
+        ">
+          版本历史
+        </div>
+
+        <!-- 版本列表 -->
+        <el-scrollbar height="800px" style="margin-top: 50px;">
+          <div
+              v-for="version in data.versionList"
+              :key="version.id"
+              class="version-item"
+              :class="{ active: selectedVersion === version.version }"
+              @click="getEvaluation(version.version)"
+          >
+            <div class="version-marker"></div>
+            <div class="version-info">
+              <div class="version-time">{{ version.time }}</div>
+              <div class="version-author">v{{ version.version }}</div>
+            </div>
+          </div>
+        </el-scrollbar>
+
+        <!-- 当前版本提示 -->
+        <div style="
+          position: absolute;
+          bottom: 10px;
+          left: 10px;
+          right: 10px;
+          font-size: 12px;
+          color: #666;
+        ">
+          当前选择:v{{ selectedVersion }}
+        </div>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import {onMounted, reactive, ref, watch} from "vue";
+  import useApis from "@/apis";
+  import MyComponent from "@/views/AIEvaluationPage/MyComponent.vue";
+  import {ElMessage} from "element-plus";
+  import {useRoute} from "vue-router";
+  import {useStore} from "@/store";
+  import { ArrowRight } from "@element-plus/icons-vue";
+
+  const apis = useApis()
+  const store = useStore()
+  const highlightSentenceId = ref(-1)
+  const selected = ref('trapezoid');
+  const route = useRoute()
+  const assignmentId = Number(route.params.assignmentId)
+  const data = reactive<{
+    overallEvaluation: OverallEvaluation | null;
+    sentenceEvaluationList: SentenceEvaluation[];
+    versionList: []
+  }>({
+    overallEvaluation: null,
+    sentenceEvaluationList: [],
+    versionList: []
+  })
+  const selectedVersion = ref(1.2)
+
+  const toEvaluation = () => {
+    apis.overAllEvaluation(assignmentId, store.user.id)
+        .then(res => {
+          if(res.data.code === 200){
+            // getEvaluation()
+          }else if(res.data.code === 400){
+            ElMessage.error(res.data.msg)
+          }
+        })
+        .catch(err => {
+          console.log(err)
+        })
+    apis.sentenceEvaluation(assignmentId,store.user.id)
+        .then(res => {
+          if(res.data.code === 200){
+            // getEvaluation()
+          }else if(res.data.code === 400){
+            ElMessage.error(res.data.msg)
+          }
+        })
+        .catch(err => {
+          console.log(err)
+        })
+  }
+
+  const getEvaluation = (version: number) => {
+    selectedVersion.value = version
+    apis.getOverall(assignmentId,store.user.id,version)
+        .then(res => {
+          // console.log(res)
+          data.overallEvaluation = res.data.data
+        })
+        .catch(err => {
+          console.log(err)
+          data.overallEvaluation = {} as OverallEvaluation
+        })
+    apis.getSentence(assignmentId,store.user.id,version)
+        .then(res => {
+          // console.log(res)
+          if(res.data.code === 200){
+            data.sentenceEvaluationList = res.data.data
+          } else {
+            data.sentenceEvaluationList = []
+          }
+        })
+        .catch(err => {
+          console.log(err)
+          data.sentenceEvaluationList = []
+        })
+  }
+
+  const getEvaluationVersionList = () => {
+    apis.getEvaluationVersionList(assignmentId, store.user.id)
+        .then(res => {
+          // 从未批改过
+          if(res.data.data.length === 0){
+            data.versionList = []
+          } else {
+            data.versionList = res.data.data
+            getEvaluation(res.data.data[0].version)
+          }
+        })
+        .catch(err => {
+          console.log(err)
+        })
+  }
+
+  onMounted(() => {
+    // toEvaluation()
+    // getEvaluation()
+    getEvaluationVersionList()
+  })
+</script>
+
+<script lang="ts">
+export default {
+  name: "AIEvaluationPage"
+}
+</script>
+
+<style scoped>
+
+.combined-box {
+  width: 100%; /* 整体宽度 */
+  height: 100%;
+}
+
+.trapezoid {
+  width: 100px; /* 梯形底边长度 */
+  height: 0;
+  border-bottom: 30px solid #cfcfcf; /* 梯形高度和颜色 */
+  border-right: 20px solid transparent;
+  line-height: 30px; /* 与 border-bottom 高度相等 */
+  text-align: center; /* 水平居中 */
+  z-index: 1;
+  cursor: pointer;
+}
+
+.trapezoid2 {
+  width: 100px; /* 梯形底边长度 */
+  height: 0;
+  border-bottom: 30px solid #cfcfcf; /* 梯形高度和颜色 */
+  border-right: 20px solid transparent;
+  border-left: 20px solid transparent;
+  margin-left: -20px;
+  z-index: 2;
+  line-height: 30px; /* 与 border-bottom 高度相等 */
+  text-align: center; /* 水平居中 */
+  cursor: pointer;
+}
+
+.trapezoid.active {
+  border-bottom: 30px solid #fff;
+  z-index: 10;
+}
+
+.trapezoid2.active {
+  border-bottom: 30px solid #fff;
+  z-index: 10;
+}
+
+.rectangle {
+  width: 100%; /* 长方形宽度 */
+  height: calc(100% - 30px - 2vh); /* 长方形高度 */
+  border-top: none; /* 去掉顶部边框,使其与梯形无缝连接 */
+  /* 长方形底部的圆角 */
+  align-items: center;
+  padding: 20px;
+  background-color: #fff; /* 长方形的背景颜色 */
+  border-radius: 0 5px 5px 5px;
+}
+
+.version-item {
+  display: flex;
+  align-items: center;
+  padding: 8px;
+  margin-bottom: 8px;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 0.2s;
+  box-sizing: border-box;
+}
+
+.version-item:hover {
+  background: #f5f7fa;
+}
+
+.version-item.active {
+  background: rgba(89, 125, 59, 0.1);
+  border: 1px solid rgba(89, 125, 59, 0.3);
+  box-sizing: border-box;
+}
+
+.version-marker {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #cfcfcf;
+  margin-right: 10px;
+}
+
+.version-item.active .version-marker {
+  background: var(--system-color);
+}
+
+.version-time {
+  font-size: 12px;
+  color: #333;
+}
+
+.version-author {
+  font-size: 10px;
+  color: #999;
+}
+</style>

+ 88 - 0
src/views/AIEvaluationPage/MyComponent.vue

@@ -0,0 +1,88 @@
+<template>
+  <div class="combined-box">
+    <div style="display: flex">
+      <div
+          class="trapezoid"
+          :class="{ active: selected === 'trapezoid' }"
+          @click="selected = 'trapezoid'"
+      >
+        整体评价
+      </div>
+      <div
+          class="trapezoid2"
+          :class="{ active: selected === 'trapezoid2' }"
+          @click="selected = 'trapezoid2'"
+      >
+        逐句评价
+      </div>
+    </div>
+    <template v-if="selected === 'trapezoid'">
+      <div class="rectangle">
+        <p>This is the content of the rectangle. You can put anything you want here.</p>
+      </div>
+    </template>
+    <template v-else>
+      <div class="rectangle">
+        <p>2222222222222This is the content of the rectangle. You can put anything you want here.</p>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+const selected = ref('trapezoid');
+</script>
+
+<style scoped>
+.combined-box {
+  width: 100%; /* 整体宽度 */
+  height: 100%;
+}
+
+.trapezoid {
+  width: 100px; /* 梯形底边长度 */
+  height: 0;
+  border-bottom: 30px solid #a3a3a3; /* 梯形高度和颜色 */
+  border-right: 20px solid transparent;
+  line-height: 30px; /* 与 border-bottom 高度相等 */
+  text-align: center; /* 水平居中 */
+  z-index: 1;
+  cursor: pointer;
+}
+
+.trapezoid2 {
+  width: 100px; /* 梯形底边长度 */
+  height: 0;
+  border-bottom: 30px solid #a3a3a3; /* 梯形高度和颜色 */
+  border-right: 20px solid transparent;
+  border-left: 20px solid transparent;
+  margin-left: -20px;
+  z-index: 2;
+  line-height: 30px; /* 与 border-bottom 高度相等 */
+  text-align: center; /* 水平居中 */
+  cursor: pointer;
+}
+
+.trapezoid.active {
+  border-bottom: 30px solid #fff;
+  z-index: 10;
+}
+
+.trapezoid2.active {
+  border-bottom: 30px solid #fff;
+  z-index: 10;
+}
+
+.rectangle {
+  width: 100%; /* 长方形宽度 */
+  height: calc(100% - 30px - 2vh); /* 长方形高度 */
+  border-top: none; /* 去掉顶部边框,使其与梯形无缝连接 */
+  /* 长方形底部的圆角 */
+  align-items: center;
+  padding: 20px;
+  background-color: #fff; /* 长方形的背景颜色 */
+  border-radius: 0 5px 5px 5px;
+}
+</style>

+ 1 - 1
src/views/EditPage/EditPage.vue

@@ -15,7 +15,7 @@
     <div class="my-work-input">
       <Edit :engagement="dataForm.engagement"/>
     </div>
-    <Slider :dialogueId="dataForm.dialogueId"/>
+    <Slider :dialogueId="dataForm.dialogueId" :engagement="dataForm.engagement"/>
   </div>
 </template>
 

+ 29 - 8
src/views/Home/component/Silder/component/ExpandableParagraph.vue

@@ -6,11 +6,13 @@
 <template>
   <div>
     <div
+        ref="contentEl"
         :class="{'collapsed': !expanded, 'expanded': expanded}"
     >
       <span>{{ $props.text }}</span>
     </div>
     <el-button
+        v-if="showToggle || expanded"
         @click="toggle"
         type="text"
         class="toggle-button"
@@ -21,7 +23,7 @@
 </template>
 
 <script>
-import { ref, onMounted } from 'vue';
+import {ref, onMounted, onUpdated, nextTick} from 'vue';
 
 export default {
   name: 'ExpandableParagraph',
@@ -31,15 +33,33 @@ export default {
     },
   },
   setup(props) {
+    const contentEl = ref(null);
     const expanded = ref(false);
+    const showToggle = ref(false);
+
+    const checkOverflow = () => {
+      nextTick(() => {
+        if (contentEl.value) {
+          // 折叠状态下判断是否溢出
+          const lineHeight = parseInt(getComputedStyle(contentEl.value).lineHeight);
+          const maxHeight = lineHeight * 2;
+          showToggle.value = contentEl.value.scrollHeight > maxHeight;
+        }
+      });
+    };
 
     const toggle = () => {
       expanded.value = !expanded.value;
     };
 
+    onMounted(checkOverflow);
+    onUpdated(checkOverflow);
+
     return {
+      contentEl,
       expanded,
-      toggle,
+      showToggle,
+      toggle
     };
   },
 };
@@ -50,17 +70,18 @@ export default {
   overflow: hidden;
   display: -webkit-box;
   -webkit-box-orient: vertical;
-  -webkit-line-clamp: 2; /* Limit to 2 lines */
-  max-height: 3em; /* Height for 2 lines of text */
+  -webkit-line-clamp: 2;
+  line-height: 1.5em; /* 明确设置行高 */
 }
 
 .expanded {
   overflow: visible;
-  max-height: none;
+  -webkit-line-clamp: unset;
+  line-height: 1.5em; /* 明确设置行高 */
 }
 
 .toggle-button {
-  margin-top: -5px;
-  cursor: pointer;
+  padding: 0;
+  font-size: 12px;
 }
-</style>
+</style>

+ 131 - 32
src/views/Home/component/Silder/index.vue

@@ -24,12 +24,12 @@
         <!--作业描述-->
         <ExpandableParagraph :text="data.assignment.description" />
         <!--下载按钮-->
-        <div class="down">
-          <el-link type="primary">下载</el-link>
-        </div>
+<!--        <div class="down">-->
+<!--          <el-link type="primary">下载</el-link>-->
+<!--        </div>-->
         <!--要求文件和附件-->
         <template v-if="data.assignment.descriptionFile || data.assignment.attachments">
-          <div>范文、介绍</div>
+          <div>作业附件</div>
           <div class="slider-tag">
             <template v-if="data.assignment.descriptionFile">
               <div class="slider-tag-item" @click="openEditor(data.assignment.descriptionFile, data.assignment.assignmentName)">作业要求</div>
@@ -40,11 +40,12 @@
           </div>
         </template>
         <div class="edit-box-header">
-          智能批改修改意见
-          <el-button @click="handleCorrect">智能批改</el-button>
+          智能批改
+          <el-button @click="handleCorrect" :disabled="!showCorrectBtn" @mouseover="handleCorrectTip">开始批改</el-button>
+          <el-button @click="handleCorrectToPage" :disabled="!showCorrectToPageBtn" @mouseover="handleCorrectToPageTip">查看完整批改报告</el-button>
         </div>
         <div class="capacity-item">
-          <div v-loading="loading" element-loading-text="智能批改中" class="remark">{{ aiRemark }}</div>
+          <div v-loading="loading" element-loading-text="智能批改中" class="remark">{{ data.overallEvaluation?.evaluation }}</div>
         </div>
       </template>
     </div>
@@ -53,9 +54,9 @@
 </template>
 
 <script lang="ts" setup>
-import {defineProps, onMounted, reactive, ref, watch, watchEffect} from 'vue'
+import {defineProps, nextTick, onMounted, reactive, ref, watch, watchEffect} from 'vue'
   import "./index.scss"
-  import type {IAssignment} from "@/types/vo";
+import type {engagementVO, IAssignment} from "@/types/vo";
   import useApis from "@/apis";
   import {useStore} from "@/store";
   import {Expand, Fold} from "@element-plus/icons-vue"
@@ -63,10 +64,9 @@ import {defineProps, onMounted, reactive, ref, watch, watchEffect} from 'vue'
 import ViewsWordEditor from "@/components/ViewsWordEditor/ViewsWordEditor.vue";
 import {useRoute} from "vue-router";
 import {ElMessage} from "element-plus";
+import router from "@/router";
+import * as path from "node:path";
 
-  interface ISliderProps {
-    dialogueId: string;
-  }
 
   interface IPreviewProps {
     url: string;
@@ -75,13 +75,17 @@ import {ElMessage} from "element-plus";
 
   const apis = useApis()
   const store = useStore()
-  const props = defineProps<ISliderProps>()
+  const props = defineProps<{
+    dialogueId: string,
+    engagement: engagementVO
+  }>()
 
   let isShrink = ref(false)
   let aiRemark = ref("")
   let loading = ref(false)
   let open= ref(false)
-  let expanded = ref(false)
+  let showCorrectBtn = ref(false)
+  let showCorrectToPageBtn = ref(false)
 
   // 获得路由中的assignmentId
   const route = useRoute()
@@ -89,10 +93,14 @@ import {ElMessage} from "element-plus";
 
   let data = reactive<{
     assignment: IAssignment,
-    currentPreview: IPreviewProps
+    currentPreview: IPreviewProps,
+    overallEvaluation: OverallEvaluation | null;
+    sentenceEvaluationList: SentenceEvaluation[];
   }>({
     assignment: {} as IAssignment,
-    currentPreview: {} as IPreviewProps
+    currentPreview: {} as IPreviewProps,
+    overallEvaluation: null,
+    sentenceEvaluationList: [],
   })
 
   const alertProps = reactive({
@@ -120,26 +128,75 @@ import {ElMessage} from "element-plus";
     });
   };
 
-  // 请求批改
-  const handleCorrect = () => {
-    loading.value = true;
-    apis.rewrite(assignmentId, store.user.id)
-        .then((res: any) => {
-          if (res.data.code == 500) {
-            aiRemark.value = new Array(2).join("智能批改请求超时")
-            ElMessage.error("智能批改请求超时")
-          } else {
-            aiRemark.value = res.data.data.content;
-          }
+  const getEvaluation = () => {
+    apis.getOverall(props.engagement.assignmentId, store.user.id,props.engagement.version)
+        .then(res => {
+          // console.log(res)
+          data.overallEvaluation = res.data.data
+          loading.value = false;
+        })
+        .catch(err => {
+          console.log(err)
+          loading.value = false;
         })
-        .catch((err: any) => {
-          console.error(err);
+  }
+
+  const toEvaluation = () => {
+    apis.overAllEvaluation(props.engagement.assignmentId, store.user.id)
+        .then(res => {
+          if(res.data.code === 200){
+            getEvaluation()
+          }else if(res.data.code === 400){
+            ElMessage.error(res.data.msg)
+            loading.value = false;
+            // 允许查看详细报告
+            showCorrectToPageBtn.value = true;
+          }
         })
-        .finally(() => {
+        .catch(err => {
+          console.log(err)
           loading.value = false;
-        });
+        })
+    apis.sentenceEvaluation(props.engagement.assignmentId, store.user.id)
+        .then(res => {
+          if(res.data.code === 400){
+            ElMessage.error(res.data.msg)
+          }
+        })
+        .catch(err => {
+          console.log(err)
+        })
+  }
+
+  // 请求批改
+  const handleCorrect = () => {
+    loading.value = true;
+    // apis.rewrite(assignmentId, store.user.id)
+    //     .then((res: any) => {
+    //       if (res.data.code == 500) {
+    //         aiRemark.value = new Array(2).join("智能批改请求超时")
+    //         ElMessage.error("智能批改请求超时")
+    //       } else {
+    //         console.log(res.data.data)
+    //         aiRemark.value = res.data.data.content;
+    //         showCorrectToPageBtn.value = true
+    //       }
+    //     })
+    //     .catch((err: any) => {
+    //       console.error(err);
+    //     })
+    //     .finally(() => {
+    //       loading.value = false;
+    //     });
+    toEvaluation()
   };
 
+  const handleCorrectToPage = () => {
+    router.push({
+      path: `/home/assignment/${props.engagement.assignmentId}/AIEvaluation`,
+    })
+  }
+
   const openEditor = (url: string, title: string) => {
     open.value = true;
     data.currentPreview.url = url;
@@ -166,8 +223,50 @@ import {ElMessage} from "element-plus";
     })
   };
 
+  /**
+   * 用于判断是否已存在历史批改记录
+   */
+  const getEvaluationVersionList = () => {
+    if(JSON.stringify(props.engagement) !== '{}'){
+      apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
+          .then(res => {
+            // 从未批改过
+            // console.log(res.data.data.length === 0)
+            if(res.data.data.length === 0){
+              showCorrectBtn.value = true
+              showCorrectToPageBtn.value = false
+            } else {
+              showCorrectToPageBtn.value = true
+              showCorrectBtn.value = (res.data.data[0].version !== props.engagement.version)
+              // console.log(res.data.data)
+            }
+          })
+          .catch(err => {
+            console.log(err)
+          })
+    }
+
+  }
+
+  const handleCorrectTip = () => {
+    if(!showCorrectBtn.value)
+      ElMessage.error("请更新作文内容并暂存后批改~")
+  }
+
+  const handleCorrectToPageTip = () => {
+    if(!showCorrectToPageBtn.value)
+      ElMessage.error("批改后才能查看报告~")
+  }
+
+  // 监听props变化
+  watch(() => props.engagement, (newEngagement, oldEngagement) => {
+    if (newEngagement) {
+      getEvaluationVersionList();
+    }
+  }, { immediate: true });
+
   onMounted(() => {
-    fetchData();
+    fetchData()
   });
 </script>
 

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä