Forráskód Böngészése

Merge branch 'dev_1.2.4' of http://git.qmth.com.cn/union-question/union-question-web into dev_1.2.4

xiatian 2 éve
szülő
commit
d44e7e9154

BIN
src/assets/images/icon-files-act.png


BIN
src/assets/images/icon-files.png


+ 4 - 0
src/assets/styles/base.scss

@@ -263,6 +263,10 @@ body {
   align-items: center;
   justify-content: space-between;
 }
+.box-flex {
+  display: flex;
+  align-items: center;
+}
 .body-content {
   margin: 15px;
 }

+ 10 - 0
src/assets/styles/icons.scss

@@ -175,4 +175,14 @@
     background-image: url(../images/icon-box-check.png);
     @include icon-14;
   }
+  &-files {
+    background-image: url(../images/icon-files.png);
+    width: 14px;
+    height: 12px;
+  }
+  &-files-act {
+    background-image: url(../images/icon-files-act.png);
+    width: 14px;
+    height: 12px;
+  }
 }

+ 42 - 0
src/assets/styles/pages.scss

@@ -807,3 +807,45 @@
   overflow-x: hidden;
   overflow-y: auto;
 }
+
+// folder-tree
+.folder-tree {
+  .node-icon {
+    margin-right: 8px;
+    width: 18px;
+    height: 14px;
+  }
+  .node-cont {
+    display: inline-block;
+    vertical-align: middle;
+    border-radius: 3px;
+    height: 26px;
+    line-height: 26px;
+    padding-left: 5px;
+    padding-right: 5px;
+    &.is-active {
+      background-color: mix(#fff, $--color-primary, 80%);
+    }
+  }
+
+  .el-tree-node__content {
+    color: $--color-text-primary;
+    height: auto;
+    min-height: 30px;
+    background-color: transparent !important;
+  }
+  .node-form {
+    display: inline-block;
+    vertical-align: middle;
+  }
+  .el-form-item {
+    margin-bottom: 0;
+  }
+  .el-input__inner {
+    width: 240px;
+  }
+  .el-button {
+    padding: 5px;
+    font-size: 14px;
+  }
+}

+ 244 - 0
src/components/ImportFile.vue

@@ -0,0 +1,244 @@
+<template>
+  <div class="import-file">
+    <el-upload
+      ref="UploadComp"
+      :action="uploadUrl"
+      :headers="headers"
+      :max-size="maxSize"
+      :accept="accept"
+      :format="format"
+      :data="uploadDataDict"
+      :on-error="handleError"
+      :on-success="handleSuccess"
+      :on-change="fileChange"
+      :http-request="upload"
+      :show-file-list="false"
+      :disabled="loading"
+      :auto-upload="false"
+    >
+      <el-button
+        slot="trigger"
+        size="small"
+        type="primary"
+        icon="icon icon-search-white"
+        :disabled="loading"
+      >
+        选择文件
+      </el-button>
+      <el-button
+        size="small"
+        type="primary"
+        icon="icon icon-save-white"
+        :loading="loading"
+        :disabled="!fileValid"
+        @click="submitUpload"
+      >
+        确认上传
+      </el-button>
+      <el-button
+        size="small"
+        type="primary"
+        icon="icon icon-delete-white"
+        :disabled="loading"
+        @click="removeFile"
+      >
+        清空文件
+      </el-button>
+      <el-button
+        v-if="templateUrl"
+        size="small"
+        type="primary"
+        icon="icon icon-export-white"
+        @click="exportFile"
+      >
+        下载模板
+      </el-button>
+    </el-upload>
+    <p v-if="filename" class="tips-info">{{ filename }}</p>
+    <p v-if="!res.success" class="tips-info tips-error">{{ res.message }}</p>
+  </div>
+</template>
+
+<script>
+import { fileMD5 } from "@/plugins/md5";
+import { $httpWithMsg } from "@/plugins/axios";
+
+export default {
+  name: "ImportFile",
+  props: {
+    format: {
+      type: Array,
+      default() {
+        return ["xlsx", "xls"];
+      },
+    },
+    accept: {
+      type: String,
+      default: null,
+    },
+    onlyFetchFile: {
+      type: Boolean,
+      default: false,
+    },
+    uploadUrl: {
+      type: String,
+      default: "",
+    },
+    uploadData: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+    maxSize: {
+      type: Number,
+      default: 20 * 1024 * 1024,
+    },
+    addFilenameParam: {
+      type: String,
+      default: "filename",
+    },
+    templateUrl: {
+      type: String,
+      default: "",
+    },
+  },
+  data() {
+    return {
+      headers: {
+        md5: "",
+      },
+      res: {},
+      loading: false,
+      uploadDataDict: {},
+      filename: "",
+      fileValid: false,
+    };
+  },
+  methods: {
+    initData() {
+      this.res = {};
+      this.loading = false;
+      this.uploadDataDict = {};
+      this.filename = "";
+      this.fileValid = false;
+    },
+    checkFileFormat(fileType) {
+      const _file_format = fileType.split(".").pop().toLowerCase();
+      return this.format.length
+        ? this.format.some((item) => item.toLowerCase() === _file_format)
+        : true;
+    },
+    fileChange(fileObj) {
+      if (fileObj.status === "ready") {
+        this.handleBeforeUpload(fileObj.raw).catch(() => {});
+      }
+    },
+    async handleBeforeUpload(file) {
+      this.res = {};
+      this.filename = file.name;
+      this.uploadDataDict = {
+        ...this.uploadData,
+      };
+      this.uploadDataDict[this.addFilenameParam] = file.name;
+
+      if (!this.checkFileFormat(file.name)) {
+        this.handleFormatError();
+        this.fileValid = false;
+        return Promise.reject();
+      }
+
+      if (file.size > this.maxSize) {
+        this.handleExceededSize();
+        this.fileValid = false;
+        return Promise.reject();
+      }
+
+      const md5 = await fileMD5(file);
+      this.headers["md5"] = md5;
+      this.fileValid = true;
+
+      if (this.onlyFetchFile) {
+        this.$emit("file-change", {
+          file,
+          md5,
+        });
+      }
+
+      return true;
+    },
+    upload(options) {
+      if (!options.file) return Promise.reject("文件丢失");
+
+      let formData = new FormData();
+      Object.entries(options.data).forEach(([k, v]) => {
+        formData.append(k, v);
+      });
+      formData.append("file", options.file);
+
+      return $httpWithMsg.post(options.action, formData, {
+        headers: options.headers,
+      });
+    },
+    handleError(error) {
+      this.loading = false;
+      this.res = {
+        success: false,
+        message: error.response.data.desc,
+      };
+      this.uploadDataDict = {};
+      this.filename = "";
+      this.fileValid = false;
+      this.$refs.UploadComp.clearFiles();
+    },
+    handleSuccess(res) {
+      this.loading = false;
+      this.res = {
+        success: true,
+        message: "导入成功!",
+      };
+      this.cancel();
+      this.$emit("uploaded", res);
+    },
+    handleFormatError() {
+      const content = "只支持文件格式为" + this.format.join("/");
+      this.res = {
+        success: false,
+        message: content,
+      };
+    },
+    handleExceededSize() {
+      const content =
+        "文件大小不能超过" + Math.floor(this.maxSize / (1024 * 1024)) + "M";
+      this.res = {
+        success: false,
+        message: content,
+      };
+    },
+    // action
+    submitUpload() {
+      if (this.onlyFetchFile) {
+        this.$emit("confirm");
+        return;
+      }
+      if (this.loading) return;
+      this.$refs.UploadComp.submit();
+      this.loading = true;
+    },
+    removeFile() {
+      if (this.loading) return;
+      this.uploadDataDict = {};
+      this.filename = "";
+      this.fileValid = false;
+      this.res = {};
+      this.$refs.UploadComp.clearFiles();
+    },
+    exportFile() {
+      window.location.href = this.templateUrl;
+    },
+    setLoading(loading) {
+      this.loading = loading;
+    },
+  },
+};
+</script>

+ 4 - 4
src/components/selection/QuestionTypeSelect.vue

@@ -7,10 +7,10 @@
     @change="select"
   >
     <el-option
-      v-for="(val, key) in QUESTION_TYPES"
-      :key="key"
-      :label="val"
-      :value="key"
+      v-for="item in QUESTION_TYPES"
+      :key="item.code"
+      :label="item.name"
+      :value="item.code"
     ></el-option>
   </el-select>
 </template>

+ 24 - 11
src/constants/constants.js

@@ -134,16 +134,29 @@ export const DIFFICULTY_LEVEL_ENUM = {
   EASY: "易",
 };
 
-// question
+//
 export const QUESTION_TYPES = [
-  { value: "SINGLE_ANSWER_QUESTION", label: "单选" },
-  { value: "MULTIPLE_ANSWER_QUESTION", label: "多选" },
-  { value: "BOOL_ANSWER_QUESTION", label: "判断" },
-  { value: "FILL_BLANK_QUESTION", label: "填空" },
-  { value: "TEXT_ANSWER_QUESTION", label: "问答" },
-  { value: "READING_COMPREHENSION", label: "阅读理解" },
-  { value: "LISTENING_QUESTION", label: "听力" },
-  { value: "CLOZE", label: "完形填空" },
-  { value: "PARAGRAPH_MATCHING", label: "段落匹配" },
-  { value: "BANKED_CLOZE", label: "选词填空" },
+  { code: "SINGLE_ANSWER_QUESTION", name: "单选" },
+  { code: "MULTIPLE_ANSWER_QUESTION", name: "多选" },
+  { code: "BOOL_ANSWER_QUESTION", name: "判断" },
+  { code: "FILL_BLANK_QUESTION", name: "填空" },
+  { code: "TEXT_ANSWER_QUESTION", name: "问答" },
+  { code: "READING_COMPREHENSION", name: "阅读理解" },
+  { code: "LISTENING_QUESTION", name: "听力" },
+  { code: "CLOZE", name: "完形填空" },
+  { code: "PARAGRAPH_MATCHING", name: "段落匹配" },
+  { code: "BANKED_CLOZE", name: "选词填空" },
 ];
+
+export const DIFFICULTY_LIST = [
+  { code: "难", name: "难" },
+  { code: "中", name: "中" },
+  { code: "易", name: "易" },
+];
+
+/**
+ * sections 必须有,这样结构容易验证和访问
+ * sections 的内容可以为 [] ,表示一个完全的空富文本
+ * 空行用 {type: "text", value: "", param: null} 来表示
+ */
+export const EMPTY_RICH_TEXT = { sections: [] };

+ 15 - 0
src/modules/question/api.js

@@ -33,3 +33,18 @@ export function questionPageListApi(data, { pageNo, pageSize }) {
 export function deleteQuestionApi(questionId) {
   return $httpWithMsg.get(`${QUESTION_API}/paper/deleteQuestion/${questionId}`);
 }
+export function moveQuestionApi(questionId, folderId) {
+  return $httpWithMsg.get(`${QUESTION_API}/paper/moveQuestion/`, {
+    params: { questionId, folderId },
+  });
+}
+export function copyQuestionApi(questionId) {
+  return $httpWithMsg.get(`${QUESTION_API}/paper/copyQuestion/`, {
+    params: { questionId },
+  });
+}
+export function importQuestionApi(data, headData) {
+  return $httpWithMsg.post(`${QUESTION_API}/paper/copyQuestion/`, data, {
+    headers: headData,
+  });
+}

+ 92 - 0
src/modules/question/components/QuestionEditDialog.vue

@@ -0,0 +1,92 @@
+<template>
+  <el-dialog
+    custom-class="side-dialog"
+    :visible.sync="modalIsShow"
+    :title="title"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    fullscreen
+    @open="visibleChange"
+  >
+    <div class="part-box question-edit">
+      <div class="question-type">
+        <div class="question-type-list">
+          <span>题型</span>
+          <el-button
+            v-for="item in QUESTION_TYPES"
+            :key="item.code"
+            :type="curQuestionType === item.code ? 'primary' : 'default'"
+            @click="switchType(item)"
+            >{{ item.name }}</el-button
+          >
+        </div>
+        <p class="tips-info">
+          说明:如果是综合类试题(套题)可以选择题型为阅卷理解进行录入操作。
+        </p>
+      </div>
+      <!-- question-body -->
+      <component
+        :is="structTypeComp"
+        ref="QuestionEditDetail"
+        :question="question"
+      ></component>
+    </div>
+    <div slot="footer">
+      <el-button type="primary" @click="confirm">确定</el-button>
+      <el-button @click="cancel">取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { QUESTION_TYPES } from "@/constants/constants";
+
+const structTypeCompDict = {
+  SINGLE_ANSWER_QUESTION: "select-question",
+  MULTIPLE_ANSWER_QUESTION: "select-question",
+  BOOL_ANSWER_QUESTION: "boolean-question",
+  FILL_BLANK_QUESTION: "fill-blank-question",
+  TEXT_ANSWER_QUESTION: "text-answer-question",
+  READING_COMPREHENSION: "",
+  LISTENING_QUESTION: "",
+  CLOZE: "",
+  PARAGRAPH_MATCHING: "",
+  BANKED_CLOZE: "",
+};
+
+export default {
+  name: "QuestionEditDialog",
+  data() {
+    return {
+      modalIsShow: false,
+      QUESTION_TYPES,
+      curQuestionType: "SINGLE_ANSWER_QUESTION",
+    };
+  },
+  computed: {
+    title() {
+      return "11";
+    },
+    structTypeComp() {
+      return structTypeCompDict[this.curQuestionType];
+    },
+  },
+  methods: {
+    visibleChange() {},
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    switchType(item) {
+      // to warning
+      this.curQuestionType = item.code;
+    },
+    confirm() {
+      this.cancel();
+    },
+  },
+};
+</script>

+ 214 - 0
src/modules/question/components/QuestionFolderDialog.vue

@@ -0,0 +1,214 @@
+<template>
+  <el-dialog
+    custom-class="side-dialog"
+    :visible.sync="modalIsShow"
+    :title="title"
+    width="600px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="visibleChange"
+  >
+    <el-tree
+      class="folder-tree"
+      :data="folderTree"
+      node-key="id"
+      default-expand-all
+      :expand-on-click-node="false"
+      :props="defaultProps"
+      @node-click="nodeClick"
+    >
+      <span slot-scope="{ node, data }">
+        <i class="icon icon-files-act node-icon"></i>
+        <span v-if="data.id === 'none'" class="node-form">
+          <el-form
+            ref="modalFormComp"
+            :model="modalForm"
+            :rules="rules"
+            size="mini"
+            :show-message="false"
+            inline
+          >
+            <el-form-item prop="name">
+              <el-input
+                v-model="modalForm.name"
+                placeholder="请输入文件夹名称"
+                clearable
+              ></el-input>
+            </el-form-item>
+            <el-form-item>
+              <el-button
+                type="primary"
+                icon="el-icon-check"
+                @click="toCreateFolder"
+              ></el-button>
+              <el-button
+                type="danger"
+                icon="el-icon-close"
+                @click="toRemoveFolder(node, data)"
+              ></el-button>
+            </el-form-item>
+          </el-form>
+        </span>
+        <span
+          v-else
+          :class="['node-cont', { 'is-active': curNodeData.id === data.id }]"
+          >{{ node.label }}</span
+        >
+      </span>
+    </el-tree>
+
+    <div slot="footer" class="box-justify">
+      <el-button v-if="isEdit" type="primary" @click="toAddFolder"
+        >新建文件夹</el-button
+      >
+      <div>
+        <el-button v-if="!isEdit" type="primary" @click="confirm"
+          >确定</el-button
+        >
+        <el-button @click="cancel">取消</el-button>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+export default {
+  name: "QuestionFolderDialog",
+  props: {
+    folderId: {
+      type: [String, Number],
+      default: "",
+    },
+    isEdit: {
+      type: Boolean,
+      default: true,
+    },
+  },
+  data() {
+    return {
+      modalIsShow: false,
+      folderTree: [
+        {
+          id: "1",
+          parent: null,
+          name: "根目录",
+          children: [
+            {
+              id: "101",
+              parent: "1",
+              name: "2021-2022第一学期期末考试用",
+            },
+            {
+              id: "102",
+              parent: "1",
+              name: "2021-2022第二学期期末考试用",
+            },
+          ],
+        },
+      ],
+      defaultProps: {
+        label: "name",
+      },
+      curNodeData: {},
+      modalForm: {
+        name: "",
+      },
+      rules: {
+        name: [
+          {
+            required: true,
+            message: "请输入文件夹名称",
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  computed: {
+    title() {
+      return this.isEdit ? "新建文件夹" : "选择文件夹";
+    },
+  },
+  methods: {
+    visibleChange() {
+      if (this.folderId && !this.isEdit) {
+        this.curNodeData = this.findNodeById(this.folderId);
+      }
+    },
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    nodeClick(data) {
+      if (data.id === "none" || data.id === this.curNodeData.id) return;
+      this.clearPreNewNode();
+      this.$nextTick(() => {
+        this.curNodeData = this.findNodeById(data.id);
+      });
+    },
+    findNodeById(id) {
+      let curNode = null;
+      const findNode = (data) => {
+        if (curNode) return;
+
+        data.forEach((item) => {
+          if (curNode) return;
+
+          if (item.id === id) {
+            curNode = item;
+            return;
+          }
+          if (item.children && item.children.length) findNode(item.children);
+        });
+      };
+      findNode(this.folderTree);
+
+      return curNode || {};
+    },
+    clearPreNewNode() {
+      const removePreNewChild = (data) => {
+        data = data.filter((item) => item.id !== "none");
+        return data.map((item) => {
+          if (item.children && item.children.length)
+            item.children = removePreNewChild(item.children);
+          return item;
+        });
+      };
+      this.folderTree = removePreNewChild(this.folderTree);
+    },
+    toAddFolder() {
+      if (!this.curNodeData.id) {
+        this.$message.error("请先选择文件夹!");
+        return;
+      }
+      const newChild = { id: "none", name: "", parentId: this.curNodeData.id };
+      if (!this.curNodeData.children) {
+        this.$set(this.curNodeData, "children", []);
+      }
+      this.curNodeData.children.push(newChild);
+      this.modalForm = { name: "" };
+    },
+    async toCreateFolder() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+    },
+    toRemoveFolder(node, data) {
+      const parent = node.parent;
+      const children = parent.data.children || parent.data;
+      const index = children.findIndex((d) => d.id === data.id);
+      children.splice(index, 1);
+    },
+    confirm() {
+      if (!this.curNodeData.id) {
+        this.$message.error("请选择文件夹!");
+        return;
+      }
+      this.$emit("selected", this.curNodeData);
+    },
+  },
+};
+</script>

+ 155 - 0
src/modules/question/components/QuestionImportDialog.vue

@@ -0,0 +1,155 @@
+<template>
+  <el-dialog
+    custom-class="side-dialog"
+    :visible.sync="modalIsShow"
+    title="试题导入"
+    width="700px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    destroy-on-close
+    @open="visibleChange"
+  >
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      label-width="120px"
+    >
+      <el-form-item label="导入类型">
+        <el-radio-group v-model="modalForm.importType">
+          <el-radio label="word">word</el-radio>
+          <el-radio label="zip">zip</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item prop="courseId" label="导入类型">
+        <course-select v-model="modalForm.courseId"> </course-select>
+      </el-form-item>
+      <el-form-item label="是否使用原卷">
+        <el-radio-group v-model="modalForm.userPaper">
+          <el-radio :label="true">是</el-radio>
+          <el-radio :label="false">否</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="总分校验">
+        <el-radio-group v-model="modalForm.scoreCheck">
+          <el-radio :label="true">开启</el-radio>
+          <el-radio :label="false">关闭</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item prop="file">
+        <import-file
+          ref="ImportFile"
+          :format="[modalForm.importType]"
+          :template-url="templateUrl"
+          only-fetch-file
+          @file-change="fileChange"
+          @confirm="confirm"
+        ></import-file>
+      </el-form-item>
+    </el-form>
+
+    <div slot="footer"></div>
+  </el-dialog>
+</template>
+
+<script>
+import ImportFile from "@/components/ImportFile.vue";
+import { importQuestionApi } from "../api";
+import { QUESTION_API } from "@/constants/constants";
+import { mapState } from "vuex";
+
+const initModalForm = {
+  importType: "word",
+  courseId: "",
+  userPaper: false,
+  scoreCheck: false,
+};
+
+export default {
+  name: "QuestionImportDialog",
+  components: { ImportFile },
+  data() {
+    return {
+      modalIsShow: false,
+      modalForm: {
+        ...initModalForm,
+      },
+      rules: {
+        courseId: [
+          {
+            required: true,
+            message: "请选择课程",
+            trigger: "change",
+          },
+        ],
+        file: [
+          {
+            validate: (rule, value, callback) => {
+              if (!this.fileData.file) {
+                return callback(new Error(`请输入选择文件`));
+              }
+              callback();
+            },
+            trigger: "change",
+          },
+        ],
+      },
+      fileData: {},
+      templateUrl: "",
+      loading: false,
+    };
+  },
+  computed: {
+    ...mapState({ user: (state) => state.user }),
+  },
+  mounted() {
+    this.templateUrl = `${QUESTION_API}/import/paper/template?$key=${this.user.key}&$token=${this.user.token}`;
+  },
+  methods: {
+    visibleChange() {
+      this.modalForm = { ...initModalForm };
+      this.fileData = {};
+    },
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    fileChange(fileData) {
+      this.fileData = fileData;
+      this.$refs.modalFormComp.validateField("file", (err) => {
+        console.log(err);
+      });
+    },
+    async confirm() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      if (this.loading) return;
+      this.loading = true;
+      this.$refs.ImportFile.setLoading(true);
+
+      let formData = new FormData();
+      Object.entries(this.modalForm).forEach(([key, val]) => {
+        formData.append(key, val);
+      });
+      formData.append("file", this.fileData.file);
+
+      const res = await importQuestionApi(formData, {
+        md5: this.fileData.md5,
+      }).catch(() => {});
+      this.loading = false;
+      this.$refs.ImportFile.setLoading(false);
+
+      if (!res) return;
+
+      this.$message.success("导入成功!");
+      this.$emit("modified");
+      this.cancel();
+    },
+  },
+};
+</script>

+ 147 - 0
src/modules/question/components/QuestionInfoEdit.vue

@@ -0,0 +1,147 @@
+<template>
+  <div class="question-info-edit">
+    <el-form label-width="100px">
+      <el-form-item label="难度">
+        <el-select v-model="quesModel.difficulty" placeholder="请输入难度">
+          <el-option
+            v-for="item in difficultyList"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          >
+          </el-option>
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="属性名">
+        <div class="box-flex">
+          <property-select
+            v-model="properties.coursePropertyId"
+            :course-id="modelForm.courseId"
+            @change="coursePropertyChange"
+          ></property-select>
+          <span>一级</span>
+          <property-sub-select
+            v-model="properties.firstPropertyId"
+            :parent-id="properties.coursePropertyId"
+            data-type="first"
+            @change="firstPropertyChange"
+          ></property-sub-select>
+          <span>二级</span>
+          <property-sub-select
+            v-model="properties.secondPropertyId"
+            :parent-id="properties.firstPropertyId"
+            data-type="second"
+            @change="secondPropertyChange"
+          ></property-sub-select>
+          <el-button
+            type="primary"
+            icon="icon icon-plus-white"
+            :disabled="!propSelected"
+            @click="addProperty"
+            >新增属性</el-button
+          >
+        </div>
+      </el-form-item>
+      <el-form-item label="属性列表">
+        <el-tag
+          v-for="item in modelForm.quesProperties"
+          :key="item.id"
+          style="margin-right: 5px"
+          closable
+          effect="dark"
+          type="primary"
+          @close="removeProperty(item)"
+        >
+          {{ item.name }}
+        </el-tag>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { DIFFICULTY_LIST } from "@/constants/constants";
+
+export default {
+  name: "QuestionInfoEdit",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {
+          courseId: "",
+          difficulty: "易",
+          difficultyDegree: null,
+          quesProperties: [],
+        };
+      },
+    },
+  },
+  data() {
+    return {
+      DIFFICULTY_LIST,
+      modelForm: {
+        difficulty: "易",
+        quesProperties: [],
+      },
+      properties: {
+        coursePropertyId: "",
+        firstPropertyId: "",
+        secondPropertyId: "",
+      },
+      selection: {
+        courseProperty: {},
+        firstProperty: {},
+        secondProperty: {},
+      },
+    };
+  },
+  computed: {
+    propSelected() {
+      return (
+        this.properties.coursePropertyId &&
+        this.properties.firstPropertyId &&
+        this.properties.secondPropertyId
+      );
+    },
+  },
+  methods: {
+    coursePropertyChange(val) {
+      this.selection.courseProperty = val || {};
+    },
+    firstPropertyChange(val) {
+      this.selection.firstProperty = val || {};
+    },
+    secondPropertyChange(val) {
+      this.selection.secondProperty = val || {};
+    },
+    removeProperty(property) {
+      this.modelForm.quesProperties = this.modelForm.quesProperties.filter(
+        (item) => property.id !== item.id
+      );
+      this.emitChange();
+    },
+    addProperty() {
+      if (!this.propSelected) return;
+      const newProperty = {
+        id: `${this.properties.coursePropertyId}_${this.properties.firstPropertyId}_${this.properties.secondPropertyId}`,
+        name: `${this.selection.courseProperty.name},${this.selection.firstProperty.name},${this.selection.secondProperty.name}`,
+        ...this.properties,
+      };
+      const propertyExist = this.modelForm.quesProperties.find(
+        (item) => item.id === newProperty.id
+      );
+      if (propertyExist) {
+        this.$message.error("属性已存在!");
+        return;
+      }
+      this.modelForm.quesProperties.push(newProperty);
+      this.emitChange();
+    },
+    emitChange() {
+      this.$emit("change", this.modelForm);
+    },
+  },
+};
+</script>

+ 60 - 0
src/modules/question/components/QuestionSafetySetDialog.vue

@@ -0,0 +1,60 @@
+<template>
+  <el-dialog
+    custom-class="side-dialog"
+    :visible.sync="modalIsShow"
+    title="安全设置"
+    width="500px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="visibleChange"
+  >
+    <el-form>
+      <el-form-item label="加密“题库”,“卷库”">
+        <el-switch v-model="modalForm.questionActionIsCrypto"></el-switch>
+        <p class="tips-info">开启后,进入题库、卷库模块,需要进行密码验证!</p>
+      </el-form-item>
+    </el-form>
+
+    <div slot="footer">
+      <el-button type="primary" @click="confirm">确定</el-button>
+      <el-button @click="cancel">取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { mapState, mapMutations } from "vuex";
+
+export default {
+  name: "QuestionSafetySetDialog",
+  data() {
+    return {
+      modalIsShow: false,
+      modalForm: {
+        questionActionIsCrypto: false,
+      },
+    };
+  },
+  computed: {
+    ...mapState("question", ["questionActionIsCrypto"]),
+  },
+  methods: {
+    ...mapMutations("question", ["setQuestionActionIsCrypto"]),
+    visibleChange() {
+      this.modalForm.questionActionIsCrypto = this.questionActionIsCrypto;
+    },
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    confirm() {
+      this.setQuestionActionIsCrypto(this.modalForm.questionActionIsCrypto);
+      this.cancel();
+    },
+  },
+};
+</script>

+ 149 - 0
src/modules/question/components/QuestionStatisticsDialog.vue

@@ -0,0 +1,149 @@
+<template>
+  <el-dialog
+    class="question-statistics-dialog"
+    :visible.sync="modalIsShow"
+    title="试题统计"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    fullscreen
+    @open="visibleChange"
+  >
+    <div>
+      <el-button
+        v-for="item in types"
+        :key="item.code"
+        :type="item.code === curType ? 'primary' : 'default'"
+        @click="toSwitch(item)"
+        >{{ item.name }}</el-button
+      >
+    </div>
+
+    <div class="part-box">
+      <el-table
+        v-if="curType === 'base'"
+        :data="baseDataList"
+        :span-method="spanMethod"
+      >
+        <el-table-column label="题型">
+          <el-table-column
+            label="题型大类"
+            prop="questionMainTypeName"
+          ></el-table-column>
+          <el-table-column
+            label="题型小类"
+            prop="questionTypeName"
+          ></el-table-column>
+        </el-table-column>
+        <el-table-column
+          label="试题数量"
+          prop="questionCountContent"
+        ></el-table-column>
+      </el-table>
+
+      <el-table
+        v-if="curType === 'blue'"
+        :data="blueDataList"
+        :span-method="spanMethod"
+      >
+        <el-table-column label="蓝图属性" fixed="left">
+          <el-table-column
+            label="一级属性"
+            prop="firstPropertyName"
+          ></el-table-column>
+          <el-table-column
+            label="二级属性"
+            prop="secondPropertyName"
+          ></el-table-column>
+        </el-table-column>
+        <el-table-column label="题型">
+          <el-table-column
+            v-for="item in blueQtypes"
+            :key="item.code"
+            :label="item.name"
+            :prop="item.code"
+          ></el-table-column>
+        </el-table-column>
+      </el-table>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+export default {
+  name: "QuestionStatisticsDialog",
+  data() {
+    return {
+      modalIsShow: false,
+      types: [
+        {
+          name: "基础构成",
+          code: "base",
+        },
+        {
+          name: "蓝图分布",
+          code: "blue",
+        },
+      ],
+      curType: "base",
+      baseDataList: [
+        {
+          id: "1",
+          questionMainTypeName: "基础题型",
+          questionTypeName: "单选",
+          questionCountContent: "48(难:10,中:15,易:23)",
+          rowspan: 3,
+        },
+        {
+          id: "2",
+          questionMainTypeName: "基础题型",
+          questionTypeName: "多选",
+          questionCountContent: "48(难:10,中:15,易:23)",
+        },
+        {
+          id: "3",
+          questionMainTypeName: "基础题型",
+          questionTypeName: "判断",
+          questionCountContent: "48(难:10,中:15,易:23)",
+        },
+        {
+          id: "4",
+          questionMainTypeName: "组合题型",
+          questionTypeName: "阅读理解",
+          questionCountContent: "48(难:10,中:15,易:23)",
+          rowspan: 2,
+        },
+        {
+          id: "5",
+          questionMainTypeName: "组合题型",
+          questionTypeName: "完型填空",
+          questionCountContent: "48(难:10,中:15,易:23)",
+        },
+      ],
+      blueDataList: [],
+      blueQtypes: [],
+    };
+  },
+  methods: {
+    visibleChange() {},
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    toSwitch(item) {
+      this.curType = item.code;
+    },
+    spanMethod({ row, columnIndex }) {
+      if (columnIndex === 0) {
+        return {
+          rowspan: row.rowspan || 0,
+          colspan: 1,
+        };
+      }
+    },
+  },
+};
+</script>

+ 13 - 0
src/modules/question/components/edit/BooleanQuestion.vue

@@ -0,0 +1,13 @@
+<template>
+  <div class="boolean-question">BooleanQuestion</div>
+</template>
+
+<script>
+export default {
+  name: "BooleanQuestion",
+  data() {
+    return {};
+  },
+  methods: {},
+};
+</script>

+ 21 - 0
src/modules/question/components/edit/FillBlankQuestion.vue

@@ -0,0 +1,21 @@
+<template>
+  <div class="fill-blank-question">FillBlankQuestion-question</div>
+</template>
+
+<script>
+export default {
+  name: "FillBlankQuestion",
+  props: {
+    question: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  methods: {},
+};
+</script>

+ 186 - 0
src/modules/question/components/edit/SelectQuestion.vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="select-question">
+    <el-form
+      ref="modalFormComp"
+      :model="modelForm"
+      :rules="rules"
+      label-width="100px"
+    >
+      <el-form-item prop="quesBody" label="题干:">
+        <v-editor v-model="modalForm.quesBody"></v-editor>
+      </el-form-item>
+      <el-form-item
+        v-for="(option, oindex) in modalForm.quesOptions"
+        :key="option.nmuber"
+        :prop="`quesOptions.${oindex}.body`"
+        :rules="optionRule"
+      >
+        <div class="question-edit-option">
+          <div class="option-check">
+            {{ (option.nmuber - 1) | optionOrderWordFilter }}
+          </div>
+          <div class="option-body">
+            <v-editor v-model="option.body"></v-editor>
+          </div>
+          <div class="option-delete">
+            <el-button
+              size="mini"
+              circle
+              type="primary"
+              icon="el-icon-add"
+              title="新增"
+              @click.prevent="addQuesOption(index)"
+            ></el-button>
+            <el-button
+              size="mini"
+              circle
+              type="danger"
+              icon="el-icon-delete"
+              title="删除"
+              @click.prevent="removeQuesOption(index)"
+            ></el-button>
+          </div>
+        </div>
+      </el-form-item>
+      <el-form-item prop="quesAnswer" label="答案">
+        <el-radio-group
+          v-if="IS_SIMPLE"
+          v-model="quesAnswer"
+          @change="answerChange"
+        >
+          <el-radio
+            v-for="option in modalForm.quesOptions"
+            :key="option.number"
+            :label="option.number"
+          >
+            {{ (option.number - 1) | optionOrderWordFilter }}
+          </el-radio>
+        </el-radio-group>
+        <el-checkbox-group
+          v-if="IS_SIMPLE"
+          v-model="quesAnswer"
+          @change="answerChange"
+        >
+          <el-checkbox
+            v-for="option in modalForm.quesOptions"
+            :key="option.number"
+            :label="option.number"
+          >
+            {{ (option.number - 1) | optionOrderWordFilter }}
+          </el-checkbox>
+        </el-checkbox-group>
+      </el-form-item>
+      <el-form-item label="答案解析">
+        <v-editor v-model="modalForm.comment"></v-editor>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { isAnEmptyRichText } from "@/utils/utils";
+
+export default {
+  name: "SelectQuestion",
+  props: {
+    question: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      modalForm: {
+        questionType: "SINGLE_ANSWER_QUESTION",
+        quesBody: null,
+        quesOptions: [],
+        quesAnswer: [],
+        comment: null,
+      },
+      quesAnswer: null,
+      rules: {
+        quesBody: [
+          {
+            validator: (rule, value, callback) => {
+              if (!value || isAnEmptyRichText(value)) {
+                return callback(new Error(`请输入题干`));
+              }
+              callback();
+            },
+            trigger: "change",
+          },
+        ],
+        quesAnswer: [
+          {
+            validator: (rule, value, callback) => {
+              if (!value || !value.length) {
+                return callback(new Error(`请设置答案`));
+              }
+              callback();
+            },
+            trigger: "change",
+          },
+        ],
+      },
+      optionRule: {
+        validator: (rule, value, callback) => {
+          if (!value || !value.length) {
+            return callback(new Error(`请输入选项内容`));
+          }
+          callback();
+        },
+        trigger: "change",
+      },
+    };
+  },
+  computed: {
+    IS_SIMPLE() {
+      return this.question.questionType === "SINGLE_ANSWER_QUESTION";
+    },
+    answer() {
+      return "w";
+    },
+  },
+  methods: {
+    addQuesOption(index) {
+      if (this.modalForm.quesOptions.length >= 20) {
+        this.$message.error("选项最多20个");
+        return;
+      }
+      this.modalForm.quesOptions.splice(index + 1, 0, {
+        number: 0,
+        body: { sections: [] },
+      });
+      this.resetNumberAndSaveOptions();
+    },
+    removeQuesOption(index) {
+      this.modalForm.quesOptions.splice(index, 1);
+      this.resetNumberAndSaveOptions();
+    },
+    resetNumberAndSaveOptions() {
+      this.modalForm.quesOptions = this.modalForm.quesOptions.forEach(
+        (option, index) => {
+          option.number = index + 1;
+        }
+      );
+      const optionCount = this.modalForm.quesOptions.length;
+      if (this.IS_SIMPLE) {
+        if (this.quesAnswer > optionCount) this.quesAnswer = null;
+      } else {
+        this.quesAnswer = this.quesAnswer.filter((item) => item <= optionCount);
+      }
+      this.answerChange();
+    },
+    answerChange() {
+      this.modalForm.quesAnswer = this.IS_SIMPLE
+        ? [this.quesAnswer]
+        : this.quesAnswer;
+    },
+    validate() {
+      return this.$refs.modalFormComp.validate();
+    },
+  },
+};
+</script>

+ 13 - 0
src/modules/question/components/edit/TextAnswerQuestion.vue

@@ -0,0 +1,13 @@
+<template>
+  <div class="text-answer-auestion">TextAnswerQuestion</div>
+</template>
+
+<script>
+export default {
+  name: "TextAnswerQuestion",
+  data() {
+    return {};
+  },
+  methods: {},
+};
+</script>

+ 118 - 0
src/modules/question/components/model/questionModel.js

@@ -0,0 +1,118 @@
+import { deepCopy } from "@/plugins/utils";
+
+export const select_question = {
+  questionType: 1, // 2多选
+  quesBody: {
+    sections: [],
+  },
+  quesOptions: [
+    {
+      number: 1,
+      body: {
+        sections: [{ blocks: [{ type: "text", value: "黑格尔" }] }],
+      },
+    },
+    {
+      number: 2,
+      body: {
+        sections: [{ blocks: [{ type: "text", value: "苏格拉底" }] }],
+      },
+    },
+  ],
+  quesAnswer: [4],
+  comment: { sections: [] },
+};
+
+export const boolean_question = {
+  questionType: 3,
+  quesBody: {
+    sections: [],
+  },
+  quesAnswer: false,
+  comment: { sections: [] },
+};
+
+export const fill_blank_question = {
+  questionType: 4,
+  quesBody: {
+    sections: [],
+  },
+  quesAnswer: [
+    {
+      index: 1,
+      sections: [{ blocks: [{ type: "text", value: "电纺丝技术" }] }],
+    },
+    {
+      index: 2,
+      sections: [{ blocks: [{ type: "text", value: "电纺丝技术" }] }],
+    },
+  ],
+  comment: { sections: [] },
+};
+
+export const text_answer_question = {
+  questionType: 5,
+  quesBody: {
+    sections: [],
+  },
+  // only one
+  quesAnswer: [
+    {
+      index: 1,
+      sections: [{ blocks: [{ type: "text", value: "电纺丝技术" }] }],
+    },
+  ],
+  comment: { sections: [] },
+};
+// 完型填空/听力/阅读理解
+export const reading_comprehension_question = {
+  questionType: 6,
+  quesBody: {
+    sections: [],
+  },
+  comment: { sections: [] },
+  subQuestions: [],
+  // answer: [{ number: 1, answer: [8] }],
+};
+
+// 段落匹配/选词填空
+export const banked_cloze_question = {
+  questionType: 8,
+  quesBody: {
+    sections: [],
+  },
+  comment: { sections: [] },
+  subQuestions: [
+    // select_question
+  ],
+  quesOptions: [
+    {
+      number: 1,
+      body: {
+        sections: [{ blocks: [{ type: "text", value: "黑格尔" }] }],
+      },
+    },
+    {
+      number: 2,
+      body: {
+        sections: [{ blocks: [{ type: "text", value: "苏格拉底" }] }],
+      },
+    },
+  ],
+  param: { matchingMode: 1, matchingType: 1 },
+  // 段落匹配: "param": { "matchingMode": 2, "matchingType": 2 },
+  quesAnswer: [{ number: 1, answer: [8] }],
+};
+
+const models = {
+  select_question,
+  boolean_question,
+  fill_blank_question,
+  text_answer_question,
+  reading_comprehension_question,
+  banked_cloze_question,
+};
+
+export const getInitQuestionModel = (qtype) => {
+  return deepCopy(models[qtype]);
+};

+ 14 - 0
src/modules/question/store.js

@@ -0,0 +1,14 @@
+const questionActionIsCrypto = sessionStorage.getItem("questionActionIsCrypto");
+
+export default {
+  namespaced: true,
+  state: {
+    questionActionIsCrypto: questionActionIsCrypto === "true",
+  },
+  mutations: {
+    setQuestionActionIsCrypto(state, questionActionIsCrypto) {
+      sessionStorage.setItem("questionActionIsCrypto", questionActionIsCrypto);
+      state.questionActionIsCrypto = questionActionIsCrypto;
+    },
+  },
+};

+ 76 - 13
src/modules/question/views/QuestionManage.vue

@@ -52,7 +52,7 @@
           <el-button
             type="danger"
             plain
-            icon="icon icon-delete"
+            icon="el-icon-lock"
             @click="toSafetySet"
             >安全设置</el-button
           >
@@ -61,21 +61,21 @@
           <el-button
             type="primary"
             plain
-            icon="icon icon-import"
+            icon="el-icon-folder-opened"
             @click="toAddDir"
             >新建文件夹</el-button
           >
           <el-button
             type="primary"
             plain
-            icon="icon icon-import"
+            icon="el-icon-circle-plus-outline"
             @click="toCreateQuestion"
             >创建试题</el-button
           >
           <el-button
             type="primary"
             plain
-            icon="icon icon-import"
+            icon="el-icon-upload2"
             @click="toImportQuestion"
             >批量导入</el-button
           >
@@ -193,14 +193,50 @@
         </el-pagination>
       </div>
     </div>
+
+    <!-- QuestionStatisticsDialog -->
+    <question-statistics-dialog
+      ref="QuestionStatisticsDialog"
+    ></question-statistics-dialog>
+    <!-- QuestionSafetySetDialog -->
+    <question-safety-set-dialog
+      ref="QuestionSafetySetDialog"
+    ></question-safety-set-dialog>
+    <!-- QuestionFolderDialog -->
+    <question-folder-dialog
+      ref="QuestionFolderDialog"
+      :is-edit="isEditFolder"
+      :folder-id="curQuestionFolderId"
+      @selected="folderSelected"
+    ></question-folder-dialog>
+    <!-- QuestionImportDialog -->
+    <question-import-dialog
+      ref="QuestionImportDialog"
+      @modified="toPage(1)"
+    ></question-import-dialog>
   </div>
 </template>
 
 <script>
-import { questionPageListApi, deleteQuestionApi } from "./api";
+import {
+  questionPageListApi,
+  deleteQuestionApi,
+  moveQuestionApi,
+  copyQuestionApi,
+} from "../api";
+import QuestionStatisticsDialog from "../components/QuestionStatisticsDialog.vue";
+import QuestionSafetySetDialog from "../components/QuestionSafetySetDialog.vue";
+import QuestionFolderDialog from "../components/QuestionFolderDialog.vue";
+import QuestionImportDialog from "../components/QuestionImportDialog.vue";
 
 export default {
   name: "QuestionMamage",
+  components: {
+    QuestionStatisticsDialog,
+    QuestionSafetySetDialog,
+    QuestionFolderDialog,
+    QuestionImportDialog,
+  },
   data() {
     return {
       filter: {
@@ -216,6 +252,8 @@ export default {
       pageSize: 10,
       total: 0,
       loading: false,
+      isEditFolder: true,
+      curQuestionFolderId: null,
     };
   },
   mounted() {
@@ -240,11 +278,21 @@ export default {
       this.pageSize = val;
       this.toPage(1);
     },
-    toStatistics() {},
-    toSafetySet() {},
-    toAddDir() {},
+    toStatistics() {
+      this.$refs.QuestionStatisticsDialog.open();
+    },
+    toSafetySet() {
+      this.$refs.QuestionSafetySetDialog.open();
+    },
+    toAddDir() {
+      this.isEditFolder = true;
+      this.curQuestionFolderId = null;
+      this.$refs.QuestionFolderDialog.open();
+    },
     toCreateQuestion() {},
-    toImportQuestion() {},
+    toImportQuestion() {
+      this.$refs.QuestionImportDialog.open();
+    },
     toView(row) {
       console.log(row);
     },
@@ -253,12 +301,27 @@ export default {
       // todo:编辑试题
     },
     toMove(row) {
-      console.log(row);
-      // todo:归类试题
+      this.isEditFolder = false;
+      this.curQuestionFolderId = row.foldId || null;
+      this.$refs.QuestionFolderDialog.open();
     },
-    toCopy(row) {
+    async folderSelected(folder) {
+      console.log(folder);
+      const res = await moveQuestionApi(
+        this.curQuestionFolderId,
+        folder.id
+      ).catch(() => {});
+
+      if (!res) return;
+      this.$message.success("操作成功!");
+      this.getList();
+    },
+    async toCopy(row) {
       console.log(row);
-      // todo:编辑试题
+      const res = await copyQuestionApi(row.id).catch(() => {});
+      if (!res) return;
+      this.$message.success("操作成功!");
+      this.getList();
     },
     async toDelete(row) {
       const confirm = await this.$confirm("确认删除试题吗?", "提示", {

+ 1 - 0
src/modules/questions/routes/routes.js

@@ -8,6 +8,7 @@ import InsertBluePaperStructureInfo from "../views/InsertBluePaperStructureInfo.
 import CourseProperty from "../views/CourseProperty.vue";
 import PropertyInfo from "../views/PropertyInfo.vue";
 import ImportPaper from "../views/ImportPaper.vue";
+// import ImportPaper from "../../question/views/QuestionManage";
 import GenPaper from "../views/GenPaper.vue";
 import ImportPaperInfo from "../views/ImportPaperInfo.vue";
 import GenPaperDetail from "../views/GenPaperDetail.vue";

+ 2 - 0
src/store/index.js

@@ -4,6 +4,7 @@ import user from "../modules/portal/store/user";
 import currentPaths from "../modules/portal/store/currentPaths";
 import menuList from "../modules/portal/store/menuList";
 import { card } from "../modules/card/store";
+import question from "../modules/question/store";
 
 Vue.use(Vuex);
 
@@ -16,5 +17,6 @@ export default new Vuex.Store({
     currentPaths,
     menuList,
     card,
+    question,
   },
 });

+ 27 - 0
src/utils/utils.js

@@ -3,3 +3,30 @@ import queryString from "query-string";
 export function object2QueryString(obj) {
   return queryString.stringify(obj);
 }
+
+/**
+ * 判断富文本是否是一个空,用来判断富文本是否为空
+ *
+ * @param {cont} 富文本对象
+ */
+export function isAnEmptyRichText(cont) {
+  if (!cont) return true;
+
+  if (!cont.sections || !cont.sections.length) return true;
+
+  let blocks = [];
+  cont.sections.forEach((section) => {
+    blocks = [...blocks, ...section.blocks];
+  });
+
+  if (!blocks.length) return true;
+
+  if (blocks.some((item) => item.type !== "text")) {
+    return false;
+  } else {
+    return !blocks
+      .map((item) => item.value)
+      .join("")
+      .trim();
+  }
+}