Quellcode durchsuchen

试卷导出模板模块

zhangjie vor 2 Jahren
Ursprung
Commit
fa3dfd4c41
24 geänderte Dateien mit 2696 neuen und 0 gelöschten Zeilen
  1. 7 0
      src/modules/paper-export/api.js
  2. 130 0
      src/modules/paper-export/components/BoxElementEdit.vue
  3. 115 0
      src/modules/paper-export/components/ElementPropEdit.vue
  4. 42 0
      src/modules/paper-export/components/PageNumber.vue
  5. 170 0
      src/modules/paper-export/components/PagePropEdit.vue
  6. 76 0
      src/modules/paper-export/components/PaperSideEdit.vue
  7. 286 0
      src/modules/paper-export/components/PaperTemplateDesign.vue
  8. 246 0
      src/modules/paper-export/components/RightClickMenu.vue
  9. 109 0
      src/modules/paper-export/components/TopicElementEdit.vue
  10. 59 0
      src/modules/paper-export/components/TopicElementPreview.vue
  11. 78 0
      src/modules/paper-export/elementModel.js
  12. 166 0
      src/modules/paper-export/elements/page/EditPage.vue
  13. 42 0
      src/modules/paper-export/elements/page/model.js
  14. 55 0
      src/modules/paper-export/elements/pane-box/EditPaneBox.vue
  15. 30 0
      src/modules/paper-export/elements/pane-box/ElemPaneBox.vue
  16. 128 0
      src/modules/paper-export/elements/pane-box/ElemPaneBoxEdit.vue
  17. 25 0
      src/modules/paper-export/elements/pane-box/model.js
  18. 138 0
      src/modules/paper-export/elements/text/EditText.vue
  19. 37 0
      src/modules/paper-export/elements/text/ElemText.vue
  20. 30 0
      src/modules/paper-export/elements/text/model.js
  21. 540 0
      src/modules/paper-export/store/paper-export.js
  22. 13 0
      src/modules/paper-export/views/PaperTemplateEdit.vue
  23. 161 0
      src/modules/paper-export/views/PaperTemplateManage.vue
  24. 13 0
      src/modules/paper-export/views/PaperTemplatePreview.vue

+ 7 - 0
src/modules/paper-export/api.js

@@ -0,0 +1,7 @@
+import { $httpWithMsg } from "../../plugins/axios";
+import { QUESTION_API } from "@/constants/constants.js";
+
+// paper-template-mamage
+export const paperTemplateListApi = (datas) => {
+  return $httpWithMsg.post(`${QUESTION_API}/paper/template/page`, datas);
+};

+ 130 - 0
src/modules/paper-export/components/BoxElementEdit.vue

@@ -0,0 +1,130 @@
+<template>
+  <div class="topic-element-edit">
+    <element-resize
+      v-model="elemData"
+      :class="{ 'element-resize-act': curElement.id === data.id }"
+      :transform-fit="transformFit"
+      :element-pk="data.id"
+      is-compact
+      @resize-over="resizeOver"
+      @on-click="activeCurElement"
+    >
+      <div
+        :id="data.id"
+        :class="classes"
+        :style="styles"
+        :data-type="data.type"
+      >
+        <component :is="compName" :data="data"></component>
+      </div>
+    </element-resize>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations } from "vuex";
+import { objAssign } from "../../../plugins/utils";
+import ElementResize from "../../../components/common/ElementResize";
+import TopicNumber from "../../../components/common/TopicNumber";
+// elements
+import EditFillQuestion from "../elements/fill-question/ElemFillQuestion";
+import EditFillLine from "../elements/fill-line/ElemFillLine";
+import EditText from "../../../elements/text/ElemText";
+import EditImage from "../../../elements/image/ElemImage";
+import EditLine from "../../../elements/line/ElemLine";
+import EditLines from "../../../elements/lines/ElemLines";
+import EditGrids from "../../../elements/grids/ElemGrids";
+import EditPane from "../../../elements/pane/ElemPane";
+import EditBarcode from "../../../elements/barcode/ElemBarcode";
+import EditFillNumber from "../../../elements/fill-number/ElemFillNumber";
+import EditFillField from "../../../elements/fill-field/ElemFillField";
+import EditFillTable from "../../../elements/fill-table/ElemFillTable";
+import EditFillPane from "../../../elements/fill-pane/ElemFillPane";
+
+export default {
+  name: "TopicElementEdit",
+  components: {
+    ElementResize,
+    TopicNumber,
+    EditFillQuestion,
+    EditFillLine,
+    EditFillNumber,
+    EditFillField,
+    EditFillTable,
+    EditFillPane,
+    EditText,
+    EditImage,
+    EditLine,
+    EditLines,
+    EditGrids,
+    EditPane,
+    EditBarcode,
+  },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+    transformFit: {
+      type: Function,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      styles: {},
+      elemData: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0,
+        zindex: 9,
+        init: false,
+      },
+    };
+  },
+  computed: {
+    ...mapState("free", ["curElement"]),
+    elementName() {
+      if (this.data.type.includes("LINE_")) return "line";
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    compName() {
+      return `edit-${this.elementName}`;
+    },
+    classes() {
+      return [
+        "topic-design",
+        "element-item",
+        `element-item-${this.elementName}`,
+      ];
+    },
+  },
+  created() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("free", ["setCurElement"]),
+    init() {
+      this.elemData = objAssign(this.elemData, this.data);
+      this.styles = {
+        left: this.data.x + "px",
+        top: this.data.y + "px",
+        width: this.data.w + "px",
+        height: this.data.h + "px",
+        zIndex: this.data.zindex,
+      };
+    },
+    activeCurElement() {
+      this.setCurElement(this.data);
+    },
+    resizeOver() {
+      this.$emit("resize-over", Object.assign({}, this.data, this.elemData));
+    },
+  },
+};
+</script>

+ 115 - 0
src/modules/paper-export/components/ElementPropEdit.vue

@@ -0,0 +1,115 @@
+<template>
+  <el-dialog
+    class="element-prop-edit edit-dialog"
+    :visible.sync="openElementEditDialog"
+    :title="title"
+    top="10vh"
+    width="640px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    :before-close="cancel"
+    append-to-body
+    destroy-on-close
+  >
+    <component
+      :is="curEditComponent"
+      ref="ElementPropEditComp"
+      :key="curElement.id"
+      :instance="curElement"
+      @modified="modified"
+    ></component>
+
+    <div slot="footer">
+      <el-button type="primary" :disabled="loading" @click="submit"
+        >确认</el-button
+      >
+      <el-button @click="cancel">取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { getElementName } from "../elementModel";
+import EditComposition from "../elements/composition/EditComposition";
+import EditExplain from "../elements/explain/EditExplain";
+import EditFillLine from "../elements/fill-line/EditFillLine";
+import EditFillQuestion from "../elements/fill-question/EditFillQuestion";
+import EditText from "../elements/text/EditText";
+import EditImage from "../elements/image/EditImage";
+import EditLine from "../elements/line/EditLine";
+import EditLines from "../elements/lines/EditLines";
+import EditGrids from "../elements/grids/EditGrids";
+
+export default {
+  name: "ElementPropEdit",
+  components: {
+    EditComposition,
+    EditExplain,
+    EditFillLine,
+    EditFillQuestion,
+    EditText,
+    EditImage,
+    EditLine,
+    EditLines,
+    EditGrids,
+  },
+  data() {
+    return { loading: false };
+  },
+  computed: {
+    ...mapState("card", ["curElement", "openElementEditDialog"]),
+    title() {
+      return this.curElement.type
+        ? getElementName(this.curElement.type)
+        : "属性编辑";
+    },
+    curEditComponent() {
+      if (!this.curElement.type) return;
+      let type = this.curElement.type.toLowerCase().replace("_", "-");
+      if (type.indexOf("line-") === 0) type = "line";
+      return `edit-${type}`;
+    },
+  },
+  methods: {
+    ...mapMutations("card", ["setOpenElementEditDialog"]),
+    ...mapActions("card", [
+      "addElement",
+      "modifyElement",
+      "modifyElementChild",
+      "rebuildPages",
+    ]),
+    cancel() {
+      this.setOpenElementEditDialog(false);
+    },
+    open() {
+      this.setOpenElementEditDialog(true);
+    },
+    submit() {
+      if (this.loading) return;
+      this.loading = true;
+      setTimeout(() => {
+        this.loading = false;
+      }, 500);
+      this.$refs.ElementPropEditComp.submit();
+    },
+    modified(element) {
+      this.cancel();
+      // 编辑试题
+      // 属性存在的条件:parent:大题的小题,container:题目内的子元素
+      if (this.curElement["_edit"]) {
+        if (element["container"]) {
+          this.modifyElementChild(element);
+        } else {
+          this.modifyElement(element);
+        }
+      } else {
+        this.addElement(element);
+      }
+      this.$nextTick(() => {
+        this.rebuildPages();
+      });
+    },
+  },
+};
+</script>

+ 42 - 0
src/modules/paper-export/components/PageNumber.vue

