<template> <el-dialog custom-class="ai-question-create-dialog" :visible.sync="modalIsShow" fullscreen :close-on-click-modal="false" :close-on-press-escape="false" append-to-body @opened="handleOpened" @close="handleClose" > <div slot="title" class="dialog-title-container"> <div class="title-left"> <span class="title-text">创建试题 - AI 命题</span> <span v-if="courseInfo.name && courseInfo.code" class="course-info"> {{ courseInfo.name }} ({{ courseInfo.code }}) </span> </div> </div> <div class="dialog-content-container"> <div class="left-panel"> <el-form ref="form" :model="formModel" :rules="rules" label-width="90px" class="form-container" :disabled="loading" > <el-form-item prop="questionType" label="选择题型"> <el-button v-for="item in questionTypes" :key="item.id" :type=" formModel.questionType === item.questionType && formModel.questionTypeName === item.name ? 'primary' : 'default' " size="small" style="margin: 0 5px 6px" @click="switchType(item)" >{{ item.name }}</el-button > </el-form-item> <el-form-item prop="questionCount" label="出题数量"> <el-input-number v-model="formModel.questionCount" :min="1" :max="10" :step="1" step-strictly :controls="false" disabled ></el-input-number> </el-form-item> <el-form-item v-if="isChoiceQuestion" prop="optionCount" label="选项个数" > <el-input-number v-model="formModel.optionCount" :min="2" :max="8" :step="1" step-strictly :controls="false" ></el-input-number> </el-form-item> <el-form-item v-if="courseInfo.outlineFilePath && courseOutlineParsed" label="教学大纲" > <div class="box-justify"> <el-checkbox v-model="formModel.syllabus" >教学大纲pdf</el-checkbox > </div> <el-input v-if="formModel.syllabus" type="textarea" :rows="3" v-model="formModel.syllabusNotes" placeholder="请输入教学大纲补充说明" ></el-input> </el-form-item> <el-form-item label="选择知识点"> <property-tree-select v-model="formModel.propertyIdList" :course-id="courseInfo.id" :disabled="loading" :style="{ width: '100%' }" ></property-tree-select> <el-input v-if=" formModel.propertyIdList && formModel.propertyIdList.length > 0 " v-model="formModel.knowledgeNotes" type="textarea" :rows="3" placeholder="请输入知识点补充说明" style="margin-top: 10px" ></el-input> </el-form-item> <el-form-item> <el-button type="primary" :loading="loading" @click="toBuildQuestion" >生成试题</el-button > </el-form-item> </el-form> </div> <div class="right-panel"> <div class="sse-output-title">试题预览</div> <div class="sse-output-container" ref="sseOuiputContainerRef"> <template v-if="aiThinkingResult"> <div class="sse-thinking-title" @click="thinkVisible = !thinkVisible" > <template v-if="thinking"> <i class="el-icon-loading" style="margin-right: 6px"></i> <span>深度思考中...</span> </template> <template v-else> <svg-btn name="think"></svg-btn> <span>已深度思考(用时:{{ thinkDuration }}秒)</span> <i v-if="thinkVisible" class="el-icon el-icon-arrow-down"></i> <i v-else class="el-icon el-icon-arrow-down"></i> </template> </div> <div v-show="thinkVisible" class="sse-thinking-container"> <sse-result-view :output="aiThinkingResult"></sse-result-view> </div> </template> <sse-result-view :output="aiResult"></sse-result-view> </div> <div class="action-buttons"> <el-button type="primary" :disabled="loading" @click="saveQuestions" >保存试题</el-button > <el-button :disabled="loading" @click="clearPreview" >清空预览</el-button > </div> </div> </div> </el-dialog> </template> <script> import { BASE_QUESTION_TYPES } from "@/constants/constants"; import { createParser } from "eventsource-parser"; import PropertyTreeSelect from "../PropertyTreeSelect.vue"; import SseResultView from "./SseResultView.vue"; import { sourceDetailPageListApi, aiBuildQuestionApi, aiBuildQuestionSaveApi, } from "../../api"; import { courseOutlineParsedCheckApi } from "@/modules/questions/api"; import { fetchTime } from "@/plugins/syncServerTime"; import { getAuthorization } from "@/plugins/crypto"; export default { name: "AiQuestionCreateDialog", components: { PropertyTreeSelect, SseResultView, }, props: { courseInfo: { type: Object, default: () => ({ id: "", name: "", code: "" }), }, }, data() { return { modalIsShow: false, formModel: this.getInitForm(), questionTypes: [], rules: { courseId: [ { required: true, message: "请选择课程", trigger: "change", }, ], questionCount: [ { required: true, message: "请输入出题数量", trigger: "change", }, ], optionCount: [ { required: true, message: "请输入选项个数", trigger: "change", }, ], questionType: [ { required: true, message: "请选择题型", trigger: "change", }, ], }, courseOutlineParsed: false, // ai question result taskId: "", aiResult: "", aiThinkingResult: "", thinking: false, thinkVisible: true, thinkDuration: "", loading: false, controller: null, parser: null, }; }, computed: { isChoiceQuestion() { return ["SINGLE_ANSWER_QUESTION", "MULTIPLE_ANSWER_QUESTION"].includes( this.formModel.questionType ); }, }, methods: { scrollToBottom() { const container = this.$refs.sseOuiputContainerRef; if (container) { container.scrollTop = container.scrollHeight; } }, close() { this.modalIsShow = false; }, open() { this.modalIsShow = true; }, async checkCourseOutlineParsed() { if (!this.courseInfo.id || !this.courseInfo.outlineFilePath) return; const res = await courseOutlineParsedCheckApi(this.courseInfo.id).catch( () => {} ); if (!res) return; this.courseOutlineParsed = res.data; }, async getQuestionTypes() { if (!this.courseInfo.id) return; const res = await sourceDetailPageListApi({ courseId: this.courseInfo.id, rootOrgId: this.$store.state.user.rootOrgId, pageSize: 100, pageNum: 1, }).catch(() => {}); if (!res) return; const baseQuestionCodes = BASE_QUESTION_TYPES.map((item) => item.code); this.questionTypes = (res.data.content || []).filter((item) => baseQuestionCodes.includes(item.questionType) ); if (this.questionTypes.length > 0) { this.switchType(this.questionTypes[0]); } }, switchType(item) { this.formModel.questionType = item.questionType; this.formModel.questionTypeName = item.name; this.$refs.form.validateField("questionType"); }, getInitForm() { return { questionType: "SINGLE_CHOICE", questionTypeName: "", questionCount: 1, optionCount: 4, syllabus: false, syllabusNotes: "", propertyIdList: [], knowledgeNotes: "", }; }, handleOpened() { this.formModel = this.getInitForm(); this.getQuestionTypes(); this.getInitForm(); this.checkCourseOutlineParsed(); }, handleClose() { this.aiResult = ""; this.aiThinkingResult = ""; this.taskId = ""; this.thinking = false; this.thinkDuration = ""; this.courseOutlineParsed = false; this.stopStream(); }, setAuth(config) { const headers = {}; let userSession = sessionStorage.getItem("user"); if (userSession) { let user = JSON.parse(userSession); const timestamp = fetchTime(); const authorization = getAuthorization( { method: config.method, uri: config.url.split("?")[0].trim(), timestamp, sessionId: user.sessionId, token: user.accessToken, }, "token" ); headers["Authorization"] = authorization; headers["time"] = timestamp; } return headers; }, async toBuildQuestion() { const valid = await this.$refs.form.validate().catch(() => false); if (!valid) return; if (this.loading) return; this.stopStream(); // 清理前一个流 this.aiResult = ""; this.aiThinkingResult = ""; this.thinkDuration = ""; this.taskId = ""; this.loading = true; // 设置60秒超时 const timeoutId = setTimeout(() => { this.controller.abort(); this.$message.error("请求超时!"); this.loading = false; }, 60000); try { this.controller = new AbortController(); const res = await aiBuildQuestionApi( { ...this.formModel, courseId: this.courseInfo.id, rootOrgId: this.$store.state.user.rootOrgId, }, { headers: { "Content-Type": "application/json", Accept: "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...this.setAuth({ method: "post", url: "/api/uq_basic/ai/question/stream/build", }), }, signal: this.controller.signal, // 使用AbortController的signal } ); // 请求成功后清除超时定时器 clearTimeout(timeoutId); const startThinkingTime = Date.now(); this.thinking = true; const onEvent = (event) => { if (event.data === "[DONE]") { // console.log(this.aiResult); return; } try { const parsed = JSON.parse(event.data); if (!this.taskId && parsed.taskId) { this.taskId = parsed.taskId; return; } if ( parsed.choices && parsed.choices[0] && parsed.choices[0].delta ) { const { content, reasoning_content } = parsed.choices[0].delta; // 只要content不为null,则表示思考结束,开始处理生成结果内容 if (content !== null) { this.thinking = false; if (!this.thinkDuration) { this.thinkDuration = Math.round( (Date.now() - startThinkingTime) / 1000 ); } requestAnimationFrame(() => { this.aiResult += content || ""; this.scrollToBottom(); }); return; } // 处理思考过程内容 requestAnimationFrame(() => { this.aiThinkingResult += reasoning_content || ""; this.scrollToBottom(); }); } } catch (e) { console.error( "Error parsing SSE JSON:", e, "Event data:", event.data ); } }; const onError = (error) => { this.thinking = false; console.error("Error parsing event:", error); }; const parser = createParser({ onEvent, onError }); this.parser = parser; // Pipe the response stream through TextDecoderStream and feed to parser const reader = res.body .pipeThrough(new TextDecoderStream()) .getReader(); // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read(); if (done) { break; } this.parser.feed(value); } } catch (err) { console.error("流式处理错误:", err); this.thinking = false; // 请求出错时清除超时定时器 clearTimeout(timeoutId); } finally { this.loading = false; this.controller = null; // Ensure controller is reset } }, stopStream() { this.controller?.abort(); this.controller = null; this.parser = null; this.loading = false; }, async saveQuestions() { if (this.loading) return; this.loading = true; const res = await aiBuildQuestionSaveApi({ aiResult: this.aiResult, taskId: this.taskId, }).catch(() => {}); this.loading = false; if (!res) return; this.$message.success("保存成功"); this.$emit("modified"); this.close(); }, clearPreview() { this.aiResult = ""; this.aiThinkingResult = ""; this.taskId = ""; }, }, beforeDestroy() { this.stopStream(); // 在组件销毁前清理流 }, }; </script> <style> .thinking-container { background-color: #f8f9fa; border-left: 3px solid #409eff; margin-bottom: 20px; } </style>