Sfoglia il codice sorgente

Merge branch 'lll_deepseek' into host-nju

lalala 4 mesi fa
parent
commit
a80daf4b3c

+ 30 - 0
package-lock.json

@@ -16,9 +16,11 @@
         "crypto-js": "^4.2.0",
         "dayjs": "^1.11.13",
         "docx-preview": "^0.3.2",
+        "dompurify": "^3.4.0",
         "echarts": "^5.6.0",
         "element-plus": "^2.9.7",
         "js-audio-recorder": "^1.0.7",
+        "marked": "^18.0.2",
         "mitt": "^3.0.1",
         "pinia": "^2.1.7",
         "pinia-plugin-persistedstate": "^3.2.1",
@@ -1208,6 +1210,13 @@
         "undici-types": "~6.19.2"
       }
     },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@types/web-bluetooth": {
       "version": "0.0.16",
       "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
@@ -2145,6 +2154,15 @@
         "jszip": ">=3.0.0"
       }
     },
+    "node_modules/dompurify": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.0.tgz",
+      "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3183,6 +3201,18 @@
         "@jridgewell/sourcemap-codec": "^1.5.0"
       }
     },
+    "node_modules/marked": {
+      "version": "18.0.2",
+      "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.2.tgz",
+      "integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==",
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 20"
+      }
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

+ 2 - 0
package.json

@@ -20,9 +20,11 @@
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.13",
     "docx-preview": "^0.3.2",
+    "dompurify": "^3.4.0",
     "echarts": "^5.6.0",
     "element-plus": "^2.9.7",
     "js-audio-recorder": "^1.0.7",
+    "marked": "^18.0.2",
     "mitt": "^3.0.1",
     "pinia": "^2.1.7",
     "pinia-plugin-persistedstate": "^3.2.1",

+ 113 - 0
src/apis/ai.ts

@@ -3,6 +3,73 @@ import type {IDialogue} from "@/types/dialogue";
 
 const AI_PREFIX = "/api/ai"
 