@@ -0,0 +1,42 @@
+<template>
+  <div :class="classes">
+    <ul v-if="type === 'rect' && current % 2" class="page-number-rect-list">
+      <li
+        v-for="n in total"
+        :key="n"
+        :class="{ 'rect-li-act': n === current }"
+      ></li>
+    </ul>
+    <div v-if="type === 'text'" class="page-number-text-cont">
+      第{{ current }}页(共{{ total }}页)
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "PageNumber",
+  props: {
+    type: {
+      type: String,
+      default: "text",
+      validator(val) {
+        return ["text", "rect"].indexOf(val) !== -1;
+      },
+    },
+    total: {
+      type: Number,
+      default: 1,
+    },
+    current: {
+      type: Number,
+      default: 1,
+    },
+  },
+  computed: {
+    classes() {
+      return ["page-number", `page-number-${this.type}`];
+    },
+  },
+};
+</script>

+ 170 - 0
src/modules/paper-export/components/PagePropEdit.vue

@@ -0,0 +1,170 @@
+<template>
+  <div class="page-prop-edit">
+    <el-form ref="form" :model="form" label-width="70px">
+      <el-form-item label="纸张规格">
+        <el-select
+          v-model="form.pageSize"
+          placeholder="请选择"
+          :disabled="pageSizeOptions.length < 2"
+          @change="modifyPageSize"
+        >
+          <el-option
+            v-for="item in pageSizeOptions"
+            :key="item"
+            :label="item"
+            :value="item"
+          >
+          </el-option>
+        </el-select>
+      </el-form-item>
+      <!-- <el-form-item label="栏位布局">
+        <el-button
+          v-for="(item, index) in columnOptions"
+          :key="index"
+          class="column-btn"
+          :title="item.title"
+          :disabled="item.disabled"
+          @click="columnNumChange"
+        >
+          <i
+            :class="[
+              'icon',
+              form.columnNumber == item.value
+                ? `icon-column-${item.label}-act`
+                : `icon-column-${item.label}`,
+            ]"
+          ></i>
+        </el-button>
+      </el-form-item> -->
+      <el-form-item label="页码">
+        <el-checkbox v-model="form.showPageNo" @change="pageChange"
+          >显示</el-checkbox
+        >
+      </el-form-item>
+      <el-form-item label="侧边栏">
+        <el-checkbox v-model="form.showSide" @change="pageChange"
+          >显示</el-checkbox
+        >
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { objAssign } from "../../card/plugins/utils";
+
+const COLUMN_OPTIONS = [
+  {
+    value: 1,
+    title: "一栏",
+    label: "one",
+    sizeValid: ["A4"],
+    disabled: false,
+  },
+  {
+    value: 2,
+    title: "二栏",
+    label: "two",
+    sizeValid: ["A3", "A4"],
+    disabled: false,
+  },
+  {
+    value: 3,
+    title: "三栏",
+    label: "three",
+    sizeValid: ["A3"],
+    disabled: false,
+  },
+  {
+    value: 4,
+    title: "四栏",
+    label: "four",
+    sizeValid: ["A3"],
+    disabled: false,
+  },
+];
+
+export default {
+  name: "PagePropEdit",
+  data() {
+    return {
+      columnOptions: [],
+      pageSizeOptions: ["A3", "A4"],
+      form: {
+        pageSize: "A3",
+        columnNumber: 2,
+        columnGap: 20,
+        showPageNo: true,
+        showSide: true,
+      },
+      prePageSize: "A3",
+    };
+  },
+  computed: {
+    ...mapState("card", ["curPage"]),
+  },
+  watch: {
+    curPage: {
+      immediate: true,
+      handler(val) {
+        this.form = objAssign(this.form, val);
+        this.prePageSize = this.form.pageSize;
+        this.columnOptions = COLUMN_OPTIONS.filter((item) =>
+          item.sizeValid.includes(this.form.pageSize)
+        );
+      },
+    },
+  },
+  methods: {
+    ...mapMutations("card", ["setCurElement"]),
+    ...mapActions("card", [
+      "modifyPagesInfo",
+      "rebuildPages",
+      "resetElementProp",
+    ]),
+    modifyColumnNum(item) {
+      this.$confirm(
+        "此操作会导致当前题卡编辑的所有元素清空, 是否继续?",
+        "提示",
+        {
+          type: "warning",
+        }
+      )
+        .then(() => {
+          this.columnNumChange(item.value);
+        })
+        .catch(() => {});
+    },
+    columnNumChange(val) {
+      this.form.columnNumber = val;
+      this.pageChange();
+    },
+    pageChange() {
+      this.modifyPagesInfo(this.form);
+      this.$nextTick(() => {
+        this.rebuildPages();
+        this.setCurElement({});
+        this.$nextTick(() => {
+          this.resetElementProp(true);
+        });
+      });
+    },
+    modifyPageSize() {
+      this.$confirm("此操作将会重置当前页面所有元素信息, 是否继续?", "提示", {
+        type: "warning",
+      })
+        .then(() => {
+          this.columnOptions = COLUMN_OPTIONS.filter((item) =>
+            item.sizeValid.includes(this.form.pageSize)
+          );
+          this.form.columnNumber = this.columnOptions[0].value;
+          this.pageChange();
+        })
+        .catch(() => {
+          this.form.pageSize = this.prePageSize;
+        });
+    },
+  },
+};
+</script>

+ 76 - 0
src/modules/paper-export/components/PaperSideEdit.vue

@@ -0,0 +1,76 @@
+<template>
+  <div
+    class="paper-side-edit"
+    @drop.prevent="dropInnerElement($event)"
+    @dragover.prevent
+    @dragleave.prevent
+  >
+    <box-element-edit
+      v-for="element in data"
+      :key="element.key"
+      :data="element"
+    ></box-element-edit>
+  </div>
+</template>
+
+<script>
+import { mapState, mapActions, mapMutations } from "vuex";
+import BoxElementEdit from "./BoxElementEdit";
+
+export default {
+  name: "PaperSideEdit",
+  components: { BoxElementEdit },
+  props: {
+    data: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    ...mapState("free", ["curDragElement", "curPage"]),
+  },
+  methods: {
+    ...mapMutations("free", ["setCurDragElement", "setCurElement"]),
+    ...mapActions("free", ["addSideElement", "modifyElement"]),
+    dropInnerElement(e) {
+      let { offsetX: x, offsetY: y } = e;
+      const { offsetLeft, offsetTop } = this.getOffsetInfo(
+        e.target || e.srcElement
+      );
+
+      const curElement = {
+        ...this.curDragElement,
+        x: x + offsetLeft,
+        y: y + offsetTop,
+      };
+
+      this.clear();
+      this.setCurDragElement({});
+      this.addSideElement(curElement);
+    },
+    getOffsetInfo(dom, endParentClass = "paper-side-edit") {
+      let parentNode = dom;
+      let offsetTop = 0,
+        offsetLeft = 0;
+      while (!parentNode.className.includes(endParentClass)) {
+        offsetTop += parentNode.offsetTop;
+        offsetLeft += parentNode.offsetLeft;
+        parentNode = parentNode.offsetParent;
+      }
+      return {
+        offsetLeft,
+        offsetTop,
+      };
+    },
+    elementResizeOver(element) {
+      this.clear();
+      this.modifyElement(element);
+    },
+  },
+};
+</script>

+ 286 - 0
src/modules/paper-export/components/PaperTemplateDesign.vue

