|
@@ -0,0 +1,436 @@
|
|
|
+<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"
|
|
|
+ >
|
|
|
+ <el-form-item prop="questionType" label="选择题型">
|
|
|
+ <el-button
|
|
|
+ v-for="item in questionTypes"
|
|
|
+ :key="item.id"
|
|
|
+ :type="
|
|
|
+ formModel.questionType === item.questionType
|
|
|
+ ? 'primary'
|
|
|
+ : 'default'
|
|
|
+ "
|
|
|
+ size="small"
|
|
|
+ @click="switchType(item)"
|
|
|
+ >{{ item.questionTypeName }}</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"
|
|
|
+ ></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 label="教学大纲">
|
|
|
+ <div class="box-justify">
|
|
|
+ <el-checkbox v-model="formModel.syllabus"
|
|
|
+ >教学大纲pdf</el-checkbox
|
|
|
+ >
|
|
|
+ <upload-button
|
|
|
+ btn-content="上传文件"
|
|
|
+ btn-icon="icon icon-import"
|
|
|
+ :disabled="uploading || !courseInfo.id"
|
|
|
+ :upload-data="{ id: courseInfo.id }"
|
|
|
+ :upload-url="uploadUrl"
|
|
|
+ :format="importFileTypes"
|
|
|
+ @valid-error="validError"
|
|
|
+ @upload-success="uploadSuccess"
|
|
|
+ ></upload-button>
|
|
|
+ </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"
|
|
|
+ :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" @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">
|
|
|
+ <pre>{{ output }}</pre>
|
|
|
+ </div>
|
|
|
+ <div class="action-buttons">
|
|
|
+ <el-button type="primary" @click="saveQuestions">保存试题</el-button>
|
|
|
+ <el-button @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 "../components/PropertyTreeSelect.vue";
|
|
|
+import UploadButton from "@/components/UploadButton.vue";
|
|
|
+import { QUESTION_API } from "@/constants/constants";
|
|
|
+import {
|
|
|
+ sourceDetailPageListApi,
|
|
|
+ aiBuildQuestionApi,
|
|
|
+ aiBuildQuestionSaveApi,
|
|
|
+} from "../api";
|
|
|
+
|
|
|
+import { fetchTime } from "@/plugins/syncServerTime";
|
|
|
+import { getAuthorization } from "@/plugins/crypto";
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: "AiQuestionCreateDialog",
|
|
|
+ components: {
|
|
|
+ PropertyTreeSelect,
|
|
|
+ UploadButton,
|
|
|
+ },
|
|
|
+ props: {
|
|
|
+ courseInfo: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({ id: "", name: "", code: "" }),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ modalIsShow: false,
|
|
|
+ formModel: this.getInitForm(),
|
|
|
+ questionTypes: [],
|
|
|
+ sseData: "", // 用于存储SSE流式数据
|
|
|
+ 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",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ // upload
|
|
|
+ uploading: false,
|
|
|
+ importFileTypes: ["pdf"],
|
|
|
+ uploadUrl: `${QUESTION_API}/course/outline/upload`,
|
|
|
+ showIframeDialog: false,
|
|
|
+ // ai question result
|
|
|
+ taskId: "",
|
|
|
+ aiResult: "",
|
|
|
+ // output
|
|
|
+ output: "",
|
|
|
+ loading: false,
|
|
|
+ controller: null,
|
|
|
+ parser: null,
|
|
|
+ };
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ isChoiceQuestion() {
|
|
|
+ return ["SINGLE_ANSWER_QUESTION", "MULTIPLE_ANSWER_QUESTION"].includes(
|
|
|
+ this.formModel.questionType
|
|
|
+ );
|
|
|
+ },
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ cancel() {
|
|
|
+ this.modalIsShow = false;
|
|
|
+ },
|
|
|
+ open() {
|
|
|
+ this.modalIsShow = true;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ close() {
|
|
|
+ this.modalIsShow = false;
|
|
|
+ },
|
|
|
+ open() {
|
|
|
+ this.modalIsShow = true;
|
|
|
+ },
|
|
|
+ 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.formModel.questionType = this.questionTypes[0].questionType;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ switchType(item) {
|
|
|
+ this.formModel.questionType = item.questionType;
|
|
|
+ this.$refs.form.validateField("questionType");
|
|
|
+ },
|
|
|
+ getInitForm() {
|
|
|
+ return {
|
|
|
+ questionType: "SINGLE_CHOICE",
|
|
|
+ questionCount: 1,
|
|
|
+ optionCount: 4,
|
|
|
+ syllabus: false,
|
|
|
+ syllabusNotes: "",
|
|
|
+ propertyIdList: [],
|
|
|
+ knowledgeNotes: "",
|
|
|
+ };
|
|
|
+ },
|
|
|
+ handleOpened() {
|
|
|
+ this.formModel = this.getInitForm();
|
|
|
+ this.getQuestionTypes();
|
|
|
+ this.getInitForm();
|
|
|
+ },
|
|
|
+ handleClose() {
|
|
|
+ this.stopStream();
|
|
|
+ },
|
|
|
+ validError(error) {
|
|
|
+ this.$message.error(error.message);
|
|
|
+ },
|
|
|
+ uploadSuccess(response) {
|
|
|
+ console.log(response);
|
|
|
+ // TODO:
|
|
|
+ this.$message.success("上传成功!");
|
|
|
+ },
|
|
|
+ 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.output = "";
|
|
|
+ this.aiResult = ""; // Reset aiResult for the new stream
|
|
|
+ this.taskId = ""; // Reset taskId for the new stream
|
|
|
+ this.loading = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await aiBuildQuestionApi(
|
|
|
+ {
|
|
|
+ ...this.formModel,
|
|
|
+ courseId: this.courseInfo.id,
|
|
|
+ rootOrgId: this.$store.state.user.rootOrgId,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ "Content-Type": "application/json",
|
|
|
+ ...this.setAuth({
|
|
|
+ method: "post",
|
|
|
+ url: "/api/uq_basic/ai/question/stream/build",
|
|
|
+ }),
|
|
|
+ },
|
|
|
+ signal: (this.controller = new AbortController()).signal, // Ensures a new controller for each call
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ const onEvent = (event) => {
|
|
|
+ console.log(event);
|
|
|
+ console.log(Date.now());
|
|
|
+ if (event.data === "[DONE]") {
|
|
|
+ this.controller.abort(); // End the stream
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(event.data);
|
|
|
+ if (!this.taskId && parsed.id) {
|
|
|
+ this.taskId = parsed.id;
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ parsed.choices &&
|
|
|
+ parsed.choices[0] &&
|
|
|
+ parsed.choices[0].delta
|
|
|
+ ) {
|
|
|
+ const content = parsed.choices[0].delta.content;
|
|
|
+ console.log(content);
|
|
|
+ if (typeof content === "string") {
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ this.output += content;
|
|
|
+ this.aiResult += content;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error(
|
|
|
+ "Error parsing SSE JSON:",
|
|
|
+ e,
|
|
|
+ "Event data:",
|
|
|
+ event.data
|
|
|
+ );
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const onError = (error) => {
|
|
|
+ console.error("Error parsing event:", error);
|
|
|
+ if (error.type === "invalid-field") {
|
|
|
+ console.error("Field name:", error.field);
|
|
|
+ console.error("Field value:", error.value);
|
|
|
+ console.error("Line:", error.line);
|
|
|
+ } else if (error.type === "invalid-retry") {
|
|
|
+ console.error("Invalid retry interval:", error.value);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ 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) {
|
|
|
+ if (err.name !== "AbortError") {
|
|
|
+ console.error("流式处理错误:", err);
|
|
|
+ }
|
|
|
+ } 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.output = "";
|
|
|
+ },
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ this.stopStream(); // 在组件销毁前清理流
|
|
|
+ },
|
|
|
+};
|
|
|
+</script>
|