+interface StreamHandlers {
+    onChunk?: (chunk: string) => void;
+    onComplete?: () => void;
+}
+
+const extractStreamContent = (payload: unknown): string => {
+    if (typeof payload === 'string') {
+        return payload
+    }
+
+    if (!payload || typeof payload !== 'object') {
+        return ''
+    }
+
+    const data = payload as Record<string, any>
+
+    if (typeof data.content === 'string') {
+        return data.content
+    }
+
+    if (typeof data.data?.content === 'string') {
+        return data.data.content
+    }
+
+    const choice = data.choices?.[0]
+    if (typeof choice?.delta?.content === 'string') {
+        return choice.delta.content
+    }
+
+    if (typeof choice?.message?.content === 'string') {
+        return choice.message.content
+    }
+
+    return ''
+}
+
+const handleSSEBlock = (block: string, onChunk?: (chunk: string) => void) => {
+    const lines = block
+        .split('\n')
+        .map((line) => line.trim())
+        .filter(Boolean)
+
+    const dataLines = lines
+        .filter((line) => line.startsWith('data:'))
+        .map((line) => line.slice(5).trim())
+
+    if (dataLines.length === 0) {
+        return
+    }
+
+    const payload = dataLines.join('\n')
+    if (!payload || payload === '[DONE]') {
+        return
+    }
+
+    try {
+        const parsed = JSON.parse(payload)
+        const content = extractStreamContent(parsed)
+        if (content) {
+            onChunk?.(content)
+        }
+        return
+    } catch (_error) {
+        onChunk?.(payload)
+    }
+}
+
 const aiApis = {
     getAIDialogues(assignmentId: number, userId: number, page: number=0, size: number=20) {
         return axiosInstance.get(`${AI_PREFIX}`, {
@@ -34,6 +101,52 @@ const aiApis = {
             timeoutErrorMessage: '请求超时,请稍后重试'
         })
     },
+    async requestAIStream(dialogueId: string, model: string, messages: IDialogue[], handlers: StreamHandlers = {}) {
+        let processedLength = 0
+        let streamBuffer = ''
+
+        const response = await axiosInstance.put(`${AI_PREFIX}/stream`, {
+            model,
+            messages
+        }, {
+            params: {
+                dialogueId
+            },
+            headers: {
+                Accept: 'text/event-stream'
+            },
+            timeout: 0,
+            responseType: 'text',
+            onDownloadProgress: (progressEvent: any) => {
+                const responseText = progressEvent?.event?.target?.responseText
+                if (typeof responseText !== 'string') {
+                    return
+                }
+
+                const newText = responseText.slice(processedLength)
+                if (!newText) {
+                    return
+                }
+
+                processedLength = responseText.length
+                streamBuffer += newText
+
+                const blocks = streamBuffer.split('\n\n')
+                streamBuffer = blocks.pop() ?? ''
+                blocks.forEach((block) => handleSSEBlock(block, handlers.onChunk))
+            }
+        })
+
+        if (typeof response.data === 'string' && response.data.length > processedLength) {
+            streamBuffer += response.data.slice(processedLength)
+        }
+
+        if (streamBuffer.trim()) {
+            handleSSEBlock(streamBuffer, handlers.onChunk)
+        }
+
+        handlers.onComplete?.()
+    },
     rewrite(assignmentId: number, userId: number) {
         return axiosInstance.post(`${AI_PREFIX}/rewrite`, {}, {
             params: {

+ 8 - 0
src/apis/evaluation.ts

@@ -47,6 +47,14 @@ const evaluationApis = {
             }
         })
     },
+    getCurrentVersionEvaluation(assignmentId: number, studentId: number){
+        return axiosInstance.get(`${EVALUATION_PREFIX}/current`, {
+            params: {
+                assignmentId,
+                studentId
+            }
+        })
+    },
 }
 
 export default evaluationApis

+ 122 - 0
src/components/AIChatter/AIChatter.module.scss

@@ -117,6 +117,67 @@
         background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
         color: #333;
         border-radius: 20px;
+
+        :global(p) {
+          margin: 0 0 8px;
+        }
+
+        :global(p:last-child) {
+          margin-bottom: 0;
+        }
+
+        :global(ul),
+        :global(ol) {
+          margin: 8px 0;
+          padding-left: 20px;
+        }
+
+        :global(li) {
+          margin: 4px 0;
+        }
+
+        :global(h1),
+        :global(h2),
+        :global(h3),
+        :global(h4),
+        :global(h5),
+        :global(h6) {
+          margin: 8px 0;
+          line-height: 1.4;
+        }
+
+        :global(code) {
+          padding: 2px 6px;
+          border-radius: 6px;
+          background-color: rgba(255, 255, 255, 0.65);
+          font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+          font-size: 0.9em;
+        }
+
+        :global(pre) {
+          overflow-x: auto;
+          margin: 8px 0;
+          padding: 10px 12px;
+          border-radius: 10px;
+          background-color: rgba(255, 255, 255, 0.7);
+        }
+
+        :global(pre code) {
+          padding: 0;
+          background: transparent;
+        }
+
+        :global(blockquote) {
+          margin: 8px 0;
+          padding-left: 12px;
+          border-left: 3px solid rgba(0, 0, 0, 0.18);
+          color: #555;
+        }
+
+        :global(a) {
+          color: #2563eb;
+          text-decoration: underline;
+        }
       }
 
       .message-avatar {
@@ -271,6 +332,67 @@
         background-color: #f0f2f5;
         color: #333;
         border-bottom-left-radius: 4px;
+
+        :global(p) {
+          margin: 0 0 8px;
+        }
+
+        :global(p:last-child) {
+          margin-bottom: 0;
+        }
+
+        :global(ul),
+        :global(ol) {
+          margin: 8px 0;
+          padding-left: 20px;
+        }
+
+        :global(li) {
+          margin: 4px 0;
+        }
+
+        :global(h1),
+        :global(h2),
+        :global(h3),
+        :global(h4),
+        :global(h5),
+        :global(h6) {
+          margin: 8px 0;
+          line-height: 1.4;
+        }
+
+        :global(code) {
+          padding: 2px 6px;
+          border-radius: 6px;
+          background-color: rgba(255, 255, 255, 0.9);
+          font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+          font-size: 0.9em;
+        }
+
+        :global(pre) {
+          overflow-x: auto;
+          margin: 8px 0;
+          padding: 10px 12px;
+          border-radius: 10px;
+          background-color: rgba(255, 255, 255, 0.95);
+        }
+
+        :global(pre code) {
+          padding: 0;
+          background: transparent;
+        }
+
+        :global(blockquote) {
+          margin: 8px 0;
+          padding-left: 12px;
+          border-left: 3px solid rgba(0, 0, 0, 0.18);
+          color: #555;
+        }
+
+        :global(a) {
+          color: #2563eb;
+          text-decoration: underline;
+        }
       }
 
       .message-avatar {

+ 90 - 42
src/components/AIChatter/AIChatter.vue

@@ -16,7 +16,7 @@
           </span>
             <template #dropdown>
               <el-dropdown-menu>
-                <el-dropdown-item v-for="model in modelOptions" :command="model">{{ model }}</el-dropdown-item>
+                <el-dropdown-item v-for="model in modelOptions" :key="model" :command="model">{{ model }}</el-dropdown-item>
               </el-dropdown-menu>
             </template>
           </el-dropdown>
@@ -28,7 +28,7 @@
     <div :class="$style['message-box']" ref="containerTopRef">
       <div
           v-for="(item, index) in messageList"
-          :key="index"
+          :key="`${item?.role}-${index}`"
           :class="$style['message-group']"
       >
         <!-- 用户消息 -->
@@ -51,8 +51,10 @@
           </div>
           <div :class="$style['message-info']">
             <div :class="$style['ai-name']">{{ data.model }}</div>
-            <div :class="[$style['message-bubble'], $style['ai-bubble']]">
-              {{ item?.content }}
+            <div
+              :class="[$style['message-bubble'], $style['ai-bubble']]"
+              v-html="renderMarkdown(item?.content || '')"
+            >
             </div>
           </div>
         </div>
@@ -106,14 +108,16 @@
 
 <script setup lang="ts">
   import "./AIChatter.module.scss";
-  import type {IDialogue} from "@/types/dialogue.ts";
+  import type {IDialogue} from "@/types/dialogue";
   import {useStore} from "@/store";
   import useApis from "@/apis";
-  import {onMounted, reactive, ref, defineProps, watch, onUnmounted, nextTick} from "vue";
+  import {onMounted, reactive, ref, watch, onUnmounted, nextTick} from "vue";
   import {ArrowDown, Top} from '@element-plus/icons-vue'
   import {ElMessage} from "element-plus";
   import emitter from "@/utils/emitter";
-  import dayjs from "dayjs";
+  import * as dayjsModule from "dayjs";
+  import { marked } from "marked";
+  import * as DOMPurifyModule from "dompurify";
 
   // const svg = `
   //       <path class="path" d="
@@ -146,20 +150,33 @@
 </svg>
 `;
 //update: 加入isTeacher属性,用于判断是否是教师批改界面
-  interface IProps {
-    dialogueId: string;
-    messages: IDialogue[]
-    isTeacher: boolean
-  }
-
-  const props = defineProps<{
+  const props = withDefaults(defineProps<{
     dialogueId: string,
     messages: IDialogue[]
     isTeacher: boolean
-  }>()
+  }>(), {
+    isTeacher: false
+  })
 
   const apis = useApis()
   const store = useStore()
+  const dayjs = (
+    (dayjsModule as unknown as { default?: (date?: Date) => { format: (template: string) => string } }).default
+    ?? dayjsModule
+  ) as (date?: Date) => { format: (template: string) => string }
+  const sanitizeHtml = (dirty: string) => {
+    const purifierModule = DOMPurifyModule as unknown as {
+      default?: { sanitize: (input: string) => string },
+      sanitize?: (input: string) => string
+    }
+    const purifier = purifierModule.default ?? purifierModule
+    return purifier.sanitize(dirty)
+  }
+
+  marked.setOptions({
+    gfm: true,
+    breaks: true
+  })
   //UPDATE: 目前只有ChatGLM4
   const modelOptions = ["ChatGLM4"]
   const questionTemplates = [
@@ -175,7 +192,7 @@
   const containerTopRef = ref(null);
 
   // 用于处理ai对话展示异常问题
-  let messageList:IDialogue[] = []
+  const messageList = ref<IDialogue[]>([])
 
   let data = reactive<{
     model: string;
@@ -196,15 +213,14 @@
   // 加载聊天记录,并滚动至最后
   onMounted(() => {
     if (props.messages) data.messages = props.messages;
-    // console.log(props.messages)
+    handleMessageList()
     nextTick(scrollToEnd);
   });
 
   const scrollToEnd = () => {
-    // messagesEndRef.value?.scrollIntoView({ behavior: 'smooth' });
-    let container = containerTopRef.value
-    container.scrollTo({ top: container.scrollHeight-1, behavior: 'smooth' });
-
+    const container = containerTopRef.value as HTMLDivElement | null
+    if (!container) return
+    container.scrollTo({ top: container.scrollHeight - 1, behavior: 'smooth' });
   };
 
   watch(() => props.messages, (newMessages:any) => {
@@ -240,7 +256,9 @@
   });
 
   // 发送问题
-  const handleSendQuestion = () => {
+  const handleSendQuestion = async () => {
+    if (data.loading || !data.questionContent.trim()) return
+
     // 触发行为记录
     emitter.emit("click-ai-send-button", {
       insert: data.questionContent,
@@ -258,32 +276,60 @@
     }
     data.loading = true;
 
-    let requestBody:IDialogue[] = data.messages
-    requestBody.push({
+    const questionContent = data.questionContent.trim()
+    const userMessage: IDialogue = {
       role: 'user',
-      content: data.questionContent
-    })
+      content: questionContent
+    }
+    const assistantMessage: IDialogue = {
+      role: 'assistant',
+      content: ''
+    }
+    const requestBody: IDialogue[] = [...data.messages, userMessage]
+
+    data.messages.push(userMessage)
+    data.messages.push(assistantMessage)
+    data.questionContent = ''
+    handleMessageList()
+    nextTick(scrollToEnd)
 
-    apis.requestAI(props.dialogueId, data.model, requestBody)
-        .then((_res:any) => {
-          const resDialogue = _res.data.data;
-          data.messages.push(resDialogue);
+    try {
+      await apis.requestAIStream(props.dialogueId, data.model, requestBody, {
+        onChunk: (chunk: string) => {
+          assistantMessage.content += chunk
           handleMessageList()
-        })
-        .catch((_err:any) => {
-          console.log(_err);
-        })
-        .finally(() => {
-          data.questionContent = '';
-          data.loading = false;
-          nextTick(scrollToEnd);
-        });
+          nextTick(scrollToEnd)
+        }
+      })
+    } catch (_err:any) {
+      console.log(_err);
+      if (!assistantMessage.content) {
+        data.messages.pop()
+      } else {
+        assistantMessage.content += '\n\n[请求中断]'
+      }
+      ElMessage.error('AI 请求失败,请稍后重试')
+      handleMessageList()
+    } finally {
+      data.loading = false;
+      nextTick(scrollToEnd);
+    }
   };
 
   const handleCommand = (command:string) => {
     data.model = command
   }
 
+  const normalizeMarkdown = (content: string) => {
+    return content.replace(/^(#{1,6})(\S)/gm, '$1 $2')
+  }
+
+  const renderMarkdown = (content: string) => {
+    const normalizedContent = normalizeMarkdown(content ?? '')
+    const html = marked.parse(normalizedContent)
+    return sanitizeHtml(typeof html === 'string' ? html : '')
+  }
+
   // 选择模板问题
   const handleMenuClick = (key:any) => {
     data.questionContent = questionTemplates[key];
@@ -292,6 +338,7 @@
 
   // 处理ai对话显示异常问题
   const handleMessageList = () => {
+    const nextMessageList: IDialogue[] = []
     // console.log(data.messages)
     let count1 = 0
     let count2 = 1
@@ -312,7 +359,7 @@
     if(userNum > AssistantNum) count2 = count2 + 2 * (userNum - AssistantNum)
     for(let message of data.messages){
       if(message.role == "user"){
-        messageList[count1] = {
+        nextMessageList[count1] = {
           role: "user",
           content: message.content
         }
@@ -320,13 +367,13 @@
       }
       if(message.role == "assistant"){
         if(userNum == AssistantNum){
-          messageList[count2] = {
+          nextMessageList[count2] = {
             role: "assistant",
             content: message.content
           }
           count2+=2
         } else {
-          messageList[count2] = {
+          nextMessageList[count2] = {
             role: "assistant",
             content: message.content
           }
@@ -334,6 +381,7 @@
         }
       }
     }
+    messageList.value = nextMessageList
     // console.log(messageList)
   }
 

+ 3 - 0
src/components/QuillEditor/Editor.vue

@@ -86,6 +86,9 @@ import {ElMessage} from "element-plus";
     // 文本变化监控
     quill.on(Quill.events.TEXT_CHANGE, (...args) => {
       const [delta, oldDelta, source] = args;
+      if (source === 'user') {
+        emitter.emit('is-modified', true)
+      }
       const oldContent = processOps(args["1"].ops);
       const docLength = quill.getLength() - 1;
       const letterCount = (quill.getText().match(/[a-zA-Z\d\u4e00-\u9fa5]/g) || []).length;

+ 15 - 15
src/router/index.ts

@@ -16,24 +16,24 @@ const router = createRouter({
     },
 
     // 登录与注册
-    {
-      path: "/",
-      redirect: () => {
-        window.location.href = "https://p-nju.seec.seecoder.cn/login?from=https://air-nju.seec.seecoder.cn";
-       //  window.location.href = "http://localhost:8000/login?from=http://localhost:4000";
-        // {本地门户首页地址}?from={本地eai首页地址}
-        return "/"; // 占位返回值,实际不会执行
-    },
-    },
     // {
     //   path: "/",
-    //   redirect: "/login"
+    //   redirect: () => {
+    //     window.location.href = "https://p-nju.seec.seecoder.cn/login?from=https://air-nju.seec.seecoder.cn";
+    //    //  window.location.href = "http://localhost:8000/login?from=http://localhost:4000";
+    //     // {本地门户首页地址}?from={本地eai首页地址}
+    //     return "/"; // 占位返回值,实际不会执行
     // },
-    // {
-    //   path: '/login',
-    //   name: 'login',
-    //   component: () => import('../views/LoginPage/LoginPage.vue')
     // },
+    {
+      path: "/",
+      redirect: "/login"
+    },
+    {
+      path: '/login',
+      name: 'login',
+      component: () => import('../views/LoginPage/LoginPage.vue')
+    },
     {
       path: '/register',
       name: 'register',
@@ -260,7 +260,7 @@ const router = createRouter({
  */
   router.beforeEach(async (to) => {
     const store = useStore();
-    const publicPaths = [  '/register', '/passwordChange', '/admin/login' ];
+    const publicPaths = [ '/login', '/register', '/passwordChange', '/admin/login' ];
     // 1. 检查URL中的token
     const urlToken = new URLSearchParams(window.location.search).get('token');
     if (urlToken) {

+ 190 - 12
src/views/AIEvaluationPage/AIEvaluationPage.vue

@@ -79,8 +79,10 @@
                 </div>
                 <div class="evaluation-message">
                   <div class="assistant-name">SEEAI英语助手</div>
-                  <div class="message-content">
-                    {{ data.overallEvaluation?.evaluation }}
+                  <div
+                    class="message-content markdown-content"
+                    v-html="renderMarkdown(data.overallEvaluation?.evaluation || '')"
+                  >
                   </div>
                 </div>
               </div>
@@ -164,6 +166,8 @@
   import {useRoute} from "vue-router";
   import {useStore} from "@/store";
   import { ArrowRight, Loading } from "@element-plus/icons-vue";
+  import { marked } from "marked";
+  import * as DOMPurifyModule from "dompurify";
 
   interface recordVersion {
     id: number,
@@ -186,12 +190,47 @@
     sentenceEvaluationList: [],
     versionList: []
   })
-  const selectedVersion = ref(1.2)
+  const selectedVersion = ref<number>(1)
+  const currentVersion = ref<number | null>(null)
   const isLoadingSentence = ref(false) // 逐句评价加载状态
   const isLoadingOverall = ref(false) // 整体评价加载状态
   let sentenceLoadingTimer: NodeJS.Timeout | null = null // 逐句评价延迟加载计时器
   let sentencePollingInterval: NodeJS.Timeout | null = null // 逐句评价轮询计时器
 
+  const sanitizeHtml = (dirty: string) => {
+    const purifierModule = DOMPurifyModule as unknown as {
+      default?: { sanitize: (input: string) => string },
+      sanitize?: (input: string) => string
+    }
+    const purifier = purifierModule.default ?? purifierModule
+    return purifier.sanitize(dirty)
+  }
+
+  marked.setOptions({
+    gfm: true,
+    breaks: true
+  })
+
+  const normalizeMarkdown = (content: string) => {
+    return content
+      .split('\n')
+      .map((line) => {
+        const headingMatch = line.match(/^\s*(#{1,6})\s*(.*)$/)
+        if (!headingMatch) {
+          return line
+        }
+        const [, hashes, text] = headingMatch
+        return `${hashes} ${text.trim()}`
+      })
+      .join('\n')
+  }
+
+  const renderMarkdown = (content: string) => {
+    const normalizedContent = normalizeMarkdown(content ?? '')
+    const html = marked.parse(normalizedContent)
+    return sanitizeHtml(typeof html === 'string' ? html : '')
+  }
+
   const toEvaluation = () => {
     apis.overAllEvaluation(assignmentId, store.user.id)
         .then(res => {
@@ -263,6 +302,11 @@
 
   const getEvaluation = (version: number) => {
     selectedVersion.value = version
+    console.info('[EVAL][FE] getEvaluation start', {
+      assignmentId,
+      userId: store.user?.id,
+      version
+    })
     
     // 清除之前的计时器和轮询
     if (sentenceLoadingTimer) {
@@ -278,11 +322,27 @@
     isLoadingOverall.value = true
     apis.getOverall(assignmentId,store.user.id,version)
         .then(res => {
-          data.overallEvaluation = res.data.data
+          if (res.data.code === 200) {
+            data.overallEvaluation = res.data.data
+            console.info('[EVAL][FE] overall ok', {
+              version,
+              hasData: !!res.data.data,
+              evalLen: res.data.data?.evaluation?.length ?? -1
+            })
+          } else {
+            data.overallEvaluation = null
+            console.warn('[EVAL][FE] overall non-200', {
+              version,
+              code: res.data.code,
+              msg: res.data.msg
+            })
+            ElMessage.error(res.data.msg || '获取整体评价失败')
+          }
         })
         .catch(err => {
-          console.log(err)
-          data.overallEvaluation = {} as OverallEvaluation
+          console.error('[EVAL][FE] overall request failed', { version, err })
+          data.overallEvaluation = null
+          ElMessage.error('整体评价请求失败')
         })
         .finally(() => {
           isLoadingOverall.value = false
@@ -299,6 +359,7 @@
             if(responseData && responseData.length > 0) {
               data.sentenceEvaluationList = responseData
               isLoadingSentence.value = false
+              console.info('[EVAL][FE] sentence ok', { version, count: responseData.length })
               
               // 检查数据完整性
               const hasEvaluation = responseData.some(item => item.evaluation)
@@ -331,9 +392,14 @@
           // 从未批改过
           if(res.data.data.length === 0){
             data.versionList = []
+            if (currentVersion.value != null) {
+              getEvaluation(currentVersion.value)
+            }
           } else {
             data.versionList = res.data.data
-            getEvaluation(res.data.data[0].version)
+            // 优先展示“当前最新提交版本”,否则回落到列表第一项(最新批改版本)
+            const prefer = currentVersion.value ?? res.data.data[0].version
+            getEvaluation(prefer)
           }
         })
         .catch(err => {
@@ -341,6 +407,19 @@
         })
   }
 
+  const fetchCurrentVersion = async () => {
+    try {
+      const res = await apis.getCurrentVersionEvaluation(assignmentId, store.user.id)
+      if (res.data.code === 200 && res.data.data?.version) {
+        currentVersion.value = res.data.data.version
+        selectedVersion.value = res.data.data.version
+      }
+    } catch (e) {
+      // 后端未部署 current 接口/用户未参与等情况,继续走历史列表逻辑
+      console.warn('获取当前版本失败:', e)
+    }
+  }
+
   const getBackgroundColor = (sentenceEvaluation: SentenceEvaluation) => {
     if (sentenceEvaluation.id === highlightSentenceId.value) {
       switch (sentenceEvaluation.type) {
@@ -376,7 +455,9 @@
   onMounted(() => {
     // toEvaluation()
     // getEvaluation()
-    getEvaluationVersionList()
+    fetchCurrentVersion().finally(() => {
+      getEvaluationVersionList()
+    })
   })
 
   // 组件卸载时清理定时器
@@ -411,7 +492,7 @@ export default {
 
 /* 作文展示区域 */
 .essay-section {
-  flex: 0 0 40%;
+  flex: 0 0 32%;
   min-width: 300px;
   position: relative;
   display: flex;
@@ -493,9 +574,105 @@ export default {
   overflow-wrap: break-word; /* 确保内容不会溢出 */
 }
 
+.markdown-content :deep(p) {
+  margin: 0 0 8px;
+}
+
+.markdown-content {
+  font-size: 13px;
+  line-height: 1.5;
+}
+
+.markdown-content :deep(p:last-child) {
+  margin-bottom: 0;
+}
+
+.markdown-content :deep(ul),
+.markdown-content :deep(ol) {
+  margin: 8px 0;
+  padding-left: 20px;
+}
+
+.markdown-content :deep(li) {
+  margin: 4px 0;
+}
+
+.markdown-content :deep(h1),
+.markdown-content :deep(h2),
+.markdown-content :deep(h3),
+.markdown-content :deep(h4),
+.markdown-content :deep(h5),
+.markdown-content :deep(h6) {
+  margin: 8px 0;
+  line-height: 1.4;
+  font-weight: 600;
+}
+
+.markdown-content :deep(h1) { font-size: 1.2em; }
+.markdown-content :deep(h2) { font-size: 1.12em; }
+.markdown-content :deep(h3) { font-size: 1.06em; }
+.markdown-content :deep(h4),
+.markdown-content :deep(h5),
+.markdown-content :deep(h6) { font-size: 1em; }
+
+.markdown-content :deep(code) {
+  padding: 2px 6px;
+  border-radius: 6px;
+  background-color: rgba(0, 0, 0, 0.06);
+  font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+  font-size: 0.9em;
+}
+
+.markdown-content :deep(pre) {
+  overflow-x: auto;
+  margin: 8px 0;
+  padding: 10px 12px;
+  border-radius: 10px;
+  background-color: rgba(0, 0, 0, 0.03);
+}
+
+.markdown-content :deep(pre code) {
+  padding: 0;
+  background: transparent;
+}
+
+.markdown-content :deep(blockquote) {
+  margin: 8px 0;
+  padding-left: 12px;
+  border-left: 3px solid rgba(0, 0, 0, 0.18);
+  color: #555;
+}
+
+.markdown-content :deep(a) {
+  color: #2563eb;
+  text-decoration: underline;
+}
+
+.markdown-content :deep(table) {
+  width: 100%;
+  border-collapse: collapse;
+  margin: 10px 0;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.markdown-content :deep(th),
+.markdown-content :deep(td) {
+  border: 1px solid #e5e7eb;
+  padding: 6px 8px;
+  text-align: left;
+  vertical-align: top;
+}
+
+.markdown-content :deep(th) {
+  background-color: #f8fafc;
+  font-weight: 600;
+}
+
 /* AI评价区域 */
 .evaluation-section {
-  flex: 0 0 40%;
+  flex: 0 0 48%;
   min-width: 300px;
   overflow: hidden;
 }
@@ -610,7 +787,8 @@ export default {
   box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
   word-wrap: break-word;
   overflow-wrap: break-word;
-  line-height: 1.6;
+  line-height: 1.5;
+  font-size: 13px;
 }
 
 .sentence-evaluation-content {
@@ -883,7 +1061,7 @@ export default {
   
   .message-content {
     padding: 10px 12px;
-    font-size: 14px;
+    font-size: 13px;
   }
   
   .sentence-evaluation-content {

+ 2 - 17
src/views/EditPage/EditPage.vue

@@ -38,7 +38,7 @@
   import router from "@/router"
   import {onMounted, onUnmounted, reactive, computed, ref, watch} from "vue";
   import type {engagementVO, IAssignment, ExamTimeVO} from "@/types/vo";
-  import type {IDialogue} from "@/types/dialogue.ts";
+  import type {IDialogue} from "@/types/dialogue";
   import Edit from "@/views/Home/component/Edit/index.vue";
   import Slider from '@/views/Home/component/Silder/index.vue'
   import "./index.scss"
@@ -124,6 +124,7 @@
     try {
       const _res = await apis.engagementStage(store.user.id, assignmentId, JSON.stringify(getQuillContent), getText.value, getHTML.value);
       emitter.emit('click-stage');
+      emitter.emit('is-modified', false);
       emitter.emit('stage-success');
       ElMessage.success(_res.data.data);
       return _res;
@@ -275,21 +276,6 @@
         })
   }
 
-  const saveAIDialogue = async () => {
-    if (dataForm.dialogueId && dataForm.messages.length > 0) {
-      try {
-        await apis.requestAI(dataForm.dialogueId, 'ChatGLM4', dataForm.messages)
-        console.log('AI对话内容已保存')
-      } catch (error) {
-        console.error('保存AI对话失败:', error)
-      }
-    }
-  }
-
-  emitter.on('save-ai-dialogue-before-submit', async () => {
-    await saveAIDialogue()
-  })
-
   emitter.on('exam-submitted', () => {
     dataForm.engagement.submitted = true;
     examTimeInfo.value = examTimeInfo.value ? { ...examTimeInfo.value, submitted: true } : null;
@@ -320,7 +306,6 @@
   onUnmounted(() => {
     stopCountdown();
     removeAllEventListeners();
-    emitter.off('save-ai-dialogue-before-submit');
     emitter.emit('exam-mode-change', false);
   })
 

+ 1 - 0
src/views/EditPage/userWritingRecord.ts

@@ -99,6 +99,7 @@ export const removeAllEventListeners = () => {
     emitter.off("click-ai-send-button");
     emitter.off("is-modified");
     emitter.off("click-stage");
+    isModified.value = false;
 
     if (_visibilityChangeHandler) {
         document.removeEventListener('visibilitychange', _visibilityChangeHandler);

+ 1 - 0
src/views/Home/component/Edit/index.vue

@@ -129,6 +129,7 @@
       console.log(props)
       const _res = await apis.engagementStage(store.user.id, props.engagement.assignmentId, JSON.stringify(getQuillContent), getText.value, getHTML.value);
       emitter.emit('click-stage');
+      emitter.emit('is-modified', false);
       // 通知 Silder 组件暂存成功,可以启用批改按钮
       emitter.emit('stage-success');
       success(_res.data.data);

+ 23 - 0
src/views/Home/component/Silder/index.scss

@@ -167,9 +167,32 @@
           background: rgba(74, 144, 196, 0.8);
         }
       }
+
+      .remark-empty {
+        height: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        color: #6b7280;
+        text-align: center;
+        padding: 0 0.75rem;
+      }
     }
   }
 
+  .correct-rule-tip {
+    margin-top: -0.5rem;
+    margin-bottom: 0.25rem;
+    padding: 0.5rem 0.625rem;
+    border-left: 4px solid #409eff;
+    border-radius: 0.5rem;
+    background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
+    color: #1d4ed8;
+    font-size: 0.75rem;
+    line-height: 1.45;
+    font-weight: 500;
+  }
+
 }
 
 

+ 201 - 73
src/views/Home/component/Silder/index.vue

@@ -57,10 +57,28 @@
         <div>智能批改</div> 
         <div class="edit-box-header">
           <el-button @click="handleCorrect" :disabled="!showCorrectBtn" @mouseover="handleCorrectTip">开始批改</el-button>
-          <el-button @click="handleCorrectToPage" :disabled="!showCorrectToPageBtn" @mouseenter="handleCorrectToPageTip">查看完整批改报告</el-button>
+          <el-button
+            v-if="showCorrectToPageBtn"
+            @click="handleCorrectToPage"
+          >
+            查看过往批改记录
+          </el-button>
+        </div>
+        <div class="correct-rule-tip">
+          提示:建议在提交前使用逐句批改,同一版本仅可批改一次。
         </div>
         <div class="capacity-item">
-          <div v-loading="loading" element-loading-text="智能批改中" class="remark">{{ data.overallEvaluation?.evaluation }}</div>
+          <div v-loading="loading" element-loading-text="智能批改中" class="remark">
+            <div v-if="data.overallEvaluation?.evaluation">
+              {{ data.overallEvaluation.evaluation }}
+            </div>
+            <div v-else class="remark-empty">
+              <el-empty
+                :image-size="96"
+                description="暂无批改结果,点击“开始批改”后在此查看智能批改内容。"
+              />
+            </div>
+          </div>
         </div>
       </template>
     </div>
@@ -80,7 +98,6 @@ 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";
 import emitter from "@/utils/emitter";
 
 
@@ -101,8 +118,11 @@ import emitter from "@/utils/emitter";
   let loading = ref(false)
   let open= ref(false)
   let showCorrectBtn = ref(false)
+  let canCorrectByVersion = ref(false)
   let showCorrectToPageBtn = ref(false)
   let hasStagedContent = ref(false) // 用于跟踪是否已暂存内容
+  let hasUnsavedChanges = ref(false) // 用于跟踪是否有未保存改动
+  let manualCorrectRequested = ref(false) // 仅允许手动点击后触发批改
 
   // 获得路由中的assignmentId
   const route = useRoute()
@@ -149,60 +169,79 @@ import emitter from "@/utils/emitter";
     // 如果没有传入version,使用props中的version
     const versionToUse = version !== undefined ? version : props.engagement.version;
     
-    apis.getOverall(props.engagement.assignmentId, store.user.id, versionToUse)
+    return apis.getOverall(props.engagement.assignmentId, store.user.id, versionToUse)
         .then(res => {
           if (res.data.data) {
             data.overallEvaluation = res.data.data;
             console.log('成功获取评语:', data.overallEvaluation);
           }
           loading.value = false;
+          return Boolean(res.data.data?.evaluation)
         })
         .catch(err => {
           ElMessage.error("获取智能批改结果异常")
           console.log(err)
           loading.value = false;
+          return false
         })
   }
 
+  const updateCorrectButtonState = () => {
+    showCorrectBtn.value = canCorrectByVersion.value && !hasUnsavedChanges.value
+  }
+
+  const isEvaluationTaskAccepted = (payload: any) => {
+    return payload?.code === 200
+      || payload?.status === 'accepted'
+      || payload?.data?.status === 'accepted'
+  }
+
+  const pollLatestEvaluationResult = (attempt = 0) => {
+    const maxAttempts = 20
+    const intervalMs = 1500
+
+    if (attempt >= maxAttempts) {
+      loading.value = false
+      ElMessage.warning("批改任务已提交,结果仍在生成中,请稍后查看")
+      return
+    }
+
+    apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
+      .then(async (versionRes: any) => {
+        const versionList = versionRes?.data?.data || []
+        if (!versionList.length) {
+          setTimeout(() => pollLatestEvaluationResult(attempt + 1), intervalMs)
+          return
+        }
+
+        const latestVersion = versionList[0].version
+        showCorrectToPageBtn.value = true
+        const hasEvaluation = await getEvaluation(latestVersion)
+        if (hasEvaluation) {
+          return
+        }
+
+        setTimeout(() => pollLatestEvaluationResult(attempt + 1), intervalMs)
+      })
+      .catch((err: any) => {
+        console.error('轮询批改结果失败:', err)
+        setTimeout(() => pollLatestEvaluationResult(attempt + 1), intervalMs)
+      })
+  }
+
   const toEvaluation = () => {
+    if (!manualCorrectRequested.value) {
+      return
+    }
+    manualCorrectRequested.value = false
+
     apis.overAllEvaluation(props.engagement.assignmentId, store.user.id)
         .then(res => {
-          if(res.data.code === 200){
-            console.log('批改请求成功,等待后端处理完成...');
-            
-            // 延迟获取评语,确保后端已经保存,并获取最新版本号
-            setTimeout(() => {
-              // 先获取最新的版本列表
-              apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
-                  .then(versionRes => {
-                    if (versionRes.data.data && versionRes.data.data.length > 0) {
-                      // 获取最新版本号(列表第一个就是最新的)
-                      const latestVersion = versionRes.data.data[0].version;
-                      console.log('获取到最新版本号:', latestVersion);
-                      
-                      // 使用最新版本号获取评语
-                      getEvaluation(latestVersion);
-                      
-                      // 允许查看详细报告
-                      showCorrectToPageBtn.value = true;
-                    } else {
-                      // 如果获取不到版本列表,尝试使用当前版本+1
-                      console.log('未获取到版本列表,使用当前版本+1');
-                      const nextVersion = (props.engagement.version || 0) + 1;
-                      getEvaluation(nextVersion);
-                      showCorrectToPageBtn.value = true;
-                    }
-                  })
-                  .catch(err => {
-                    console.error('获取版本列表失败:', err);
-                    // 如果获取版本列表失败,尝试使用当前版本+1
-                    const nextVersion = (props.engagement.version || 0) + 1;
-                    getEvaluation(nextVersion);
-                    showCorrectToPageBtn.value = true;
-                  });
-            }, 1500); // 延迟1.5秒,确保后端处理完成
-          }else{
-            ElMessage.error("智能批改请求超时")
+          if (isEvaluationTaskAccepted(res.data)) {
+            console.log('批改任务已提交,开始轮询结果...')
+            pollLatestEvaluationResult()
+          } else {
+            ElMessage.error(res.data?.message || "智能批改请求失败")
             loading.value = false;
           }
         })
@@ -225,8 +264,11 @@ import emitter from "@/utils/emitter";
 
   // 请求批改
   const handleCorrect = () => {
+    if (!showCorrectBtn.value) {
+      return
+    }
+    manualCorrectRequested.value = true
     loading.value = true;
-    showCorrectBtn.value = !showCorrectBtn.value
     toEvaluation()
   };
 
@@ -265,34 +307,109 @@ import emitter from "@/utils/emitter";
   };
 
   /**
-   * 用于判断是否已存在历史批改记录
+   * 优先用 /current 接口判断当前版本是否已有批改结果:
+   * - 有结果 → 直接展示,禁止重复批改
+   * - 无结果 → 再查历史列表,决定按钮状态
    */
   const getEvaluationVersionList = () => {
-    if(JSON.stringify(props.engagement) !== '{}'){
-      // 检查是否已有暂存的作文内容
-      const hasContent = props.engagement.content && props.engagement.content.trim() !== '';
-      if (hasContent) {
-        hasStagedContent.value = true;
-      }
-      
-      apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
+    if (JSON.stringify(props.engagement) === '{}') return
+
+    const hasContent = props.engagement.textContent && props.engagement.textContent.trim() !== ''
+    if (hasContent) {
+      hasStagedContent.value = true
+    }
+
+    apis.getCurrentVersionEvaluation(props.engagement.assignmentId, store.user.id)
+      .then((currentRes: any) => {
+        const currentEvaluation = currentRes?.data?.data
+        const currentVersion = currentEvaluation?.version
+        const overallStatus = currentEvaluation?.overallStatus ?? 0
+        const sentenceStatus = currentEvaluation?.sentenceStatus ?? 0
+
+        // /current 接口返回的是“状态”,不是“批改内容”
+        // 只有当状态显示已完成时,才认为该版本已批改;批改内容需用 /select/overall 获取
+        if (overallStatus === 1 || sentenceStatus === 1) {
+          canCorrectByVersion.value = false
+          showCorrectToPageBtn.value = true
+
+          // 优先展示整体评价内容(逐句内容在独立页面展示)
+          if (overallStatus === 1 && typeof currentVersion === 'number') {
+            console.info('[EVAL][FE][Silder] current has overall, fetch overall content', {
+              assignmentId: props.engagement.assignmentId,
+              userId: store.user.id,
+              version: currentVersion
+            })
+            apis.getOverall(props.engagement.assignmentId, store.user.id, currentVersion)
+              .then((res: any) => {
+                if (res?.data?.code === 200) {
+                  data.overallEvaluation = res.data.data
+                  console.info('[EVAL][FE][Silder] overall content loaded', {
+                    version: currentVersion,
+                    evalLen: res.data.data?.evaluation?.length ?? -1
+                  })
+                } else {
+                  data.overallEvaluation = null
+                  console.warn('[EVAL][FE][Silder] overall content non-200', {
+                    version: currentVersion,
+                    code: res?.data?.code,
+                    msg: res?.data?.msg
+                  })
+                }
+                updateCorrectButtonState()
+              })
+              .catch((err: any) => {
+                console.error('获取当前版本整体评价失败:', err)
+                data.overallEvaluation = null
+                updateCorrectButtonState()
+              })
+              .finally(() => {
+                // 避免 loading 遮罩导致用户误以为没有内容
+                loading.value = false
+              })
+          } else {
+            data.overallEvaluation = null
+            loading.value = false
+            updateCorrectButtonState()
+          }
+        } else {
+          // 当前版本尚未批改,查询历史列表决定按钮可用性
+          apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
+            .then(res => {
+              const list = res.data.data || []
+              if (list.length === 0) {
+                canCorrectByVersion.value = hasStagedContent.value
+                showCorrectToPageBtn.value = false
+              } else {
+                showCorrectToPageBtn.value = true
+                canCorrectByVersion.value = hasStagedContent.value
+              }
+              updateCorrectButtonState()
+            })
+            .catch(err => {
+              ElMessage.error("获取评价历史记录异常")
+              console.log(err)
+            })
+        }
+      })
+      .catch(err => {
+        console.error('检查当前版本批改状态异常,回退到历史列表查询:', err)
+        apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
           .then(res => {
-            // 从未批改过
-            if(res.data.data.length === 0){
-              // 修改:从未批改过时,只有当已有暂存内容时才能启用批改按钮
-              showCorrectBtn.value = hasStagedContent.value
+            const list = res.data.data || []
+            if (list.length === 0) {
+              canCorrectByVersion.value = hasStagedContent.value
               showCorrectToPageBtn.value = false
             } else {
               showCorrectToPageBtn.value = true
-              showCorrectBtn.value = (res.data.data[0].version !== props.engagement.version) && hasStagedContent.value
+              canCorrectByVersion.value = (list[0].version !== props.engagement.version) && hasStagedContent.value
             }
+            updateCorrectButtonState()
           })
-          .catch(err => {
+          .catch(err2 => {
             ElMessage.error("获取评价历史记录异常")
-            console.log(err)
+            console.log(err2)
           })
-    }
-
+      })
   }
 
   // 添加节流函数
@@ -309,15 +426,16 @@ import emitter from "@/utils/emitter";
 
   // 使用节流包装 handleCorrectTip
   const handleCorrectTip = throttle(() => {
-    if(!showCorrectBtn.value) {
-      ElMessage.info("请更新作文内容并暂存后批改");
+    if (!showCorrectBtn.value) {
+      if (hasUnsavedChanges.value) {
+        ElMessage.info("检测到未保存修改,请先暂存后再批改")
+      } else if (!canCorrectByVersion.value) {
+        ElMessage.info("当前版本已完成智能批改")
+      } else {
+        ElMessage.info("请更新作文内容并暂存后批改")
+      }
     }
-  }, 2000); // 2秒的节流时间
-
-  const handleCorrectToPageTip = throttle(() => {
-    if(!showCorrectToPageBtn.value)
-      ElMessage.info("批改后查看报告")
-  },1000);
+  }, 2000)
 
   // 监听props变化
   watch(() => props.engagement, (newEngagement, oldEngagement) => {
@@ -327,12 +445,21 @@ import emitter from "@/utils/emitter";
   }, { immediate: true });
 
   // 监听暂存成功事件
-  emitter.on('stage-success', () => {
-    console.log('收到暂存成功事件,启用批改按钮')
-    // 暂存成功后,标记已有暂存内容,并启用"开始批改"按钮
+  const handleStageSuccess = () => {
+    console.log('收到暂存成功事件,刷新当前版本批改状态')
     hasStagedContent.value = true
-    showCorrectBtn.value = true
-  })
+    hasUnsavedChanges.value = false
+    // 暂存不会产生新版本:是否可再次批改取决于当前版本是否已批改
+    getEvaluationVersionList()
+  }
+
+  const handleModifiedStateChange = (state: boolean) => {
+    hasUnsavedChanges.value = Boolean(state)
+    updateCorrectButtonState()
+  }
+
+  emitter.on('stage-success', handleStageSuccess)
+  emitter.on('is-modified', handleModifiedStateChange)
 
   onMounted(() => {
     fetchData()
@@ -340,7 +467,8 @@ import emitter from "@/utils/emitter";
 
   onUnmounted(() => {
     // 清理事件监听器
-    emitter.off('stage-success');
+    emitter.off('stage-success', handleStageSuccess);
+    emitter.off('is-modified', handleModifiedStateChange);
   });
 </script>
 

+ 13 - 19
src/views/HomeworkPage/component/HomeworkDetail.vue

@@ -114,15 +114,14 @@ const isAssignmentDisabled = computed(() => {
     return true
   }
 
-  if (now > endTime) {
-    if (engagementStatus.value) {
-      return engagementStatus.value.status !== 'NOT_SUBMITTED'
+  // 普通作业允许多次进入并再次提交;仅考试模式限制已提交/超时后再次进入
+  if (data.examMode === true) {
+    if (engagementStatus.value?.submitted) {
+      return true
+    }
+    if (now > endTime) {
+      return true
     }
-    return false
-  }
-
-  if (data.examMode === true && engagementStatus.value?.submitted) {
-    return true
   }
 
   return false
@@ -140,18 +139,13 @@ const getButtonTooltip = computed(() => {
 
   if (now < startTime) {
     return `作业尚未开始,开始时间:${startTime.toLocaleString()}`
-  } else if (now > endTime) {
-    if (engagementStatus.value) {
-      const status = engagementStatus.value.status
-      if (status === 'NOT_SUBMITTED') {
-        return '作业已截止,但您可以继续完成作业'
-      } else if (status === 'SUBMITTED') {
-        return '作业已截止且已提交,无法再次编辑'
-      } else if (status === 'CORRECTED') {
-        return '作业已截止且已批改,无法再次编辑'
-      }
+  } else if (data.examMode === true && now > endTime) {
+    return '考试已截止,无法再次进入'
+  } else if (!data.examMode && now > endTime) {
+    if (engagementStatus.value?.status === 'NOT_SUBMITTED') {
+      return '作业已截止,但您可以继续完成并补交'
     }
-    return `作业已截止,截止时间:${endTime.toLocaleString()}`
+    return '作业已截止,您可继续修改并再次提交(按补交记录)'
   } else {
     return '点击开始作业'
   }

+ 8 - 0
vite.config.js

@@ -6,6 +6,14 @@ export default defineConfig({
     plugins: [
         vue(),
     ],
+    css: {
+        preprocessorOptions: {
+            scss: {
+                // Use Sass modern JS API to avoid legacy API warnings.
+                api: 'modern-compiler',
+            },
+        },
+    },
     resolve: {
         alias: {
             '@': fileURLToPath(new URL('./src', import.meta.url))

+ 8 - 0
vite.config.ts

@@ -8,6 +8,14 @@ export default defineConfig({
   plugins: [
     vue(),
   ],
+  css: {
+    preprocessorOptions: {
+      scss: {
+        // Use Sass modern JS API to avoid legacy API warnings.
+        api: 'modern-compiler',
+      }
+    }
+  },
   resolve: {
     alias: {
       '@': fileURLToPath(new URL('./src', import.meta.url))