@@ -0,0 +1,286 @@
+<template>
+  <div class="paper-template-design">
+    <!-- actions -->
+    <div class="design-action">
+      <div class="design-logo">
+        <h1>
+          <i class="el-icon-d-arrow-left" title="退出" @click="toExit"></i>
+          试卷模板制作
+        </h1>
+      </div>
+
+      <div class="action-part">
+        <div class="action-part-title"><h2>基本设置</h2></div>
+        <div class="action-part-body">
+          <page-prop-edit @init-page="initPageData"></page-prop-edit>
+        </div>
+      </div>
+      <div class="action-part">
+        <div class="action-part-title"><h2>编辑结构</h2></div>
+        <div class="action-part-body">
+          <div class="type-list">
+            <div class="type-item">
+              <el-button @click="addNewTopic('PANE_BOX')"
+                ><i class="el-icon-plus"></i>编辑框</el-button
+              >
+            </div>
+          </div>
+          <p class="tips-info">提示:点击创建编辑框</p>
+        </div>
+      </div>
+      <div class="action-part">
+        <div class="action-part-title"><h2>插入元素</h2></div>
+        <div class="action-part-body">
+          <div class="type-list">
+            <div
+              v-for="(item, index) in ELEMENT_LIST"
+              :key="index"
+              class="type-item"
+              draggable="true"
+              @dragstart="dragstart(item)"
+            >
+              <el-button><i class="el-icon-plus"></i>{{ item.name }}</el-button>
+            </div>
+          </div>
+          <p class="tips-info">提示:拖动插入元素</p>
+        </div>
+      </div>
+    </div>
+
+    <div class="design-main">
+      <!-- menus -->
+      <div class="design-control">
+        <div class="control-left tab-btns">
+          <el-button
+            v-for="(page, pageNo) in pages"
+            :key="pageNo"
+            :type="curPageNo === pageNo ? 'primary' : 'default'"
+            @click="swithPage(pageNo)"
+            >{{ pageNo ? "正面" : "反面" }}</el-button
+          >
+        </div>
+        <div class="control-right">
+          <el-button type="primary" :loading="isSubmit" @click="toSubmit"
+            >提交</el-button
+          >
+        </div>
+      </div>
+
+      <!-- edit body -->
+      <div class="design-body">
+        <div
+          :class="[
+            'page-box',
+            `page-box-${curPage.pageSize}`,
+            `page-box-${curPageNo % 2}`,
+          ]"
+        >
+          <!-- inner edit area -->
+          <div class="page-main-inner">
+            <div
+              :class="['page-main', `page-main-${curPage.columns.length}`]"
+              :style="{ margin: `0 -${curPage.columnGap / 2}px` }"
+            >
+              <div
+                v-for="(column, columnNo) in curPage.columns"
+                :key="columnNo"
+                class="page-column"
+                :style="{ padding: `0 ${curPage.columnGap / 2}px` }"
+              >
+                <div
+                  :id="[`column-${curPageNo}-${columnNo}`]"
+                  class="page-column-main"
+                >
+                  <div class="page-column-body">
+                    <topic-element-edit
+                      v-for="element in column.elements"
+                      :key="element.id"
+                      class="page-column-element"
+                      :data-h="element.h"
+                      :data="element"
+                    ></topic-element-edit>
+                  </div>
+                </div>
+                <page-number
+                  v-if="curPage.showPageNo"
+                  type="text"
+                  :total="pages.length * 2"
+                  :current="curPageNo * 2 + columnNo + 1"
+                ></page-number>
+              </div>
+            </div>
+          </div>
+          <!-- side edit   -->
+          <paper-side-edit class="page-main-side"></paper-side-edit>
+        </div>
+      </div>
+    </div>
+
+    <!-- all topics -->
+    <div class="topic-list">
+      <div :class="['page-box', `page-box-${curPage.pageSize}`]">
+        <div class="page-main-inner">
+          <div
+            :class="['page-main', `page-main-${curPage.columnNumber}`]"
+            :style="{ margin: `0 -${curPage.columnGap / 2}px` }"
+          >
+            <div
+              class="page-column"
+              :style="{ padding: `0 ${curPage.columnGap / 2}px` }"
+            >
+              <div id="topic-column" class="page-column-main">
+                <div class="page-column-body">
+                  <!-- topic element -->
+                  <topic-element-preview
+                    v-for="element in topics"
+                    :key="element.id"
+                    class="page-column-element"
+                    :data="element"
+                  ></topic-element-preview>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- element-prop-edit -->
+    <element-prop-edit ref="ElementPropEdit"></element-prop-edit>
+    <!-- right-click-menu -->
+    <right-click-menu></right-click-menu>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { getElementModel, ELEMENT_LIST } from "../elementModel";
+import { getModel as getPageModel } from "../elements/page/model";
+
+import PagePropEdit from "./PagePropEdit";
+import ElementPropEdit from "./ElementPropEdit";
+import RightClickMenu from "./RightClickMenu";
+import PageNumber from "./PageNumber";
+import TopicElementEdit from "./TopicElementEdit.vue";
+import TopicElementPreview from "./TopicElementPreview.vue";
+import PaperSideEdit from "./PaperSideEdit.vue";
+
+export default {
+  name: "PaperTemplateDesign",
+  components: {
+    PagePropEdit,
+    ElementPropEdit,
+    RightClickMenu,
+    PageNumber,
+    TopicElementEdit,
+    TopicElementPreview,
+    PaperSideEdit,
+  },
+  props: {
+    content: {
+      type: Object,
+      default() {
+        return {
+          pages: [],
+        };
+      },
+    },
+  },
+  data() {
+    return {
+      ELEMENT_LIST,
+      isSubmit: false,
+    };
+  },
+  computed: {
+    ...mapState("paper-export", [
+      "topics",
+      "pages",
+      "curElement",
+      "curPage",
+      "curPageNo",
+    ]),
+  },
+  mounted() {
+    this.initCard();
+  },
+  beforeDestroy() {
+    this.initState();
+  },
+  methods: {
+    ...mapMutations("paper-export", [
+      "addPage",
+      "setCurPage",
+      "setCurElement",
+      "setOpenElementEditDialog",
+      "setCurDragElement",
+      "setPages",
+      "setTopics",
+      "initState",
+    ]),
+    ...mapActions("paper-export", [
+      "addElement",
+      "modifyElement",
+      "rebuildPages",
+      "initTopicsFromPages",
+    ]),
+    initCard() {
+      const { pages } = this.content;
+      if (pages && pages.length) {
+        this.setPages(pages);
+        this.initTopicsFromPages();
+        this.setCurPage(0);
+      } else {
+        this.initPageData();
+      }
+    },
+    initPageData() {
+      this.setPages([getPageModel(), getPageModel()]);
+      this.setCurPageNo(0);
+    },
+    addNewTopic(type) {
+      let element = getElementModel(type);
+      element.w = document.getElementById("topic-column").offsetWidth;
+      this.setCurElement(element);
+    },
+    // 元件编辑
+    dragstart(element) {
+      this.setCurDragElement(getElementModel(element.type));
+    },
+    // 切换正反页
+    swithPage(pindex) {
+      if (this.curPageNo === pindex) return;
+      this.setCurPage(pindex);
+      this.setCurElement({});
+    },
+    getTemplateJson() {
+      return new Promise((resolve) => {
+        setTimeout(() => {
+          const data = JSON.stringify(
+            {
+              pages: this.pages,
+            },
+            (k, v) => (k.startsWith("_") ? undefined : v)
+          );
+          resolve(data);
+        }, 100);
+      });
+    },
+    toSubmit() {
+      if (this.isSubmit) return;
+      this.$emit("on-submit", {
+        pages: this.pages,
+      });
+    },
+    toExit() {
+      this.$emit("on-exit");
+    },
+    loading() {
+      this.isSubmit = true;
+    },
+    unloading() {
+      this.isSubmit = false;
+    },
+  },
+};
+</script>

+ 246 - 0
src/modules/paper-export/components/RightClickMenu.vue

@@ -0,0 +1,246 @@
+<template>
+  <div class="right-click-menu">
+    <div
+      v-if="visible"
+      ref="RightMenuBody"
+      v-clickoutside="close"
+      class="right-menu-body"
+      :style="styles"
+    >
+      <ul>
+        <li v-if="IS_CONTAINER_ELEMENT" @click="toEdit">
+          <i class="el-icon-edit-outline"></i>编辑元素
+        </li>
+        <li v-if="IS_CONTAINER_ELEMENT" class="li-danger" @click="toDelete">
+          <i class="el-icon-delete"></i>删除元素
+        </li>
+        <li v-if="IS_CONTAINER_ELEMENT" @click="toCopyExplainElement">
+          <i class="el-icon-copy-document"></i> 复制元素
+        </li>
+        <li
+          v-if="IS_CONTAINER && curCopyElement"
+          @click="toPasteExplainElement"
+        >
+          <i class="el-icon-document-copy"></i> 粘贴元素
+        </li>
+      </ul>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { deepCopy } from "../plugins/utils";
+import { fetchSameSerialNumberChildrenPositionInfo } from "../store/card";
+import Clickoutside from "element-ui/src/utils/clickoutside";
+
+export default {
+  name: "RightClickMenu",
+  directives: { Clickoutside },
+  data() {
+    return {
+      visible: false,
+      curCopyElement: null,
+      styles: {
+        position: "fixed",
+        zIndex: 3000,
+      },
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement", "topics"]),
+    IS_CONTAINER_ELEMENT() {
+      return !!this.curElement.container;
+    },
+    IS_CONTAINER() {
+      return (
+        this.curElement.type === "CONTAINER" ||
+        (this.curElement.container &&
+          this.curElement.container.type === "CONTAINER")
+      );
+    },
+  },
+  mounted() {
+    this.init();
+  },
+  beforeDestroy() {
+    document.oncontextmenu = null;
+    document.removeEventListener("mouseup", this.docMouseUp);
+  },
+  methods: {
+    ...mapMutations("card", ["setOpenElementEditDialog"]),
+    ...mapActions("card", [
+      "actElementById",
+      "removeElement",
+      "removeElementChild",
+      "pasteExplainElementChild",
+      "rebuildPages",
+      "copyExplainChildren",
+      "deleteExplainChildren",
+      "topicMoveUp",
+    ]),
+    init() {
+      // 注册自定义右键事件菜单
+      document.oncontextmenu = function () {
+        return false;
+      };
+      document.addEventListener("mouseup", this.docMouseUp);
+    },
+    close() {
+      this.visible = false;
+    },
+    show() {
+      this.visible = true;
+    },
+    docMouseUp(e) {
+      if (e.button === 2) {
+        this.rightClick(e);
+      }
+    },
+    rightClick(e) {
+      const id = this.getRelateElementId(e.target);
+      if (!id) return;
+
+      this.actElementById(id);
+      let curElement = this.curElement;
+      const TYPES = ["EXPLAIN", "COMPOSITION"];
+      if (
+        TYPES.includes(curElement.type) ||
+        (curElement.container && TYPES.includes(curElement.container.type))
+      ) {
+        if (curElement.container) {
+          const pos = this.topics.findIndex(
+            (item) => item.id === curElement.container.id
+          );
+          curElement = this.topics[pos];
+        }
+        const positionInfos = fetchSameSerialNumberChildrenPositionInfo(
+          curElement,
+          this.topics
+        );
+        this.showDeleteChildBtn = positionInfos.length >= 2;
+      }
+      this.show();
+
+      this.$nextTick(() => {
+        const { x: clickLeft, y: clickTop } = e;
+        const { offsetWidth: menuWidth, offsetHeight: menuHeight } =
+          this.$refs.RightMenuBody;
+
+        const { innerWidth: wWidth, innerHeight: wHeight } = window;
+
+        let menuLeft = clickLeft,
+          menuTop = clickTop;
+        if (menuWidth + clickLeft > wWidth) {
+          menuLeft = clickLeft - menuWidth;
+        }
+        if (menuHeight + clickTop > wHeight) {
+          menuTop = clickTop - menuHeight;
+        }
+
+        this.styles = Object.assign({}, this.styles, {
+          top: menuTop + "px",
+          left: menuLeft + "px",
+        });
+      });
+    },
+    getRelateElementId(dom) {
+      let parentNode = dom;
+      while (
+        !(
+          (parentNode["id"] && parentNode["id"].includes("element-")) ||
+          parentNode.className.includes("page-column-body") ||
+          parentNode.className.includes("card-design")
+        )
+      ) {
+        parentNode = parentNode.parentNode;
+      }
+      const elementType = parentNode.getAttribute("data-type");
+      const unValidElement = ["TOPIC_HEAD", "CARD_HEAD"];
+
+      return parentNode["id"] &&
+        elementType &&
+        !unValidElement.includes(elementType)
+        ? parentNode["id"]
+        : null;
+    },
+    toEdit() {
+      this.curElement._edit = true;
+      this.close();
+      this.setOpenElementEditDialog(true);
+    },
+    toDelete() {
+      this.close();
+      this.$confirm("确定要删除当前元素吗?", "提示", {
+        type: "warning",
+      })
+        .then(() => {
+          this.removeSelectElement();
+        })
+        .catch(() => {});
+    },
+    toCopyChildren() {
+      this.close();
+      this.copyExplainChildren(this.curElement);
+      this.toRebuildPages();
+    },
+    toDeleteChildren() {
+      this.close();
+      this.deleteExplainChildren(this.curElement);
+      this.toRebuildPages();
+    },
+    removeSelectElement() {
+      if (!this.curElement["container"]) return;
+      this.removeElementChild(this.curElement);
+      this.toRebuildPages();
+    },
+    toCopyExplainElement() {
+      this.close();
+      this.curCopyElement = deepCopy(this.curElement);
+    },
+    toPasteExplainElement() {
+      this.close();
+      const id = this.curElement.container
+        ? this.curElement.container.id
+        : this.curElement.id;
+      const pasteElement =
+        this.curCopyElement.container.id === id
+          ? Object.assign({}, this.curCopyElement, {
+              y: this.curCopyElement.y + 20,
+            })
+          : this.curCopyElement;
+
+      this.pasteExplainElementChild({
+        curElement: this.curElement,
+        pasteElement,
+      });
+      this.toRebuildPages();
+    },
+    toMoveUpTopic() {
+      this.close();
+      this.topicMoveUp(this.curElement.parent.id);
+      this.toRebuildPages();
+    },
+    toMoveDownTopic() {
+      this.close();
+      const curTopicPos = this.topicSeries.findIndex(
+        (item) => item.id === this.curElement.parent.id
+      );
+      this.topicMoveUp(this.topicSeries[curTopicPos + 1].id);
+      this.toRebuildPages();
+    },
+    toInsetTopic() {
+      this.close();
+      this.$emit("inset-topic", {
+        id: this.curElement.parent.id,
+        type: this.curElement.type,
+      });
+    },
+    toRebuildPages() {
+      this.$nextTick(() => {
+        this.rebuildPages();
+      });
+    },
+  },
+};
+</script>

+ 109 - 0
src/modules/paper-export/components/TopicElementEdit.vue

@@ -0,0 +1,109 @@
+<template>
+  <div class="topic-element-edit">
+    <element-resize
+      v-model="elemData"
+      :class="{ 'element-resize-act': curElement.id === data.id }"
+      :active="['b']"
+      :move="false"
+      :min-height="data.minHeight"
+      :fit-parent="['h']"
+      @on-click="activeCurElement"
+      @resize-over="modifyElement"
+      @change="sizeChange"
+    >
+      <div :id="data.id" :class="classes" :data-type="data.type">
+        <component :is="compName" :data="data"></component>
+      </div>
+      <!-- topic-number -->
+      <topic-number
+        title="点击选中当前编辑框"
+        @click="activeCurElement"
+      ></topic-number>
+    </element-resize>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { objAssign } from "../../card/plugins/utils";
+import { checkElementisCovered } from "../store/paper-export";
+
+import EditPaneBox from "../elements/pane-box/ElemPaneBoxEdit.vue";
+import ElementResize from "../../card/common/ElementResize";
+import TopicNumber from "./common/TopicNumber";
+
+export default {
+  name: "TopicElementEdit",
+  components: {
+    EditPaneBox,
+    ElementResize,
+    TopicNumber,
+  },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      elemData: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0,
+        init: false,
+        isCovered: false,
+      },
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement"]),
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    compName() {
+      return `edit-${this.elementName}`;
+    },
+    classes() {
+      return [
+        "topic-design",
+        "element-item",
+        `element-item-${this.elementName}`,
+        {
+          "element-item-error": this.elemData.isCovered,
+        },
+      ];
+    },
+  },
+  created() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("card", ["setCurElement"]),
+    ...mapActions("card", ["modifyTopic", "rebuildPages"]),
+    init() {
+      this.elemData = objAssign(this.elemData, this.data);
+    },
+    activeCurElement() {
+      this.setCurElement(this.data);
+    },
+    sizeChange() {
+      this.elemData.isCovered = checkElementisCovered(
+        this.data.id,
+        this.data.type
+      );
+      this.modifyElement();
+    },
+    modifyElement() {
+      this.modifyTopic(Object.assign({}, this.curElement, this.elemData));
+      // 注意:当前组件并没有实时更新元件的尺寸信息,只是在rebuildPages时统一更新。
+      this.$nextTick(() => {
+        this.rebuildPages();
+      });
+    },
+  },
+};
+</script>

+ 59 - 0
src/modules/paper-export/components/TopicElementPreview.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="topic-element-preview">
+    <div
+      :id="`preview-${data.id}`"
+      :class="classes"
+      :data-type="data.type"
+      :style="styles"
+    >
+      <component :is="compName" :data="data" preview></component>
+    </div>
+  </div>
+</template>
+
+<script>
+import ElemPaneBox from "../elements/pane-box/ElemPaneBox.vue";
+
+export default {
+  name: "TopicElementPreview",
+  components: {
+    ElemPaneBox,
+  },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    compName() {
+      return `preview-${this.elementName}`;
+    },
+    classes() {
+      return [
+        "topic-preview",
+        "element-item",
+        "element-item-width",
+        `element-item-${this.elementName}`,
+      ];
+    },
+    styles() {
+      return {
+        left: this.data.x + "px",
+        top: this.data.y + "px",
+        width: this.data.w + "px",
+        height: this.data.h + "px",
+      };
+    },
+  },
+  methods: {},
+};
+</script>

+ 78 - 0
src/modules/paper-export/elementModel.js

@@ -0,0 +1,78 @@
+// element
+import { getModel as createLines } from "../card/elements/lines/model";
+import { getModel as createLine } from "../card/elements/line/model";
+import { getModel as createText } from "../card/elements/text/model";
+import { getModel as createImage } from "../card/elements/image/model";
+import { getModel as createGrids } from "../card/elements/grids/model";
+import { getModel as createGutter } from "../card/elements/gutter/model";
+import { getModel as createFillField } from "../card/elements/fill-field/model";
+import { getModel as createPaneBox } from "./elements/pane-box/model";
+
+// available infos
+const EDITABLE_ELEMENT = [
+  "LINE_HORIZONTAL",
+  "LINE_VERTICAL",
+  "LINES",
+  "TEXT",
+  "IMAGE",
+  "GRIDS",
+  "GUTTER",
+  "FILL_FIELD",
+];
+
+const ELEMENT_INFOS = {
+  LINES: {
+    name: "多横线",
+    getModel: createLines,
+  },
+  LINE_HORIZONTAL: {
+    name: "横线",
+    getModel: () => createLine("HORIZONTAL"),
+  },
+  LINE_VERTICAL: {
+    name: "竖线",
+    getModel: () => createLine("VERTICAL"),
+  },
+  TEXT: {
+    name: "文本",
+    getModel: createText,
+  },
+  IMAGE: {
+    name: "图片",
+    getModel: createImage,
+  },
+  GRIDS: {
+    name: "网格",
+    getModel: createGrids,
+  },
+  GUTTER: {
+    name: "装订线",
+    getModel: createGutter,
+  },
+  FILL_FIELD: {
+    name: "填词块",
+    getModel: createFillField,
+  },
+  PANE_BOX: {
+    name: "编辑框",
+    getModel: createPaneBox,
+  },
+};
+
+const ELEMENT_LIST = EDITABLE_ELEMENT.map((type) => {
+  return {
+    ...ELEMENT_INFOS[type],
+    type,
+  };
+});
+
+// 获取元件默认数据结构
+const getElementModel = (type, optionData = {}) => {
+  return ELEMENT_INFOS[type].getModel(optionData);
+};
+
+const getElementName = (type) => {
+  return ELEMENT_INFOS[type].name;
+};
+
+export { getElementModel, getElementName, ELEMENT_LIST };

+ 166 - 0
src/modules/paper-export/elements/page/EditPage.vue

@@ -0,0 +1,166 @@
+<template>
+  <div class="edit-page page-prop-edit">
+    <el-form ref="form" :model="form" label-width="70px">
+      <el-form-item v-if="editPageSize" label="纸张规格">
+        <el-select
+          v-model="form.pageSize"
+          placeholder="请选择"
+          :disabled="pageSizeOptions.length < 2"
+          @change="modifyPageSize"
+        >
+          <el-option
+            v-for="item in pageSizeOptions"
+            :key="item"
+            :label="item"
+            :value="item"
+          >
+          </el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item v-if="editColumnNumber" label="栏位布局">
+        <el-button
+          v-for="(item, index) in columnOptions"
+          :key="index"
+          class="column-btn"
+          :title="item.title"
+          :disabled="item.disabled"
+          @click="modifyColumnNum(item)"
+        >
+          <i
+            :class="[
+              'icon',
+              form.columnNumber == item.value
+                ? `icon-column-${item.label}-act`
+                : `icon-column-${item.label}`,
+            ]"
+          ></i>
+        </el-button>
+      </el-form-item>
+      <el-form-item v-if="editForbidArea" label="禁答区域">
+        <el-checkbox
+          v-model="form.showForbidArea"
+          @change="showForbidAreaChange"
+          >启用</el-checkbox
+        >
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { objAssign } from "../../plugins/utils";
+import { mapState, mapActions } from "vuex";
+import { getModel as getPageModel } from "./model";
+
+const COLUMN_OPTIONS = [
+  {
+    value: 1,
+    title: "一栏",
+    label: "one",
+    sizeValid: ["A3", "A4"],
+  },
+  {
+    value: 2,
+    title: "二栏",
+    label: "two",
+    sizeValid: ["A3", "A4"],
+  },
+  {
+    value: 3,
+    title: "三栏",
+    label: "three",
+    sizeValid: ["A3"],
+  },
+  {
+    value: 4,
+    title: "四栏",
+    label: "four",
+    sizeValid: ["A3"],
+  },
+];
+
+export default {
+  name: "EditPage",
+  props: {
+    editPageSize: {
+      type: Boolean,
+      default: false,
+    },
+    editColumnNumber: {
+      type: Boolean,
+      default: true,
+    },
+    editForbidArea: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      columnOptions: [],
+      pageSizeOptions: ["A3", "A4"],
+      form: {
+        pageSize: "A3",
+        columnNumber: 2,
+        showForbidArea: false,
+      },
+      prePageSize: "A3",
+    };
+  },
+  computed: {
+    ...mapState("free", ["curPage"]),
+  },
+  watch: {
+    curPage: {
+      immediate: true,
+      handler(val) {
+        this.form = objAssign(this.form, val);
+        this.prePageSize = this.form.pageSize;
+        this.columnOptions = COLUMN_OPTIONS.filter((item) =>
+          item.sizeValid.includes(this.form.pageSize)
+        );
+      },
+    },
+  },
+  methods: {
+    ...mapActions("free", ["modifyPage"]),
+    modifyColumnNum(item) {
+      this.$confirm("此操作将会重置当前页面所有元素信息, 是否继续?", "提示", {
+        type: "warning",
+      })
+        .then(() => {
+          this.form.columnNumber = item.value;
+          this.pageChange(true);
+        })
+        .catch(() => {});
+    },
+    showForbidAreaChange() {
+      this.pageChange();
+    },
+    modifyPageSize() {
+      this.$confirm("此操作将会重置当前页面所有元素信息, 是否继续?", "提示", {
+        type: "warning",
+      })
+        .then(() => {
+          this.columnOptions = COLUMN_OPTIONS.filter((item) =>
+            item.sizeValid.includes(this.form.pageSize)
+          );
+          this.form.columnNumber = this.columnOptions[0].value;
+          this.pageChange(true);
+        })
+        .catch(() => {
+          this.form.pageSize = this.prePageSize;
+        });
+    },
+    pageChange(isInit) {
+      if (isInit) {
+        let curPage = getPageModel(this.form);
+        curPage.id = this.curPage.id;
+        this.modifyPage(curPage);
+      } else {
+        this.modifyPage(Object.assign({}, this.curPage, this.form));
+      }
+    },
+  },
+};
+</script>

+ 42 - 0
src/modules/paper-export/elements/page/model.js

@@ -0,0 +1,42 @@
+import {
+  getElementId,
+  randomCode,
+  deepCopy,
+  getNumList,
+  objAssign,
+} from "../../../card/plugins/utils";
+
+const MODEL = {
+  type: "PAGE",
+  pageSize: "A3",
+  columnNumber: 2,
+  columnGap: 20,
+  showPageNo: true,
+  showSide: true,
+  sides: [],
+  columns: [],
+};
+// 可编辑栏
+const COLUMN = {
+  type: "COLUMN",
+  x: "",
+  y: "",
+  w: "",
+  h: "",
+  isFull: false, // 是否已经填满元素
+  elements: [],
+};
+
+const getModel = (datas = {}) => {
+  let npage = deepCopy(MODEL);
+  npage = objAssign(npage, datas);
+  npage.id = getElementId();
+  const { columnNumber } = npage;
+
+  npage.columns = getNumList(columnNumber).map(() => {
+    return { id: `column-${randomCode()}`, ...deepCopy(COLUMN) };
+  });
+  return npage;
+};
+
+export { MODEL, getModel };

+ 55 - 0
src/modules/paper-export/elements/pane-box/EditPaneBox.vue

@@ -0,0 +1,55 @@
+<template>
+  <div class="edit-pane-box">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      label-width="100px"
+    >
+      <el-form-item label="边框形状:">
+        <line-style-select
+          v-model="modalForm.borderStyle"
+          show-empty
+        ></line-style-select>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import LineStyleSelect from "../../../card/components/common/LineStyleSelect";
+
+const initModalForm = {
+  id: "",
+  borderStyle: "none",
+};
+
+export default {
+  name: "EditPaneBox",
+  components: { LineStyleSelect },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      modalForm: { ...initModalForm },
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+    },
+    submit() {
+      this.$emit("modified", this.modalForm);
+    },
+  },
+};
+</script>

+ 30 - 0
src/modules/paper-export/elements/pane-box/ElemPaneBox.vue

@@ -0,0 +1,30 @@
+<template>
+  <div class="elem-pane-box" :style="styles"></div>
+</template>
+
+<script>
+export default {
+  name: "ElemPaneBox",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    styles() {
+      if (this.data.borderStyle === "none") return {};
+
+      return {
+        border: `1px ${this.data.borderStyle} #000`,
+      };
+    },
+  },
+  methods: {},
+};
+</script>

+ 128 - 0
src/modules/paper-export/elements/pane-box/ElemPaneBoxEdit.vue

@@ -0,0 +1,128 @@
+<template>
+  <div class="elem-pane-box elem-pane-box-edit">
+    <div class="elem-body" :style="bodyStyle">
+      <!-- 子元件编辑区域 -->
+      <div
+        class="elem-pane-box-elements"
+        @drop.prevent="dropInnerElement($event)"
+        @dragover.prevent
+        @dragleave.prevent
+      >
+        <box-element-edit
+          v-for="element in data.elements"
+          :key="element.id"
+          :data="element"
+          :transform-fit="rebuildGuides"
+          @resize-over="elementResizeOver"
+        ></box-element-edit>
+        <!-- guide-lines -->
+        <div class="element-guide-lines">
+          <div
+            v-for="line in xLines"
+            :key="`x-${line.top}`"
+            class="guide-line guide-line-x"
+            :style="line"
+          ></div>
+          <div
+            v-for="line in yLines"
+            :key="`y-${line.left}`"
+            class="guide-line guide-line-y"
+            :style="line"
+          ></div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import BoxElementEdit from "../../components/BoxElementEdit.vue";
+import guideLinesMixins from "../../../card/mixins/guideLines";
+
+export default {
+  name: "ElemPaneBoxEdit",
+  components: { BoxElementEdit },
+  mixins: [guideLinesMixins],
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      bodyStyle: {},
+    };
+  },
+  computed: {
+    ...mapState("paper-export", ["curDragElement"]),
+  },
+  watch: {
+    "data.parent": {
+      handler() {
+        this.modifyBodyStyle();
+      },
+    },
+  },
+  mounted() {
+    this.modifyBodyStyle();
+  },
+  methods: {
+    ...mapMutations("paper-export", ["setCurDragElement", "setCurElement"]),
+    ...mapActions("paper-export", ["rebuildPages", "modifyElementChild"]),
+    modifyBodyStyle() {
+      this.$nextTick(() => {
+        this.bodyStyle = {
+          height: this.data.h + "px",
+        };
+      });
+    },
+    dropInnerElement(e) {
+      let { offsetX: x, offsetY: y } = e;
+      const { offsetLeft, offsetTop } = this.getOffsetInfo(
+        e.target || e.srcElement
+      );
+      // 解答题的子元素中会新增container字段
+      const curElement = {
+        ...this.curDragElement,
+        x: x + offsetLeft,
+        y: y + offsetTop,
+        container: {
+          id: this.data.id,
+          type: this.data.type,
+        },
+      };
+      this.elementResizeOver(curElement);
+      this.setCurDragElement({});
+      this.setCurElement(curElement);
+    },
+    getOffsetInfo(dom, endParentClass = "elem-pane-box-elements") {
+      let parentNode = dom;
+      let offsetTop = 0,
+        offsetLeft = 0;
+      while (!parentNode.className.includes(endParentClass)) {
+        offsetTop += parentNode.offsetTop;
+        offsetLeft += parentNode.offsetLeft;
+        parentNode = parentNode.offsetParent;
+      }
+      return {
+        offsetLeft,
+        offsetTop,
+      };
+    },
+    elementResizeOver(element) {
+      this.clear();
+      this.modifyElementChild(element);
+      this.$nextTick(() => {
+        this.rebuildPages();
+      });
+    },
+    rebuildGuides(element, actionType) {
+      return this.rebuild(this.data.elements, element, actionType);
+    },
+  },
+};
+</script>

+ 25 - 0
src/modules/paper-export/elements/pane-box/model.js

@@ -0,0 +1,25 @@
+import {
+  getElementId,
+  randomCode,
+  deepCopy,
+} from "../../../card/plugins/utils";
+
+const MODEL = {
+  type: "PANE_BOX",
+  x: 0,
+  y: 0,
+  w: 200,
+  h: 200,
+  borderStyle: "none",
+  elements: [],
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...deepCopy(MODEL),
+  };
+};
+
+export { MODEL, getModel };

+ 138 - 0
src/modules/paper-export/elements/text/EditText.vue

@@ -0,0 +1,138 @@
+<template>
+  <div class="edit-text">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="100px"
+    >
+      <el-form-item label="字号:">
+        <size-select
+          v-model="modalForm.fontSize"
+          style="width: 100%"
+        ></size-select>
+      </el-form-item>
+      <el-form-item label="字体:">
+        <font-family-select
+          v-model="modalForm.fontFamily"
+          style="width: 100%"
+        ></font-family-select>
+      </el-form-item>
+      <el-form-item label="颜色:">
+        <color-select v-model="modalForm.color"></color-select>
+      </el-form-item>
+      <el-form-item label="加粗:">
+        <el-checkbox v-model="isBold" @change="boldChange"
+          >是否加粗</el-checkbox
+        >
+      </el-form-item>
+      <el-form-item prop="contentStr" label="内容:">
+        <el-input
+          ref="contentTextarea"
+          v-model="modalForm.contentStr"
+          type="textarea"
+          :rows="4"
+          placeholder="请输入内容"
+          @change="contentChange"
+        >
+        </el-input>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SizeSelect from "../../components/common/SizeSelect";
+import ColorSelect from "../../components/common/ColorSelect";
+import FontFamilySelect from "../../components/common/FontFamilySelect";
+
+const initModalForm = {
+  id: "",
+  fontSize: "14px",
+  color: "",
+  fontFamily: "",
+  fontWeight: 400,
+  rotation: 0,
+  content: [],
+  contentStr: "",
+};
+
+export default {
+  name: "EditText",
+  components: {
+    SizeSelect,
+    ColorSelect,
+    FontFamilySelect,
+  },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      modalForm: { ...initModalForm },
+      isBold: false,
+      rules: {
+        contentStr: [
+          {
+            required: true,
+            message: "请输入文本内容",
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      const contentStr = val.content
+        .map((item) => {
+          return item.type === "text"
+            ? item.content
+            : "${" + item.content + "}";
+        })
+        .join("");
+      this.modalForm = { ...val, contentStr };
+      this.isBold = val.fontWeight > 400;
+    },
+    boldChange(isBold) {
+      this.modalForm.fontWeight = isBold ? 700 : 400;
+    },
+    contentChange() {
+      const constentStr = this.modalForm.contentStr;
+      const rexp = new RegExp(/\$\{.+?\}/, "g");
+      const variates = constentStr.match(rexp);
+      const texts = constentStr.split(rexp);
+      let contents = [];
+
+      texts.forEach((text, index) => {
+        if (text)
+          contents.push({
+            type: "text",
+            content: text,
+          });
+
+        if (variates && variates[index])
+          contents.push({
+            type: "variate",
+            content: variates[index].replace("${", "").replace("}", ""),
+          });
+      });
+      this.modalForm.content = contents;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+      this.$emit("modified", this.modalForm);
+    },
+  },
+};
+</script>

+ 37 - 0
src/modules/paper-export/elements/text/ElemText.vue

@@ -0,0 +1,37 @@
+<template>
+  <div class="elem-text">
+    <div class="text-body" :style="styles">
+      <span
+        v-for="(cont, index) in data.content"
+        :key="index"
+        :class="`cont-${cont.type}`"
+        >{{ cont.content }}</span
+      >
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "ElemText",
+  props: {
+    data: {
+      type: Object,
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    styles() {
+      return {
+        fontWeight: this.data.fontWeight,
+        fontFamily: this.data.fontFamily,
+        fontSize: this.data.fontSize,
+        color: this.data.color,
+      };
+    },
+  },
+  methods: {},
+};
+</script>

+ 30 - 0
src/modules/paper-export/elements/text/model.js

@@ -0,0 +1,30 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+
+const MODEL = {
+  type: "TEXT",
+  x: 0,
+  y: 0,
+  w: 200,
+  h: 50,
+  sign: "",
+  fontWeight: 400,
+  fontFamily: "宋体",
+  fontSize: "14px",
+  color: "#000",
+  content: [
+    {
+      type: "text",
+      content: "样例内容",
+    },
+  ],
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...deepCopy(MODEL),
+  };
+};
+
+export { MODEL, getModel };

+ 540 - 0
src/modules/paper-export/store/paper-export.js

@@ -0,0 +1,540 @@
+import { getNewPage } from "../elementModel";
+import {
+  deepCopy,
+  calcSum,
+  getElementId,
+  numberIsOdd,
+  objAssign,
+} from "../../card/plugins/utils";
+
+const state = {
+  curElement: {},
+  curDragElement: {},
+  curPage: {},
+  curPageNo: 0,
+  pages: [],
+  topics: [],
+  insetTarget: {},
+  openElementEditDialog: false,
+};
+
+const mutations = {
+  setCurElement(state, curElement) {
+    state.curElement = curElement;
+  },
+  setCurDragElement(state, curDragElement) {
+    state.curDragElement = curDragElement;
+  },
+  setCurPage(state, curPageNo) {
+    const pageNo = state.pages[curPageNo] ? curPageNo : 0;
+    state.curPage = state.pages[pageNo];
+    state.curPageNo = pageNo;
+  },
+  setCurPageNo(state, curPageNo) {
+    state.curPageNo = curPageNo;
+  },
+  setPages(state, pages) {
+    state.pages = pages;
+  },
+  setTopics(state, topics) {
+    state.topics = topics;
+  },
+  setInsetTarget(state, insetTarget) {
+    state.insetTarget = insetTarget;
+  },
+  addPage(state, page) {
+    state.pages.push(page);
+  },
+  modifyPage(state, page) {
+    state.pages.splice(page._pageNo, 1, page);
+  },
+  setOpenElementEditDialog(state, openElementEditDialog) {
+    state.openElementEditDialog = openElementEditDialog;
+  },
+  initState(state) {
+    state.curElement = {};
+    state.curDragElement = {};
+    state.curPage = {};
+    state.curPageNo = 0;
+    state.topics = [];
+    state.pages = [];
+    state.openElementEditDialog = false;
+  },
+};
+
+const fetchElementPositionInfos = (element, topics) => {
+  return topics.findIndex((item) => item.id === element.id);
+};
+
+const fetchAllRelateParentElementPositionInfos = (parentElement, topics) => {
+  let postionInfos = [];
+  topics.forEach((item, eindex) => {
+    if (item["parent"] && item.parent.id === parentElement.id) {
+      let pos = { _elementNo: eindex };
+      postionInfos.push(pos);
+    }
+  });
+
+  return postionInfos;
+};
+
+const fetchSameSerialNumberChildrenPositionInfo = (
+  elementChildernElement,
+  topics
+) => {
+  let postionInfos = [];
+  const elementId = elementChildernElement.parent.id;
+  const serialNumber = elementChildernElement.serialNumber;
+
+  topics.forEach((item, eindex) => {
+    if (
+      item.parent &&
+      item.parent.id === elementId &&
+      item.serialNumber === serialNumber
+    ) {
+      postionInfos.push({
+        _elementNo: eindex,
+        _elementId: item.id,
+      });
+    }
+  });
+  return postionInfos;
+};
+
+const groupByParams = (datas, paramKey) => {
+  let elementGroupInfos = [];
+  for (let i = 0, len = datas.length; i < len; i++) {
+    if (i === 0 || datas[i][paramKey] !== datas[i - 1][paramKey]) {
+      elementGroupInfos.push([datas[i]]);
+    } else {
+      elementGroupInfos[elementGroupInfos.length - 1].push(datas[i]);
+    }
+  }
+  return elementGroupInfos;
+};
+
+const findElementById = (id, topics) => {
+  let curElement = null;
+  topics.forEach((element) => {
+    if (curElement) return;
+    if (element.id === id) {
+      curElement = element;
+      return;
+    }
+
+    if (element["elements"]) {
+      element["elements"].forEach((elem) => {
+        if (elem.id === id) curElement = elem;
+      });
+    }
+  });
+  return curElement;
+};
+
+const checkElementisCovered = (id, type) => {
+  const elementDom = document.getElementById(id);
+
+  if (type === "EXPLAIN") {
+    const elemTitleDome = elementDom.querySelector(".elem-title");
+    const limitHeight = elemTitleDome
+      ? elementDom.offsetHeight - elemTitleDome.offsetHeight
+      : elementDom.offsetHeight;
+
+    let elementHeights = [];
+    elementDom
+      .querySelector(".elem-explain-elements")
+      .childNodes.forEach((node) => {
+        if (!node.className.includes("elem-explain-element")) return;
+        elementHeights.push(
+          node.firstChild.offsetHeight + node.firstChild.offsetTop
+        );
+      });
+    return elementHeights.some((item) => item > limitHeight);
+  }
+
+  if (type === "COMPOSITION") {
+    const elemTitleDome = elementDom.querySelector(".elem-title");
+    const limitHeight = elemTitleDome
+      ? elementDom.offsetHeight - elemTitleDome.offsetHeight
+      : elementDom.offsetHeight;
+
+    let elementHeights = [];
+    elementDom
+      .querySelector(".elem-composition-elements")
+      .childNodes.forEach((node) => {
+        if (!node.className.includes("elem-composition-element")) return;
+        elementHeights.push(
+          node.firstChild.offsetHeight + node.firstChild.offsetTop
+        );
+      });
+    return elementHeights.some((item) => item > limitHeight);
+  }
+
+  return elementDom.offsetHeight < elementDom.firstChild.offsetHeight;
+};
+
+const actions = {
+  initTopicsFromPages({ state, commit }) {
+    let topics = [];
+    state.pages.forEach((page) => {
+      page.columns.forEach((column) => {
+        column.elements.forEach((element) => {
+          if (
+            element.type === "TOPIC_HEAD" ||
+            (element.type === "CARD_HEAD" && element.isSimple)
+          )
+            return;
+          topics.push(element);
+        });
+      });
+    });
+    commit("setTopics", topics);
+  },
+  modifyPagesInfo({ state, commit }, data) {
+    for (let i = 0; i < state.pages.length; i++) {
+      state.pages[i] = objAssign(state.pages[i], data);
+    }
+    commit("setCurPage", state.curPageNo);
+  },
+  actElementById({ state, commit }, id) {
+    const curElement = findElementById(id, state.topics);
+    if (!curElement) return;
+
+    commit("setCurElement", curElement);
+  },
+  resetTopicSeries({ state, commit }) {
+    let curTopicId = "",
+      curTopicNo = 0,
+      topicSeries = [];
+    state.topics.forEach((topic) => {
+      if (!topic.parent) return;
+      if (curTopicId !== topic.parent.id) {
+        curTopicId = topic.parent.id;
+        curTopicNo++;
+        topicSeries.push({
+          id: curTopicId,
+          topicNo: curTopicNo,
+          type: topic.type,
+          sign: topic.sign,
+        });
+      }
+      topic.topicNo = curTopicNo;
+    });
+    commit("setTopicSeries", topicSeries);
+  },
+  // 新增试题 --------------->
+  addElement({ state, commit }, element) {
+    let pos = null;
+
+    if (state.insetTarget.id) {
+      //
+    } else {
+      //
+    }
+
+    let preElements = [];
+    preElements.forEach((preElement, index) => {
+      if (pos && pos !== -1) {
+        state.topics.splice(pos + index, 0, preElement);
+      } else {
+        state.topics.push(preElement);
+      }
+    });
+    commit("setCurElement", element);
+  },
+  addSideElement({ state, commit }, element) {
+    state.pages[state.curPageNo].sides.push(element);
+    state.curPage = state.pages[state.curPageNo];
+    commit("setCurElement", element);
+  },
+  // 修改试题 --------------->
+  modifyTopic({ state }, element) {
+    // 单独编辑某个细分题
+    const pos = fetchElementPositionInfos(element, state.topics);
+    state.topics.splice(pos, 1, element);
+  },
+  modifyExplain({ state }, element) {
+    // 解答题既是拆分题,又是可复制题
+    const positionInfos = fetchAllRelateParentElementPositionInfos(
+      element,
+      state.topics
+    );
+    const elementGroupPosInfos = groupByParams(positionInfos, "serialNumber");
+    for (let i = 0; i < elementGroupPosInfos.length; i++) {
+      elementGroupPosInfos[i].forEach((pos) => {
+        let child = state.topics[pos._elementNo];
+        child.parent = { ...element };
+        child.topicName = element.topicName;
+      });
+    }
+  },
+  modifyElement({ dispatch }, element) {
+    if (element.type === "EXPLAIN") {
+      dispatch("modifyExplain", element);
+    } else {
+      dispatch("modifySplitTopic", element);
+    }
+    // commit("setCurElement", element);
+  },
+  // 修改试题包含元素
+  modifyElementChild({ state, commit }, element) {
+    // 修改解答题小题
+    const pos = fetchElementPositionInfos(element.container, state.topics);
+    const columnElements = state.topics[pos].elements;
+    const childIndex = columnElements.findIndex(
+      (item) => item.id === element.id
+    );
+    element.id = getElementId();
+    if (childIndex === -1) {
+      columnElements.push(element);
+    } else {
+      columnElements.splice(childIndex, 1, element);
+    }
+
+    commit("setCurElement", element);
+  },
+  // 粘贴试题内的元素
+  pasteExplainElementChild({ state }, { curElement, pasteElement }) {
+    let element = {
+      id: curElement.container ? curElement.container.id : curElement.id,
+    };
+    const pos = fetchElementPositionInfos(element, state.topics);
+    if (pos === -1) return;
+
+    element = state.topics[pos];
+    const newElement = Object.assign({}, pasteElement, {
+      id: getElementId(),
+      container: {
+        id: element.id,
+        type: element.type,
+      },
+    });
+    element.elements.push(newElement);
+  },
+  // 删除试题 --------------->
+  removeElement({ state, commit }, element) {
+    const positionInfos = fetchAllRelateParentElementPositionInfos(
+      element.parent,
+      state.topics
+    );
+    positionInfos.reverse().forEach((pos) => {
+      state.topics.splice(pos._elementNo, 1);
+    });
+
+    commit("setCurElement", {});
+  },
+  // 删除试题包含元素 --------------->
+  removeElementChild({ state, commit }, element) {
+    // 删除解答题小题
+    const pos = fetchElementPositionInfos(element.container, state.topics);
+    const columnElements = state.topics[pos].elements;
+    const childIndex = columnElements.findIndex(
+      (item) => item.id === element.id
+    );
+    columnElements.splice(childIndex, 1);
+
+    commit("setCurElement", {});
+  },
+  // 扩展答题区操作 --------------->
+  copyExplainChildren({ state }, element) {
+    let curElement = {
+      id: element.container ? element.container.id : element.id,
+    };
+    const pos = fetchElementPositionInfos(curElement, state.topics);
+    curElement = state.topics[pos];
+
+    let newElement = Object.assign({}, curElement, {
+      id: getElementId(),
+      elements: [],
+      h: 200,
+      isExtend: true,
+      showTitle: false,
+    });
+
+    state.topics.splice(pos + 1, 0, newElement);
+    // 更新小题答题区isLast
+    let positionInfos = fetchSameSerialNumberChildrenPositionInfo(
+      curElement,
+      state.topics
+    );
+    positionInfos.forEach((pos, pindex) => {
+      state.topics[pos._elementNo].isLast = pindex + 1 === positionInfos.length;
+    });
+  },
+  deleteExplainChildren({ state }, element) {
+    let curElement = {
+      id: element.container ? element.container.id : element.id,
+    };
+    const curPos = fetchElementPositionInfos(curElement, state.topics);
+    curElement = state.topics[curPos];
+
+    let positionInfos = fetchSameSerialNumberChildrenPositionInfo(
+      curElement,
+      state.topics
+    );
+    if (positionInfos.length < 2) return;
+    const pindex = positionInfos.findIndex(
+      (item) => item._elementId === curElement.id
+    );
+    const pos = positionInfos[pindex]._elementNo;
+    positionInfos.splice(pindex, 1);
+    const nextPos = positionInfos[0]._elementNo;
+    // 当删除的是非扩展区域时,则下一个答题区要被设置成非扩展区
+    if (!curElement.isExtend) {
+      state.topics[nextPos].isExtend = false;
+    }
+    // 当删除的是含有标题答题区时,则需要将下一个答题区开启显示标题。
+    if (curElement.showTitle) {
+      state.topics[nextPos].showTitle = true;
+    }
+    state.topics.splice(pos, 1);
+    // 更新小题答题区isLast
+    positionInfos = fetchSameSerialNumberChildrenPositionInfo(
+      curElement,
+      state.topics
+    );
+    positionInfos.forEach((pos, pindex) => {
+      state.topics[pos._elementNo].isLast = pindex + 1 === positionInfos.length;
+    });
+  },
+  // 大题顺序操作 --------------->
+  topicMoveUp({ state, dispatch }, topicId) {
+    const curTopicPos = state.topicSeries.findIndex(
+      (item) => item.id === topicId
+    );
+    const prevTopicId = state.topicSeries[curTopicPos - 1].id;
+    let relateTopicPos = [];
+    state.topics.forEach((item, index) => {
+      if (item.parent && item.parent.id === topicId) relateTopicPos.push(index);
+    });
+    const prevTopicFirstIndex = state.topics.findIndex(
+      (item) => item.parent && item.parent.id === prevTopicId
+    );
+    const relateTopics = state.topics.splice(
+      relateTopicPos[0],
+      relateTopicPos.length
+    );
+    relateTopics.reverse().forEach((topic) => {
+      state.topics.splice(prevTopicFirstIndex, 0, topic);
+    });
+
+    dispatch("resetTopicSeries");
+  },
+  // 重构页面
+  resetElementProp({ state }, isResetId = false) {
+    state.topics.forEach((element) => {
+      const elementDom = document.getElementById(element.id);
+      if (elementDom) {
+        element.h = elementDom.offsetHeight;
+        element.w = elementDom.offsetWidth;
+      }
+      if (isResetId) {
+        element.id = getElementId();
+      }
+    });
+  },
+  rebuildPages({ state, commit }) {
+    const columnNumber = state.cardConfig.columnNumber;
+    const pageSize = state.cardConfig.pageSize;
+    // 更新元件最新的高度信息
+    // 整理所有元件
+    state.topics.forEach((element) => {
+      const elementDom = document.getElementById(`preview-${element.id}`);
+
+      if (elementDom) {
+        element.h = elementDom.offsetHeight;
+        element.w = elementDom.offsetWidth;
+        // 解答题小题与其他题有些区别。
+        // 其他题都是通过内部子元素自动撑高元件,而解答题则需要手动设置高度。
+        const ESCAPE_ELEMENTS = ["CARD_HEAD"];
+        element.isCovered =
+          !ESCAPE_ELEMENTS.includes(element.type) &&
+          checkElementisCovered(`preview-${element.id}`, element.type);
+      }
+    });
+
+    // 动态计算每列可以分配的元件
+    const columnHeight = document.getElementById("topic-column").offsetHeight;
+    let pages = [];
+    let page = {};
+    let columns = [];
+    let curColumnElements = [];
+    let curColumnHeight = 0;
+
+    const initCurColumnElements = () => {
+      curColumnElements = [];
+      curColumnHeight = 0;
+    };
+
+    // 放入元素通用流程
+    const pushElement = (element) => {
+      // 当前栏中第一个题型之前新增题型头元素(topic-head)。
+      // 题型头和当前题要组合加入栏中,不可拆分。
+      let elementList = [element];
+
+      const elementHeight = calcSum(elementList.map((elem) => elem.h));
+      if (curColumnHeight + elementHeight > columnHeight) {
+        // 当前栏第一个元素高度超过一栏时,不拆分,直接放在当前栏。
+        // 解决可能空栏的情况
+        const curElementIsFirst = !curColumnElements.length;
+        if (curElementIsFirst) {
+          curColumnElements = [...curColumnElements, ...elementList];
+          curColumnHeight += elementHeight;
+        } else {
+          columns.push([...curColumnElements]);
+          initCurColumnElements();
+          pushElement(element);
+        }
+      } else {
+        curColumnElements = [...curColumnElements, ...elementList];
+        curColumnHeight += elementHeight;
+      }
+    };
+
+    // 批量添加所有元素。
+    initCurColumnElements();
+    state.topics.forEach((element, eindex) => {
+      element.elementSerialNo = eindex;
+      pushElement(element);
+    });
+
+    // 最后一栏的处理。
+    columns.push([...curColumnElements]);
+    // 构建pages
+    columns.forEach((column, cindex) => {
+      const columnNo = cindex % columnNumber;
+      if (!columnNo) {
+        page = getNewPage(pages.length, { pageSize, columnNumber });
+      }
+      page.columns[columnNo].elements = column;
+
+      if (columnNo + 1 === columnNumber || cindex === columns.length - 1) {
+        pages.push(page);
+      }
+    });
+    // 保证页面总是偶数页
+    if (numberIsOdd(pages.length)) {
+      pages.push(getNewPage(pages.length, { pageSize, columnNumber }));
+    }
+
+    pages.forEach((page, pindex) => {
+      if (numberIsOdd(pindex + 1)) {
+        page.sides = deepCopy(state.pageDefaultElems.sideLeft);
+      } else {
+        page.sides = deepCopy(state.pageDefaultElems.sideRight);
+      }
+    });
+
+    commit("setPages", pages);
+    commit("setCurPage", state.curPageNo);
+  },
+};
+
+export { fetchSameSerialNumberChildrenPositionInfo, checkElementisCovered };
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions,
+};

+ 13 - 0
src/modules/paper-export/views/PaperTemplateEdit.vue

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

+ 161 - 0
src/modules/paper-export/views/PaperTemplateManage.vue

@@ -0,0 +1,161 @@
+<template>
+  <div class="paper-template-manage">
+    <div class="part-box">
+      <h2 class="part-box-title">试卷模板管理</h2>
+      <!-- 搜索 -->
+      <el-form class="part-filter-form" inline :model="searchForm">
+        <el-form-item>
+          <el-button type="danger" @click="handleCurrentChange(1)">
+            查询
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="part-box-action">
+        <el-button type="primary" plain icon="icon icon-edit" @click="toCreate"
+          >新建模板
+        </el-button>
+      </div>
+    </div>
+
+    <div class="part-box">
+      <!-- 页面列表 -->
+      <el-table ref="table" :data="tableData">
+        <el-table-column prop="name" label="名称"> </el-table-column>
+        <el-table-column
+          prop="createTime"
+          label="创建时间"
+          width="170"
+        ></el-table-column>
+        <el-table-column prop="creator" label="创建人"></el-table-column>
+        <el-table-column width="50" label="状态">
+          <template slot-scope="scope">
+            <span v-if="scope.row.enable">
+              <el-tooltip
+                class="item"
+                effect="dark"
+                content="启用"
+                placement="left"
+              >
+                <i class="icon icon-right"></i>
+              </el-tooltip>
+            </span>
+            <span v-else>
+              <el-tooltip
+                class="item"
+                effect="dark"
+                content="禁用"
+                placement="left"
+              >
+                <i class="icon icon-error"></i>
+              </el-tooltip>
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column width="170" label="操作">
+          <template slot-scope="scope">
+            <el-button
+              v-if="!onlyAssignTeacher"
+              size="mini"
+              :type="scope.row.enable ? 'danger' : 'primary'"
+              plain
+              @click="toEnable(scope.row)"
+            >
+              {{ scope.row.enable ? "禁用" : "启用" }}
+            </el-button>
+            <el-dropdown>
+              <el-button type="primary" plain size="mini">
+                更多<i class="el-icon-more el-icon--right"></i>
+              </el-button>
+              <el-dropdown-menu slot="dropdown" class="action-dropdown">
+                <el-dropdown-item>
+                  <el-button
+                    size="mini"
+                    type="primary"
+                    plain
+                    @click="toEdit(scope.row)"
+                    >编辑模板
+                  </el-button>
+                </el-dropdown-item>
+                <el-dropdown-item v-if="!onlyAssignTeacher">
+                  <el-button
+                    size="mini"
+                    type="danger"
+                    plain
+                    @click="toDelete(scope.row)"
+                    >删除
+                  </el-button>
+                </el-dropdown-item>
+              </el-dropdown-menu>
+            </el-dropdown>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="part-page">
+        <el-pagination
+          :current-page="currentPage"
+          :page-size="pageSize"
+          :page-sizes="[10, 20, 50, 100, 200, 300]"
+          layout="total, sizes, prev, pager, next, jumper"
+          :total="total"
+          @current-change="handleCurrentChange"
+          @size-change="handleSizeChange"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { paperTemplateListApi } from "../api";
+
+export default {
+  name: "PaperTemplateManage",
+  data() {
+    return {
+      loading: false,
+      tableData: [],
+      curRow: {},
+      currentPage: 1,
+      pageSize: 10,
+      total: 10,
+    };
+  },
+  methods: {
+    async search() {
+      if (this.loading) return;
+      this.loading = true;
+
+      const res = await paperTemplateListApi({
+        pageNumber: this.currentPage,
+        pageSize: this.pageSize,
+      }).catch(() => {});
+
+      this.loading = false;
+      if (!res) return;
+
+      this.tableData = res.data.content;
+      this.total = res.data.totalElements;
+    },
+    handleCurrentChange(val) {
+      this.currentPage = val;
+      this.search();
+    },
+    handleSizeChange(val) {
+      this.currentPage = 1;
+      this.pageSize = val;
+      this.search();
+    },
+    toCreate() {},
+    toEnable(row) {
+      console.log(row);
+    },
+    toEdit(row) {
+      console.log(row);
+    },
+    toDelete(row) {
+      console.log(row);
+    },
+  },
+};
+</script>

+ 13 - 0
src/modules/paper-export/views/PaperTemplatePreview.vue

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