Browse Source

引入题卡

zhangjie 3 years ago
parent
commit
87252fc94d
100 changed files with 12646 additions and 0 deletions
  1. 40 0
      src/modules/card/api.js
  2. BIN
      src/modules/card/assets/images/barcode-sample-notext.png
  3. BIN
      src/modules/card/assets/images/icon-back.png
  4. BIN
      src/modules/card/assets/images/icon-checked.png
  5. BIN
      src/modules/card/assets/images/icon-close-act.png
  6. BIN
      src/modules/card/assets/images/icon-close.png
  7. BIN
      src/modules/card/assets/images/icon-column-four-act.png
  8. BIN
      src/modules/card/assets/images/icon-column-four.png
  9. BIN
      src/modules/card/assets/images/icon-column-one-act.png
  10. BIN
      src/modules/card/assets/images/icon-column-one.png
  11. BIN
      src/modules/card/assets/images/icon-column-three-act.png
  12. BIN
      src/modules/card/assets/images/icon-column-three.png
  13. BIN
      src/modules/card/assets/images/icon-column-two-act.png
  14. BIN
      src/modules/card/assets/images/icon-column-two.png
  15. BIN
      src/modules/card/assets/images/icon-doubt.png
  16. BIN
      src/modules/card/assets/images/icon-four-gray.png
  17. BIN
      src/modules/card/assets/images/icon-four-white.png
  18. BIN
      src/modules/card/assets/images/icon-help.png
  19. BIN
      src/modules/card/assets/images/icon-radio-checked.png
  20. BIN
      src/modules/card/assets/images/icon-three-gray.png
  21. BIN
      src/modules/card/assets/images/icon-three-white.png
  22. BIN
      src/modules/card/assets/images/icon-two-gray.png
  23. BIN
      src/modules/card/assets/images/icon-two-white.png
  24. 482 0
      src/modules/card/assets/styles/base.scss
  25. 830 0
      src/modules/card/assets/styles/card-design.scss
  26. 1490 0
      src/modules/card/assets/styles/card-preview.scss
  27. 1257 0
      src/modules/card/assets/styles/card-temp.css
  28. 619 0
      src/modules/card/assets/styles/element-ui-costom.scss
  29. 341 0
      src/modules/card/assets/styles/home.scss
  30. 102 0
      src/modules/card/assets/styles/icons.scss
  31. 10 0
      src/modules/card/assets/styles/index.scss
  32. 5 0
      src/modules/card/assets/styles/module.scss
  33. 42 0
      src/modules/card/assets/styles/variables.scss
  34. 0 0
      src/modules/card/card.temp.json
  35. 137 0
      src/modules/card/components/CardConfigPropEdit.vue
  36. 524 0
      src/modules/card/components/CardDesign.vue
  37. 111 0
      src/modules/card/components/CardView.vue
  38. 114 0
      src/modules/card/components/ElementPropEdit.vue
  39. 42 0
      src/modules/card/components/PageNumber.vue
  40. 192 0
      src/modules/card/components/PagePropEdit.vue
  41. 358 0
      src/modules/card/components/PaperParams.vue
  42. 309 0
      src/modules/card/components/RightClickMenu.vue
  43. 132 0
      src/modules/card/components/TopicElementEdit.vue
  44. 72 0
      src/modules/card/components/TopicElementPreview.vue
  45. 66 0
      src/modules/card/components/TopicSelectDialog.vue
  46. 148 0
      src/modules/card/components/UploadButton.vue
  47. 79 0
      src/modules/card/components/common/ColorSelect.vue
  48. 68 0
      src/modules/card/components/common/DirectionSelect.vue
  49. 582 0
      src/modules/card/components/common/ElementResize.vue
  50. 60 0
      src/modules/card/components/common/FontFamilySelect.vue
  51. 79 0
      src/modules/card/components/common/LineStyleSelect.vue
  52. 76 0
      src/modules/card/components/common/LineWidthSelect.vue
  53. 49 0
      src/modules/card/components/common/PopoverButton.vue
  54. 59 0
      src/modules/card/components/common/RotationSelect.vue
  55. 33 0
      src/modules/card/components/common/ShortcutKeySpin.vue
  56. 81 0
      src/modules/card/components/common/SizeSelect.vue
  57. 21 0
      src/modules/card/components/common/TopicNumber.vue
  58. 49 0
      src/modules/card/directives/move-ele.js
  59. 174 0
      src/modules/card/elementModel.js
  60. 127 0
      src/modules/card/elements/barcode/EditBarcode.vue
  61. 45 0
      src/modules/card/elements/barcode/ElemBarcode.vue
  62. 27 0
      src/modules/card/elements/barcode/model.js
  63. 190 0
      src/modules/card/elements/card-head/CardHead.vue
  64. 105 0
      src/modules/card/elements/card-head/CardHeadBodyAutoResize.vue
  65. 48 0
      src/modules/card/elements/card-head/CardHeadSample.vue
  66. 156 0
      src/modules/card/elements/card-head/cardHeadSpin/HeadDynamic.vue
  67. 41 0
      src/modules/card/elements/card-head/cardHeadSpin/HeadNotice.vue
  68. 55 0
      src/modules/card/elements/card-head/cardHeadSpin/HeadStdinfo.vue
  69. 57 0
      src/modules/card/elements/card-head/cardHeadSpin/HeadStdno.vue
  70. 33 0
      src/modules/card/elements/card-head/model.js
  71. 70 0
      src/modules/card/elements/composition/EditComposition.vue
  72. 48 0
      src/modules/card/elements/composition/ElemComposition.vue
  73. 138 0
      src/modules/card/elements/composition/ElemCompositionEdit.vue
  74. 60 0
      src/modules/card/elements/composition/ElemCompositionElement.vue
  75. 120 0
      src/modules/card/elements/composition/ElemCompositionElementEdit.vue
  76. 69 0
      src/modules/card/elements/composition/model.js
  77. 121 0
      src/modules/card/elements/explain/EditExplain.vue
  78. 55 0
      src/modules/card/elements/explain/ElemExplain.vue
  79. 141 0
      src/modules/card/elements/explain/ElemExplainEdit.vue
  80. 58 0
      src/modules/card/elements/explain/ElemExplainElement.vue
  81. 117 0
      src/modules/card/elements/explain/ElemExplainElementEdit.vue
  82. 67 0
      src/modules/card/elements/explain/model.js
  83. 142 0
      src/modules/card/elements/fill-field/EditFillField.vue
  84. 87 0
      src/modules/card/elements/fill-field/ElemFillField.vue
  85. 25 0
      src/modules/card/elements/fill-field/model.js
  86. 277 0
      src/modules/card/elements/fill-line/EditFillLine.vue
  87. 90 0
      src/modules/card/elements/fill-line/ElemFillLine.vue
  88. 105 0
      src/modules/card/elements/fill-line/model.js
  89. 87 0
      src/modules/card/elements/fill-number/EditFillNumber.vue
  90. 52 0
      src/modules/card/elements/fill-number/ElemFillNumber.vue
  91. 23 0
      src/modules/card/elements/fill-number/model.js
  92. 139 0
      src/modules/card/elements/fill-pane/EditFillPane.vue
  93. 49 0
      src/modules/card/elements/fill-pane/ElemFillPane.vue
  94. 25 0
      src/modules/card/elements/fill-pane/model.js
  95. 257 0
      src/modules/card/elements/fill-question/EditFillQuestion.vue
  96. 142 0
      src/modules/card/elements/fill-question/ElemFillQuestion.vue
  97. 93 0
      src/modules/card/elements/fill-question/model.js
  98. 205 0
      src/modules/card/elements/fill-table/EditFillTable.vue
  99. 40 0
      src/modules/card/elements/fill-table/ElemFillTable.vue
  100. 27 0
      src/modules/card/elements/fill-table/model.js

+ 40 - 0
src/modules/card/api.js

@@ -0,0 +1,40 @@
+import { randomCode } from "./plugins/utils";
+import Vue from "vue";
+
+export const cardConfigInfos = () => {
+  return Promise.resolve({
+    id: "173438690998091776",
+    createId: "173437828976345088",
+    createTime: 1632291806278,
+    updateId: null,
+    updateTime: 1632291806278,
+    schoolId: "2",
+    orgId: "173436480729907200",
+    name: "测试题卡规则1",
+    examNumberStyle: "PRINT",
+    paperType: "PRINT",
+    examAbsent: true,
+    writeSign: true,
+    requiredFields:
+      '[{"code":"ticketNumber","name":"考号","enable":true,"selected":false},{"code":"siteNumber","name":"座位号","enable":true,"selected":false},{"code":"studentName","name":"姓名","enable":true,"selected":false},{"code":"courseName","name":"课程名称","enable":true,"selected":false}]',
+    extendFields: "[]",
+    // extendFields:
+    //   '[{"code":"studentCode","name":"学号","enable":true,"selected":false},{"code":"courseCode","name":"课程代码","enable":true,"selected":false},{"code":"paperNumber","name":"试卷编号","enable":true,"selected":false},{"code":"campusName","name":"校区","enable":true,"selected":false},{"code":"examPlace","name":"考点","enable":true,"selected":false},{"code":"examRoom","name":"考场","enable":true,"selected":false},{"code":"examDate","name":"考试日期","enable":true,"selected":false},{"code":"examTime","name":"考试时间","enable":true,"selected":false}]',
+    titleRule: "测试题卡规则1",
+    attention: "测试题卡规则1",
+    objectiveAttention: "测试题卡规则1",
+    subjectiveAttention: "测试题卡规则1",
+    enable: true,
+    remark: "测试题卡规则1",
+    orgIds: null,
+  });
+};
+export const cardDetail = () => {
+  const cardData = Vue.ls.get("cardData", {});
+  return Promise.resolve(cardData);
+};
+
+export const saveCard = (datas) => {
+  Vue.ls.set("cardData", datas);
+  return Promise.resolve(randomCode());
+};

BIN
src/modules/card/assets/images/barcode-sample-notext.png


BIN
src/modules/card/assets/images/icon-back.png


BIN
src/modules/card/assets/images/icon-checked.png


BIN
src/modules/card/assets/images/icon-close-act.png


BIN
src/modules/card/assets/images/icon-close.png


BIN
src/modules/card/assets/images/icon-column-four-act.png


BIN
src/modules/card/assets/images/icon-column-four.png


BIN
src/modules/card/assets/images/icon-column-one-act.png


BIN
src/modules/card/assets/images/icon-column-one.png


BIN
src/modules/card/assets/images/icon-column-three-act.png


BIN
src/modules/card/assets/images/icon-column-three.png


BIN
src/modules/card/assets/images/icon-column-two-act.png


BIN
src/modules/card/assets/images/icon-column-two.png


BIN
src/modules/card/assets/images/icon-doubt.png


BIN
src/modules/card/assets/images/icon-four-gray.png


BIN
src/modules/card/assets/images/icon-four-white.png


BIN
src/modules/card/assets/images/icon-help.png


BIN
src/modules/card/assets/images/icon-radio-checked.png


BIN
src/modules/card/assets/images/icon-three-gray.png


BIN
src/modules/card/assets/images/icon-three-white.png


BIN
src/modules/card/assets/images/icon-two-gray.png


BIN
src/modules/card/assets/images/icon-two-white.png


+ 482 - 0
src/modules/card/assets/styles/base.scss

@@ -0,0 +1,482 @@
+/* reset */
+body,
+div,
+ul,
+ol,
+li,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+input,
+p,
+tr,
+th,
+td,
+span,
+a,
+header,
+footer,
+i {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  -webkit-tap-highlight-color: rgba(255, 255, 255, 0);
+}
+li {
+  list-style: none;
+}
+em,
+i,
+u {
+  font-style: normal;
+}
+input {
+  outline: none;
+  border: none;
+  background: rgba(245, 245, 245, 1);
+  font-family: $--font-family;
+}
+input::-webkit-input-placeholder,
+input::-moz-placeholder,
+input:-ms-input-placeholder,
+input:-moz-placeholder {
+  font-size: 12px;
+  font-weight: bold;
+  color: $--color-text-gray-4;
+}
+button,
+textarea {
+  font-family: $--font-family;
+}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  font-size: 100%;
+}
+fieldset,
+img {
+  border: 0;
+}
+abbr {
+  border: 0;
+  font-variant: normal;
+}
+a {
+  text-decoration: none;
+  color: inherit;
+  *color: $--color-text-gray-3;
+}
+img {
+  vertical-align: middle;
+}
+
+/* common-style */
+input:-webkit-autofill {
+  box-shadow: 0 0 0 1000px white inset;
+}
+input[type="text"]:focus,
+input[type="password"]:focus,
+input[type="number"]:focus,
+textarea:focus {
+  box-shadow: 0 0 0 1000px white inset;
+}
+
+/* browse style */
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+  background: transparent;
+}
+::-webkit-scrollbar-button {
+  display: none;
+}
+::-webkit-scrollbar-track {
+  background: transparent;
+}
+::-webkit-scrollbar-thumb {
+  border-radius: 8px;
+  background: #666;
+}
+::-webkit-scrollbar-corner {
+  background: transparent;
+}
+::-webkit-scrollbar-resizer {
+  background: transparent;
+}
+
+body {
+  font-family: $--font-family;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  font-size: $--font-size-base;
+  color: $--color-text-dark-1;
+}
+
+/* part */
+.part-box {
+  margin-bottom: 20px;
+  background-color: #fff;
+  border-radius: $--border-radius;
+
+  &-border {
+    border: 1px solid $--color-border;
+  }
+  &-pad {
+    padding: 20px;
+  }
+
+  &-filter {
+    padding: 20px 20px 5px 20px;
+
+    .el-form-item {
+      margin-bottom: 15px;
+    }
+    .el-form-item__label {
+      display: none;
+    }
+  }
+  &-gray {
+    background-color: $--color-text-gray-7;
+  }
+
+  &-flex {
+    display: flex;
+    align-items: stretch;
+    justify-content: space-between;
+  }
+
+  &-action {
+    padding-bottom: 15px;
+    white-space: nowrap;
+    display: flex;
+    align-items: flex-end;
+  }
+  &-tips {
+    font-size: 16px;
+    line-height: 25px;
+    color: $--color-text-dark-1;
+    margin-bottom: 15px;
+  }
+
+  &-head {
+    display: flex;
+    align-items: stretch;
+    justify-content: space-between;
+    min-height: 30px;
+    margin: -10px 0 10px -10px;
+    color: $--color-text-dark;
+
+    > h3 {
+      font-size: 17px;
+    }
+    .el-icon-question {
+      margin-left: 10px;
+      font-size: 16px;
+      color: $--color-text-gray-5;
+      cursor: pointer;
+
+      &:hover {
+        color: #fe8652;
+      }
+    }
+  }
+}
+.part-title {
+  font-size: 16px;
+  font-weight: bold;
+  padding: 15px 20px;
+  line-height: 30px;
+  overflow: hidden;
+
+  h2 {
+    float: left;
+  }
+  &-infos {
+    float: right;
+  }
+}
+.part-body {
+  padding: 25px;
+}
+.part-page {
+  margin-top: 15px;
+  text-align: right;
+}
+.part-none {
+  padding: 100px;
+  font-size: 20px;
+  color: $--color-text-gray-3;
+  text-align: center;
+}
+// box-justify
+.box-justify {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+// page-head
+.page-head {
+  margin-bottom: 20px;
+  color: $--color-text-dark;
+  &-flex {
+    display: flex;
+    align-items: stretch;
+    justify-content: space-between;
+  }
+
+  > h2 {
+    font-size: 20px;
+  }
+  .el-icon-question {
+    margin-left: 10px;
+    font-size: 16px;
+    color: $--color-text-gray-5;
+    cursor: pointer;
+
+    &:hover {
+      color: #fe8652;
+    }
+  }
+}
+
+/* table */
+.table {
+  width: 100%;
+  border-spacing: 0;
+  border-collapse: collapse;
+  text-align: left;
+
+  &.table-white {
+    background-color: #fff;
+  }
+
+  th {
+    padding: 12px;
+    line-height: 1.2;
+    letter-spacing: 1px;
+    color: $--color-text-gray-2;
+    border: 1px solid $--color-border;
+  }
+  td {
+    padding: 14px;
+    line-height: 1.2;
+    color: $--color-text-dark;
+    border: 1px solid $--color-border;
+
+    &.td-link {
+      span {
+        cursor: pointer;
+        &:hover {
+          color: $--color-text-gray;
+        }
+      }
+    }
+  }
+  .td-th {
+    font-weight: 600;
+    color: $--color-text-gray;
+  }
+
+  &--border {
+    border: 1px solid $--color-border;
+    border-radius: 10px;
+    th {
+      background-color: #fcfcfd;
+      border: none;
+      border-bottom: 1px solid $--color-border;
+    }
+    td {
+      border: none;
+      border-bottom: 1px solid $--color-border;
+    }
+  }
+}
+
+/* list */
+.list-lr-right {
+  float: right;
+  width: 300px;
+}
+.list-lr-left {
+  margin-right: 320px;
+}
+
+.vlcode {
+  height: 36px;
+}
+.vlcode-left {
+  margin-right: 135px;
+}
+.vlcode-right {
+  float: right;
+  width: 120px;
+}
+
+// color
+.color-primary {
+  color: $--color-primary !important;
+}
+.color-success {
+  color: $--color-success;
+}
+.color-warning {
+  color: $--color-warning;
+}
+.color-danger {
+  color: $--color-danger;
+}
+.color-info {
+  color: $--color-text-gray-1;
+}
+.color-dark {
+  color: $--color-dark;
+}
+.color-gray {
+  color: $--color-text-gray;
+}
+.color-gray-2 {
+  color: $--color-text-gray-2;
+}
+.color-white {
+  color: #fff;
+}
+
+// text
+.text-center {
+  text-align: center;
+}
+.text-left {
+  text-align: left;
+}
+.text-right {
+  text-align: right;
+}
+
+// other
+.btn-danger {
+  &.el-button--text {
+    color: $--color-danger !important;
+
+    &:hover {
+      font-weight: 600;
+      color: mix(#000, $--color-danger, 20%) !important;
+    }
+  }
+}
+.btn-primary {
+  &.el-button--text:not(.is-disabled) {
+    color: $--color-text-dark-1 !important;
+    &:hover {
+      font-weight: 600;
+      color: $--color-primary !important;
+    }
+  }
+}
+
+.btn-white {
+  background-color: #fff !important;
+  color: #999 !important;
+}
+.font-bold {
+  font-weight: bold;
+}
+.table-head-bg {
+  th {
+    background-color: #f6f6f6;
+    color: $--color-text-gray;
+  }
+}
+
+.tab-btns {
+  .el-button {
+    border-bottom-right-radius: 0;
+    border-bottom-left-radius: 0;
+
+    &:first-child {
+      border-bottom-left-radius: 8px;
+    }
+
+    &:last-child {
+      border-bottom-right-radius: 8px;
+    }
+  }
+
+  .el-button + .el-button {
+    margin-left: 10px;
+  }
+}
+
+.cont-link {
+  color: $--color-primary;
+  cursor: pointer;
+  &:hover {
+    color: $--color-success;
+  }
+}
+.ml-1 {
+  margin-left: 5px;
+}
+.ml-2 {
+  margin-left: 10px;
+}
+.mr-1 {
+  margin-right: 5px;
+}
+.mr-2 {
+  margin-right: 10px;
+}
+.mr-4 {
+  margin-right: 20px;
+}
+.mb-0 {
+  margin-bottom: 0;
+}
+.mb-2 {
+  margin-bottom: 10px;
+}
+.mb-4 {
+  margin-bottom: 20px;
+}
+
+// other
+.tips-info {
+  font-size: 12px;
+  line-height: 20px;
+  color: $--color-text-gray-2;
+}
+.tips-dark {
+  color: $--color-text-gray;
+}
+.tips-error {
+  color: $--color-danger;
+}
+.tips-icon {
+  display: inline-block;
+  vertical-align: middle;
+  color: $--color-text-gray-3;
+  font-size: 18px;
+  margin: 0 10px;
+  cursor: pointer;
+}
+.form-item-content {
+  color: $--color-text-gray-2;
+}
+.inline-block {
+  display: inline-block;
+  vertical-align: top;
+}
+.custom-tree-node {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 12px;
+  padding-right: 8px;
+}

+ 830 - 0
src/modules/card/assets/styles/card-design.scss

@@ -0,0 +1,830 @@
+// card-design
+.card-design {
+  color: $--color-text-dark;
+  background: $--color-background;
+
+  // page-box
+  .page-box {
+    // box-shadow: $--shadow-light;
+    // box-shadow: 0 0 1px #333;
+    &::before {
+      content: "";
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      top: 0;
+      left: 0;
+      z-index: 7;
+      // background-color: rgba($color: #ffffff, $alpha: 0.7);
+    }
+  }
+  .page-column-main {
+    &.is-active {
+      &::before,
+      .page-column-forbid-area {
+        border-color: $--color-primary;
+      }
+    }
+
+    &::before {
+      content: "";
+      display: block;
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      z-index: 8;
+      border: 1px dashed #d0d0d0;
+    }
+  }
+  .topic-element-edit {
+    .element-item-error {
+      box-shadow: 0 0 10px $--color-danger;
+    }
+
+    .element-resize {
+      background-color: #fff;
+      > .resize-control {
+        > .control-point,
+        > .control-line {
+          display: none;
+        }
+      }
+
+      &:hover {
+        > .resize-control {
+          > .control-line {
+            display: block;
+          }
+        }
+      }
+
+      &-act {
+        > .resize-control {
+          > .control-point,
+          > .control-line {
+            display: block;
+          }
+        }
+      }
+    }
+    .element-resize-compact {
+      > .resize-control {
+        > .control-line {
+          display: block;
+        }
+      }
+
+      &:hover {
+        > .resize-control {
+          > .control-line {
+            border-color: #617bea;
+          }
+        }
+      }
+
+      &.element-resize-act {
+        > .resize-control {
+          > .control-line {
+            border-color: #617bea;
+            &-left,
+            &-right {
+              border-left-style: solid;
+            }
+            &-top,
+            &-bottom {
+              border-top-style: solid;
+            }
+          }
+        }
+      }
+    }
+
+    .element-item-topic-number {
+      position: absolute;
+      left: -22px;
+      top: 0;
+      width: 20px;
+      height: 20px;
+      z-index: 99;
+      line-height: 20px;
+      font-size: 12px;
+      text-align: center;
+      background-color: $--color-primary;
+      color: #fff;
+      border-radius: 50% 0 50% 50%;
+      cursor: pointer;
+      &:hover {
+        background-color: $--color-primary-light;
+      }
+    }
+    // 编辑时,小题扩展答题区之间用虚线
+    .element-item {
+      &-explain {
+        &::before {
+          border-bottom: 1px dashed #333;
+        }
+      }
+      &-type-last {
+        &::before {
+          border-bottom: 1px solid #333;
+        }
+      }
+    }
+    &:last-child {
+      .element-item {
+        &::before {
+          border-bottom: 1px solid #333;
+        }
+      }
+      .element-item-card-head.element-item-type-pre {
+        &::before {
+          border-bottom: none;
+        }
+      }
+    }
+
+    // elem-pane
+    .elem-pane {
+      width: 100%;
+      height: 100%;
+    }
+  }
+  .page-column-element {
+    > .element-resize {
+      width: 100% !important;
+    }
+  }
+
+  // page-main-outer
+  .page-main-outer {
+    z-index: 1;
+    background-color: rgba($color: #ffffff, $alpha: 0.7);
+
+    &-act {
+      z-index: 19;
+    }
+  }
+
+  // design-other-pages
+  .design-other-pages {
+    position: absolute;
+    width: 2000px;
+    left: -9999px;
+    top: 0;
+    z-index: 1000;
+    visibility: hidden;
+  }
+
+  // card-head-sample
+  .card-head-sample {
+    position: absolute;
+    width: 2000px;
+    left: -9999px;
+    top: 0;
+    z-index: 1001;
+    visibility: hidden;
+  }
+  // card-head
+  .card-head {
+    &-top {
+      .el-input > .el-input__inner {
+        text-align: center;
+        border-radius: 0;
+        border: 0;
+        background-color: transparent;
+        box-shadow: 0 0 1px #ccc;
+        color: #000;
+        padding: 0;
+        letter-spacing: -1px;
+      }
+    }
+    &-title {
+      .el-input__inner {
+        font-size: 24px;
+        font-weight: bold;
+        line-height: 33px;
+        height: 33px;
+      }
+    }
+    &-subtitle {
+      .el-input__inner {
+        line-height: 22px;
+        height: 22px;
+        padding: 0 10px;
+      }
+    }
+  }
+}
+
+.design-header {
+  position: fixed;
+  width: 100%;
+  height: 50px;
+  top: 0;
+  left: 0;
+  z-index: 99;
+  padding-left: 260px;
+  background-color: $--color-text-dark;
+  overflow: hidden;
+  padding: 10px 20px 10px 260px;
+
+  .design-steps {
+    position: relative;
+    text-align: center;
+    height: 30px;
+
+    &::after {
+      content: "";
+      position: absolute;
+      width: 100%;
+      left: 0;
+      top: 14px;
+      border-bottom: 1px dashed $--color-text-gray;
+      z-index: 2;
+    }
+
+    .step-item {
+      position: absolute;
+      top: 0;
+      z-index: 8;
+      padding: 0 10px;
+      color: $--color-text-gray;
+
+      &::after {
+        content: "";
+        position: absolute;
+        width: 100%;
+        left: 0;
+        top: 14px;
+        border-bottom: 1px solid $--color-text-dark;
+        z-index: 5;
+      }
+
+      > i {
+        position: relative;
+        display: inline-block;
+        vertical-align: middle;
+        height: 30px;
+        width: 30px;
+        border-radius: 50%;
+        border: 2px solid $--color-text-gray;
+        line-height: 26px;
+        font-weight: bold;
+        z-index: 8;
+      }
+      > span {
+        position: relative;
+        display: inline-block;
+        vertical-align: middle;
+        margin-left: 10px;
+        font-weight: bold;
+        color: #999;
+        z-index: 8;
+      }
+
+      &:first-child {
+        left: 0;
+        padding-left: 0;
+      }
+      &:last-child {
+        right: 0;
+        padding-right: 0;
+      }
+      &:nth-of-type(2) {
+        left: 33.3%;
+        transform: translateX(-50%);
+      }
+      &:nth-of-type(3) {
+        left: 66.7%;
+        transform: translateX(-50%);
+      }
+    }
+  }
+}
+
+.design-action {
+  position: fixed;
+  width: 240px;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  z-index: 100;
+  overflow: auto;
+  font-size: 14px;
+  padding: 20px;
+  background: $--color-white;
+  border-top-right-radius: $--border-radius-huge;
+  border-bottom-right-radius: $--border-radius-huge;
+
+  .design-logo {
+    font-size: 20px;
+    line-height: 40px;
+    margin-bottom: 30px;
+
+    i {
+      cursor: pointer;
+      font-weight: 600;
+      &:hover {
+        color: $--color-primary;
+      }
+    }
+  }
+
+  .action-part {
+    margin-bottom: 5px;
+    color: $--color-text-dark-1;
+
+    &-title {
+      padding-bottom: 10px;
+      border-bottom: 1px solid $--color-border;
+    }
+    &-body {
+      padding: 20px 0;
+    }
+  }
+
+  // design-action
+  .type-list {
+    font-size: 0;
+  }
+  .type-item {
+    font-size: 14px;
+    display: inline-block;
+    vertical-align: top;
+    width: 50%;
+    text-align: center;
+    margin-bottom: 10px;
+    border-radius: 10px;
+    cursor: pointer;
+
+    &:nth-of-type(even) {
+      padding-left: 5px;
+    }
+    &:nth-of-type(odd) {
+      padding-right: 5px;
+    }
+    .el-button {
+      width: 100%;
+      font-size: 14px;
+    }
+
+    i {
+      margin-right: 2px;
+    }
+  }
+}
+
+.design-main {
+  padding: 70px 20px 50px 260px;
+  min-height: 100%;
+}
+
+.design-control {
+  position: fixed;
+  top: 50px;
+  left: 240px;
+  right: 0;
+  height: 70px;
+  z-index: 99;
+  background-color: $--color-background;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+
+  .control-right {
+    padding: 20px 20px 20px 0;
+    width: 240px;
+    text-align: right;
+    flex-grow: 0;
+    .el-button {
+      width: 60px;
+    }
+  }
+  .control-left {
+    padding: 20px 0 20px 20px;
+    white-space: nowrap;
+    overflow-y: hidden;
+    overflow-x: auto;
+    flex-grow: 2;
+  }
+}
+
+.design-body {
+  position: relative;
+  min-height: 1242px;
+  padding-top: 50px;
+}
+
+// topic-list
+.topic-list {
+  position: absolute;
+  top: -2000px;
+  left: -5000px;
+  z-index: auto;
+  visibility: hidden;
+  .element-item-card-head {
+    width: 100% !important;
+    height: auto !important;
+  }
+  .page-main-inner {
+    overflow: hidden;
+  }
+}
+
+// tool-tips
+.tool-tips {
+  color: $--color-text-dark-1;
+}
+// page-prop-edit
+.page-prop-edit {
+  .el-form-item {
+    margin-bottom: 5px;
+  }
+  .el-form-item__label,
+  .el-checkbox {
+    color: $--color-text-dark-1;
+  }
+  .column-btn {
+    padding: 0;
+    border: none !important;
+    vertical-align: middle;
+  }
+  .topicno-list {
+    font-size: 0;
+    line-height: 32px;
+    li {
+      display: inline-block;
+      vertical-align: middle;
+      margin: 0 5px 0 0;
+      width: 20px;
+      height: 20px;
+      line-height: 20px;
+      text-align: center;
+      font-size: 12px;
+      border-radius: 5px;
+      background-color: $--color-primary;
+      color: #fff;
+    }
+  }
+}
+// element-prop-edit
+.edit-dialog {
+  .el-input-split {
+    position: relative;
+    display: inline-block;
+    margin: 3px 10px;
+    width: 10px;
+    border-bottom: 2px solid #dddddd;
+  }
+  .el-input-number.is-without-controls {
+    .el-input__inner {
+      padding-left: 5px;
+      padding-right: 5px;
+    }
+  }
+}
+// element-tier-edit
+.element-tier-edit {
+  .tier-menu {
+    font-size: 0;
+    &-item {
+      display: inline-block;
+      vertical-align: top;
+      height: 26px;
+      line-height: 26px;
+      padding: 0 10px;
+      font-size: 13px;
+      cursor: pointer;
+      border: 1px solid #eff0f5;
+      width: 25%;
+      text-align: center;
+      border-top-left-radius: 3px;
+      border-top-right-radius: 3px;
+
+      &:hover {
+        color: $--color-primary;
+      }
+
+      &.is-active {
+        background-color: #eff0f5;
+        color: #333;
+      }
+    }
+  }
+  .tier-list {
+    height: 240px;
+    overflow: auto;
+    background-color: #eff0f5;
+    position: relative;
+    padding: 3px 0;
+    border-bottom-left-radius: 3px;
+    border-bottom-right-radius: 3px;
+  }
+  .tier-item {
+    position: relative;
+    padding: 0 5px;
+
+    &.after-drop {
+      &::after {
+        content: "";
+        display: block;
+        position: absolute;
+        width: 100%;
+        border-bottom: 2px solid $--color-text-dark;
+        bottom: -2px;
+        left: 0;
+        z-index: 9;
+      }
+    }
+    &.before-drop {
+      &::before {
+        content: "";
+        display: block;
+        position: absolute;
+        width: 100%;
+        border-top: 2px solid $--color-text-dark;
+        top: -2px;
+        left: 0;
+        z-index: 9;
+      }
+    }
+
+    &-cont {
+      padding: 5px 10px;
+      background-color: #fff;
+      border: 1px solid #e0e0e0;
+      border-radius: 3px;
+      height: 30px;
+      line-height: 20px;
+      font-size: 13px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      cursor: pointer;
+
+      &:hover {
+        color: $--color-primary;
+      }
+      &.is-active {
+        border-color: $--color-primary;
+        color: $--color-primary;
+      }
+    }
+  }
+}
+
+// right-menu-body
+.right-menu-body {
+  border: 1px solid #cccccc;
+  border-radius: 5px;
+  overflow: hidden;
+  background-color: #fff;
+  font-size: 14px;
+  color: #666666;
+  box-shadow: 5px 5px 4px 0px rgba(0, 0, 0, 0.1);
+  li {
+    padding: 6px;
+    font-weight: 400;
+    cursor: pointer;
+
+    &:hover {
+      background-color: #f6f6f6;
+      color: $--color-primary;
+    }
+    &.li-danger:hover {
+      color: $--color-danger;
+    }
+
+    &:not(:last-child) {
+      border-bottom: 1px solid #dddddd;
+    }
+  }
+}
+
+// design-preview-frame
+.design-preview-frame {
+  position: absolute;
+  width: 1000px;
+  height: 600px;
+  left: -9999px;
+  top: 0;
+  z-index: 1001;
+  visibility: hidden;
+}
+
+// card custom-select
+.custom-select {
+  width: 100%;
+  .select-preview {
+    height: 32px;
+    line-height: 30px;
+    border: 1px solid #e0e0e0;
+    border-radius: 5px;
+    position: relative;
+    overflow: hidden;
+    cursor: pointer;
+
+    &:hover {
+      border-color: #b0b0b0;
+    }
+
+    &-main {
+      padding: 0 30px 0 15px;
+    }
+    &-icon {
+      position: absolute;
+      top: 0;
+      right: 0;
+      width: 30px;
+      height: 100%;
+      background-color: #fff;
+      text-align: center;
+    }
+  }
+}
+
+// color-select
+.color-item {
+  display: inline-block;
+  vertical-align: middle;
+  width: 100px;
+  height: 26px;
+}
+.color-menu .el-dropdown-menu__item {
+  line-height: 30px;
+  padding: 0 15px;
+}
+// line-style-select
+.style-item {
+  display: inline-block;
+  vertical-align: middle;
+  width: 100px;
+  border-bottom-width: 2px;
+  border-bottom-color: #000;
+}
+.line-style-menu .el-dropdown-menu__item {
+  line-height: 24px;
+  padding: 0 10px;
+}
+// line-width-select
+.width-item {
+  display: inline-block;
+  vertical-align: middle;
+  width: 100px;
+  > i {
+    display: inline-block;
+    vertical-align: middle;
+    width: 65px;
+    border-bottom: 1pt solid #000;
+  }
+  > span {
+    display: block;
+    float: right;
+    line-height: 28px;
+    width: 40px;
+    margin-right: -10px;
+    text-align: center;
+  }
+}
+.line-width-menu .el-dropdown-menu__item {
+  line-height: 24px;
+  padding: 0 10px;
+}
+
+.edit-dialog {
+  .el-dialog__footer {
+    text-align: right;
+  }
+}
+
+// paper-params
+.paper-params {
+  .params-dialog-title {
+    font-size: 16px;
+    > span {
+      font-size: 14px;
+      margin-left: 24px;
+      color: $--color-text-gray-1;
+    }
+  }
+  .params-main {
+    margin-top: -10px;
+    padding: 0 10px;
+  }
+  .params-head {
+    margin-bottom: 20px;
+  }
+  .params-part {
+    margin: 15px 0;
+  }
+  .params-title {
+    margin-bottom: 10px;
+  }
+  .params-subtitle {
+    margin-bottom: 10px;
+    > span {
+      display: inline-block;
+      vertical-align: middle;
+      &:first-child {
+        margin-right: 20px;
+      }
+    }
+    .el-input-number {
+      width: 60px;
+      margin: 0 5px;
+    }
+  }
+  .param-sum-score {
+    color: $--color-danger;
+  }
+}
+
+// element-guide-lines
+.element-guide-lines {
+  .guide-line {
+    position: absolute;
+    z-index: 999;
+
+    &-x {
+      border-bottom: 1px solid $--color-success;
+    }
+    &-y {
+      border-left: 1px solid $--color-success;
+    }
+  }
+}
+
+// card-free-design
+.card-free-design {
+  .page-column-main {
+    overflow: hidden;
+  }
+  .topic-design {
+    position: absolute;
+  }
+  .design-header {
+    &-cont {
+      height: 100%;
+    }
+    .btn-help {
+      font-size: 20px;
+      padding: 0;
+    }
+  }
+  .design-action {
+    .action-part {
+      &-title {
+        padding-bottom: 5px;
+      }
+      &-body {
+        padding: 10px 0;
+      }
+    }
+  }
+
+  .control-left {
+    .el-button {
+      position: relative;
+    }
+    .page-delete {
+      display: block;
+      position: absolute;
+      top: 0;
+      right: 0;
+      margin-top: -8px;
+      margin-right: -8px;
+      font-size: 14px;
+      color: $--color-danger;
+      cursor: pointer;
+
+      &:hover {
+        color: mix(#000, $--color-danger, 20%);
+      }
+    }
+  }
+}
+
+// shortcut-key-spin
+.shortcut-key-spin {
+  position: relative;
+  display: inline-block;
+  vertical-align: middle;
+  border-radius: 5px;
+  padding: 0 5px;
+  background-color: $--color-text-gray-5;
+  color: $--color-text-dark-1;
+
+  &:not(:first-child) {
+    margin-left: 20px;
+    &::before {
+      content: "+";
+      display: block;
+      position: absolute;
+      left: -15px;
+      top: 50%;
+      transform: translateY(-50%);
+    }
+  }
+}

+ 1490 - 0
src/modules/card/assets/styles/card-preview.scss

@@ -0,0 +1,1490 @@
+// card-preview
+.card-preview {
+  padding: 10px 0;
+  background-color: #f0f0f0;
+  color: #000;
+  .page-box {
+    margin: 10px auto;
+    box-shadow: 0 0 4px #ddd;
+  }
+}
+.card-print {
+  padding: 0;
+
+  .page-box {
+    margin: 0 auto;
+    box-shadow: none;
+    page-break-after: always;
+  }
+  // 印刷模式:隐藏客观题强调标记
+  .elem-fill-question-first {
+    &::before {
+      display: none;
+    }
+  }
+}
+
+// page-box
+.page-box {
+  position: relative;
+  background: #fff;
+  margin: 0 auto;
+  font-weight: normal;
+
+  .page-main {
+    height: 100%;
+    position: relative;
+    white-space: nowrap;
+    margin: 0 -10px;
+
+    &-2 {
+      .page-column {
+        width: 50%;
+      }
+    }
+    &-3 {
+      .page-column {
+        width: 33.33%;
+      }
+    }
+    &-4 {
+      .page-column {
+        width: 25%;
+      }
+    }
+  }
+
+  &-A3 {
+    width: 1586px;
+    height: 1122px;
+
+    .page-main {
+      &-inner {
+        padding: 60px 80px 86px;
+      }
+
+      &-1 {
+        .page-column-forbid-area {
+          &::before {
+            width: 2000px;
+            transform: rotate(34.326deg);
+          }
+          &::after {
+            width: 2000px;
+            transform: rotate(-34.326deg);
+          }
+        }
+      }
+      &-2 {
+        .page-column-forbid-area {
+          &::before {
+            transform: rotate(54.216deg);
+          }
+          &::after {
+            transform: rotate(-54.216deg);
+          }
+        }
+      }
+
+      &-3 {
+        .page-column-forbid-area {
+          &::before {
+            transform: rotate(64.6555deg);
+          }
+          &::after {
+            transform: rotate(-64.6555deg);
+          }
+        }
+      }
+      &-4 {
+        .page-column-forbid-area {
+          &::before {
+            transform: rotate(70.3109deg);
+          }
+          &::after {
+            transform: rotate(-70.3109deg);
+          }
+        }
+      }
+    }
+  }
+  &-A4 {
+    width: 793px;
+    height: 1122px;
+
+    .page-main {
+      &-inner {
+        padding: 60px 45px 86px;
+      }
+
+      &-1 {
+        .page-column-forbid-area {
+          &::before {
+            transform: rotate(54.216deg);
+          }
+          &::after {
+            transform: rotate(-54.216deg);
+          }
+        }
+      }
+
+      &-2 {
+        .page-column-forbid-area {
+          &::before {
+            transform: rotate(70.5109deg);
+          }
+          &::after {
+            transform: rotate(-70.5109deg);
+          }
+        }
+      }
+    }
+  }
+}
+
+// 分栏间距,默认20px
+// page-main-inner
+.page-main-inner {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  padding: 60px 80px 86px;
+  z-index: 9;
+  font-size: 0;
+}
+// page-main-outer
+.page-main-outer {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 7;
+  background-color: transparent;
+  overflow: hidden;
+}
+
+.page-column {
+  display: inline-block;
+  vertical-align: middle;
+  position: relative;
+  height: 100%;
+  width: 100%;
+  font-size: 14px;
+  padding: 0 10px;
+
+  &-forbid-area {
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+    z-index: 1;
+    border: 1px solid #333;
+    overflow: hidden;
+    &::before {
+      content: "";
+      display: block;
+      position: absolute;
+      width: 1200px;
+      border-bottom: 1px solid rgba(172, 172, 172, 1);
+      top: 0;
+      left: 0;
+      transform: rotate(54.216deg);
+      transform-origin: left;
+      z-index: 1;
+    }
+    &::after {
+      content: "";
+      display: block;
+      position: absolute;
+      width: 1200px;
+      border-bottom: 1px solid rgba(172, 172, 172, 1);
+      bottom: 0;
+      left: 0;
+      transform: rotate(-54.216deg);
+      transform-origin: left;
+      z-index: 1;
+    }
+    > p {
+      padding: 20px;
+      position: absolute;
+      width: 260px;
+      height: 82px;
+      top: 50%;
+      left: 50%;
+      margin-top: -41px;
+      margin-left: -130px;
+      z-index: 9;
+      font-weight: bold;
+      font-size: 30px;
+      color: #999;
+      background-color: #fff;
+      text-align: center;
+    }
+  }
+  &-main {
+    position: relative;
+    height: 100%;
+  }
+  &-body {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    z-index: 9;
+  }
+  &-element {
+    .element-item {
+      position: relative;
+
+      &-width {
+        width: 100% !important;
+      }
+
+      &::before {
+        content: "";
+        position: absolute;
+        width: 100%;
+        height: 100%;
+        top: 0;
+        left: 0;
+        box-sizing: border-box;
+        z-index: 2;
+        border: 1px solid #333;
+        border-top: 0;
+      }
+
+      > div {
+        z-index: 9;
+        position: relative;
+      }
+      &-card-head {
+        &::before {
+          border: 0;
+        }
+      }
+
+      &-topic-head {
+        &::before {
+          border: 0;
+        }
+      }
+      &-fill-question,
+      &-fill-line {
+        &::before {
+          border-bottom: 0;
+        }
+      }
+      // 预览时,小题扩展答题区之间隐藏分割线
+      &-explain {
+        &::before {
+          border-bottom-color: transparent;
+        }
+      }
+      &-type-last {
+        &::before {
+          border-bottom: 1px solid #333;
+        }
+      }
+    }
+    &:last-child {
+      .element-item {
+        &::before {
+          border-bottom: 1px solid #333;
+        }
+      }
+      .element-item-card-head.element-item-type-pre {
+        &::before {
+          border-bottom: none;
+        }
+      }
+    }
+  }
+}
+
+// locator
+.page-locators {
+  position: absolute;
+  top: 60px;
+  left: 80px;
+  right: 80px;
+  bottom: 86px;
+  z-index: 8;
+}
+.page-locator-group {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 24px;
+  &:first-child {
+    left: 0;
+  }
+  &:nth-of-type(2) {
+    left: 50%;
+    margin-left: -12px;
+  }
+  &:last-child {
+    left: auto;
+    right: 96px;
+  }
+  li {
+    position: absolute;
+    width: 24px;
+    border-bottom: 16px solid #000;
+    z-index: 99;
+    &:first-child {
+      top: -20px;
+    }
+    &:last-child {
+      bottom: -46px;
+    }
+  }
+}
+.page-box-1 {
+  .page-locator-group {
+    &:first-child {
+      left: -30px;
+    }
+  }
+}
+// page-number
+.page-number {
+  position: absolute;
+  bottom: 40px;
+  &-rect {
+    left: 152px;
+  }
+  &-rect-list {
+    font-size: 0;
+
+    li {
+      display: inline-block;
+      vertical-align: top;
+      font-size: 14px;
+      width: 24px;
+      height: 16px;
+      border: 1px solid #000;
+      margin-right: 10px;
+      &.rect-li-act {
+        height: 0;
+        border: none;
+        border-bottom: 16px solid #000;
+      }
+    }
+  }
+  &-text {
+    right: 25%;
+  }
+  &-text-cont {
+    height: 16px;
+    line-height: 16px;
+  }
+}
+
+// elem
+.elem {
+  &-title {
+    padding: 10px;
+    font-size: 14px;
+    color: rgba(0, 0, 0, 1);
+    line-height: 1;
+    white-space: normal;
+  }
+  &-body {
+    padding: 10px;
+  }
+}
+// grid
+.grid-container {
+  margin-left: -10px;
+  margin-right: -10px;
+}
+.grid-row {
+  display: table;
+  width: 100%;
+  border-spacing: 10px 0;
+  border-collapse: separate;
+
+  &:nth-of-type(2) {
+    margin-top: 10px;
+  }
+
+  .grid-col {
+    display: table-cell;
+    width: 50%;
+    vertical-align: top;
+    border: 1px solid #333;
+    &-dash {
+      border-style: dashed;
+      vertical-align: middle;
+    }
+  }
+}
+// card-head
+.card-head {
+  &-top {
+    text-align: center;
+    color: #000;
+  }
+  &-title {
+    font-size: 24px;
+    font-weight: bold;
+    overflow: hidden;
+
+    > h1 {
+      line-height: 33px;
+      white-space: nowrap;
+      letter-spacing: -1px;
+    }
+  }
+  &-subtitle {
+    height: 44px;
+    font-family: $--font-family;
+    font-size: 14px;
+    overflow: hidden;
+    white-space: normal;
+    margin-bottom: 10px;
+
+    > p {
+      padding: 0 10px;
+      line-height: 22px;
+      white-space: pre;
+    }
+  }
+  &-body {
+    font-weight: normal;
+    .el-col {
+      padding-top: 5px;
+      padding-bottom: 5px;
+    }
+    &-spin {
+      padding: 5px 12px;
+      white-space: normal;
+      word-break: break-all;
+    }
+    .stdinfo-item {
+      height: 30px;
+      line-height: 30px;
+      position: relative;
+      overflow: hidden;
+
+      &::after {
+        content: "";
+        display: block;
+        position: absolute;
+        width: 100%;
+        border-bottom: 1px solid #333;
+        bottom: 6px;
+        left: 0;
+        z-index: 1;
+      }
+
+      > span {
+        z-index: 2;
+        display: block;
+        position: relative;
+        font-size: 14px;
+
+        &:first-child {
+          float: left;
+          background-color: #fff;
+          text-align: justify;
+
+          &::after {
+            content: "";
+            display: inline-block;
+            width: 100%;
+            height: 0;
+            line-height: 0;
+          }
+        }
+        &:nth-of-type(2) {
+          float: left;
+          width: 20px;
+          background-color: #fff;
+        }
+        &:last-child {
+          margin-left: 80px;
+          height: 100%;
+        }
+      }
+    }
+    .head-stdno {
+      height: 100%;
+      padding: 0;
+      .stdno-empty {
+        font-weight: bold;
+        letter-spacing: 3px;
+        text-align: center;
+      }
+      .stdno-fill {
+        min-height: 284px;
+        height: 100%;
+        position: relative;
+
+        &-rect {
+          font-size: 0;
+          height: 27px;
+          border-bottom: 1px solid #333;
+        }
+        &-number {
+          display: inline-block;
+          vertical-align: top;
+          width: 7.692%;
+          height: 100%;
+          &:not(:last-child) {
+            border-right: 1px solid #333;
+          }
+        }
+
+        &-head {
+          position: absolute;
+          width: 100%;
+          height: 51px;
+          top: 0;
+          left: 0;
+          z-index: 9;
+
+          > h5 {
+            border-bottom: 1px solid #333;
+            line-height: 24px;
+            font-size: 16px;
+            font-weight: bold;
+            text-align: center;
+          }
+        }
+
+        &-body {
+          position: absolute;
+          top: 0;
+          bottom: 0;
+          padding-top: 51px;
+          display: table;
+          width: 100%;
+        }
+        &-list {
+          display: table-cell;
+          width: 7.692%;
+          padding: 1px 0;
+        }
+        &-option {
+          margin: 8px auto;
+          width: 20px;
+          height: 14px;
+          font-size: 12px;
+          line-height: 1;
+          text-align: center;
+          color: #000;
+          // border-rect
+          border: 1px solid #000;
+          font-family: "Times New Roman", Arial, sans-serif;
+          > i {
+            display: inline-block;
+            transform: scale(0.67, 0.67);
+          }
+        }
+      }
+      .stdno-auto {
+        &-barcode {
+          height: 70px;
+          text-align: center;
+
+          > img {
+            display: block;
+            height: 50px;
+            width: 300px;
+            margin: 0 auto;
+          }
+          > p {
+            line-height: 20px;
+          }
+        }
+      }
+    }
+
+    .head-notice {
+      > h4 {
+        font-weight: normal;
+        margin-bottom: 8px;
+      }
+      &-cont {
+        line-height: 1.5;
+        font-size: 12px;
+        margin-bottom: 5px;
+
+        > span {
+          display: block;
+
+          &:first-child {
+            width: 20px;
+            white-space: nowrap;
+            float: left;
+          }
+          &:last-child {
+            margin-left: 20px;
+          }
+        }
+      }
+
+      &-exam-number-fill {
+        span {
+          display: inline;
+
+          &:first-child {
+            float: none;
+          }
+          &:last-child {
+            margin: 0;
+          }
+        }
+      }
+    }
+
+    .head-dynamic {
+      padding: 0;
+      font-size: 12px;
+      border-spacing: 0;
+      border-collapse: collapse;
+
+      &-part:not(:last-child) {
+        border-bottom: 1px solid #000;
+      }
+      &-write {
+        padding: 5px 12px;
+        .stdinfo-item {
+          margin-bottom: 0;
+        }
+        > p {
+          line-height: 18px;
+        }
+      }
+      &-missfill {
+        display: table;
+        width: 100%;
+      }
+      &-miss {
+        padding: 10px;
+        display: table-cell;
+        vertical-align: middle;
+
+        &:nth-of-type(2) {
+          border-left: 1px solid #000;
+        }
+        span {
+          display: block;
+        }
+        .dynamic-miss-title {
+          width: 54px;
+          float: left;
+        }
+        .dynamic-miss-body {
+          margin-left: 54px;
+          text-align: center;
+        }
+        .head-dynamic-rect {
+          margin: auto;
+          vertical-align: middle;
+        }
+      }
+      &-fill {
+        padding: 10px;
+
+        p {
+          display: inline-block;
+          vertical-align: middle;
+          line-height: 18px;
+          word-wrap: normal;
+
+          &:first-child {
+            margin-right: 20px;
+          }
+
+          > span,
+          > i {
+            display: inline-block;
+            vertical-align: middle;
+            box-sizing: border-box;
+          }
+          &:first-child {
+            i {
+              width: 28px;
+              height: 14px;
+              background-color: #000;
+            }
+          }
+          &:last-child {
+            > i {
+              width: 28px;
+              height: 14px;
+              border: 1px solid #000;
+              font-size: 14px;
+              font-weight: bold;
+              margin-right: 6px;
+              line-height: 12px;
+              text-align: center;
+
+              &:last-child {
+                margin-right: 0;
+              }
+              // wkhtmltopdf 工具无法渲染如下样式:
+              // &:nth-of-type(1) {
+              //   position: relative;
+              //   &::before {
+              //     content: "";
+              //     display: block;
+              //     position: absolute;
+              //     left: 30%;
+              //     top: 1px;
+              //     height: 5px;
+              //     width: 11px;
+              //     transform: rotate(-45deg);
+              //     border-left: 1px solid #000;
+              //     border-bottom: 1px solid #000;
+              //   }
+              // }
+              // &:nth-of-type(2) {
+              //   position: relative;
+              //   &::before {
+              //     content: "";
+              //     display: block;
+              //     position: absolute;
+              //     left: 7px;
+              //     top: 5px;
+              //     width: 11px;
+              //     transform: rotate(-45deg);
+              //     transform-origin: center center;
+              //     border-bottom: 1px solid #000;
+              //   }
+              //   &::after {
+              //     content: "";
+              //     display: block;
+              //     position: absolute;
+              //     left: 8px;
+              //     top: 5px;
+              //     width: 11px;
+              //     transform: rotate(45deg);
+              //     transform-origin: center center;
+              //     border-bottom: 1px solid #000;
+              //   }
+              // }
+
+              &:nth-of-type(3) {
+                &::before {
+                  content: "";
+                  display: inline-block;
+                  vertical-align: top;
+                  margin-left: -5px;
+                  height: 100%;
+                  width: 5px;
+                  background-color: #000;
+                }
+              }
+              &:nth-of-type(4) {
+                &::before {
+                  content: "";
+                  display: inline-block;
+                  margin-top: 1px;
+                  width: 10px;
+                  height: 10px;
+                  border-radius: 50%;
+                  background-color: #000;
+                }
+              }
+            }
+          }
+        }
+      }
+      &-rect {
+        display: inline-block;
+        width: 30px;
+        height: 14px;
+        // border-rect
+        border: 1px solid #000;
+        font-size: 12px;
+        text-align: center;
+        line-height: 1;
+        color: #000;
+        margin: 0 5px;
+        font-family: "Times New Roman", Arial, sans-serif;
+
+        > i {
+          display: inline-block;
+          transform: scale(0.67, 0.67);
+        }
+      }
+      &-aorb {
+        display: table;
+        width: 100%;
+        .dynamic-aorb-item {
+          display: table-cell;
+          vertical-align: middle;
+          text-align: center;
+          &:not(:last-child) {
+            border-right: 1px solid #333;
+          }
+        }
+        &-fill {
+          .dynamic-aorb-item:first-child {
+            border: none;
+          }
+        }
+
+        .dynamic-aorb-title {
+          width: 83px;
+        }
+        .dynamic-aorb-info {
+          width: 50px;
+          font-size: 16px;
+          position: relative;
+          overflow: hidden;
+          .dynamic-aorb-content {
+            position: absolute;
+            top: 50%;
+            left: 0;
+            width: 100%;
+            transform: translateY(-50%);
+            z-index: auto;
+          }
+        }
+        .dynamic-aorb-barcode {
+          img {
+            display: block;
+            position: relative;
+            margin: 0 auto;
+            width: 200px;
+            height: 26px;
+            padding: 7px 0;
+          }
+        }
+        .dynamic-aorb-rects {
+          padding: 16px 10px;
+        }
+      }
+    }
+  }
+  &-part {
+    border: 1px solid #333;
+    &:not(:last-child) {
+      margin-bottom: 10px;
+    }
+  }
+  &-normal {
+    .head-dynamic {
+      &-1 {
+        .head-dynamic-part {
+          height: 100%;
+        }
+      }
+    }
+  }
+  &-narrow {
+    .head-stdno {
+      height: 138px;
+      .stdno-auto {
+        position: relative;
+        top: 50%;
+        margin-top: -40px;
+      }
+    }
+  }
+
+  &-handle {
+    &.card-head-narrow {
+      .head-stdno {
+        height: 286px;
+      }
+    }
+  }
+}
+// card-head-body-auto-resize
+.card-head-body-auto-resize {
+  margin-left: -5px;
+  margin-right: -5px;
+  overflow: hidden;
+
+  &.col-item-auto-height {
+    .card-head-body-spin {
+      height: auto;
+    }
+  }
+
+  .head-dynamic-2 {
+    .head-dynamic-part {
+      height: auto;
+    }
+  }
+
+  .rect-col {
+    padding: 5px;
+    &:first-child {
+      float: left;
+      width: 289px;
+    }
+    &:last-child {
+      float: right;
+      width: 424px;
+    }
+
+    &-item {
+      border: 1px solid #333;
+      &:nth-of-type(2) {
+        margin-top: 10px;
+      }
+      &-none {
+        border: none;
+        margin: 0 !important;
+      }
+    }
+  }
+}
+// elem-topic-head
+.elem-topic-head {
+  text-align: center;
+  .elem-body {
+    padding: 0;
+    border: 1px solid #333;
+  }
+  &-pad {
+    padding-top: 10px;
+  }
+  h3 {
+    font-size: 16px;
+    height: 29px;
+    line-height: 28px;
+    border-bottom: 1px dotted #333;
+    font-weight: normal;
+  }
+  p {
+    font-size: 12px;
+    height: 29px;
+    line-height: 29px;
+    white-space: nowrap;
+    overflow: hidden;
+    // text-overflow: ellipsis;
+  }
+}
+// elem-line
+.elem-line-horizontal {
+  height: 100%;
+  line-height: 30px;
+  .line-body {
+    display: inline-block;
+    vertical-align: middle;
+    width: 100%;
+    border-bottom: 1px solid #000;
+  }
+}
+.elem-line-vertical {
+  height: 100%;
+  text-align: center;
+  .line-body {
+    display: inline-block;
+    vertical-align: top;
+    height: 100%;
+    border-left: 1px solid #000;
+  }
+}
+// elem-lines
+.elem-lines {
+  .line-item {
+    display: inline-block;
+    vertical-align: top;
+  }
+}
+// elem-rect
+.elem-rect {
+  .rect-body {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+  }
+}
+// elem-text
+.elem-text {
+  .text-body {
+    padding: 5px;
+    line-height: 1.4;
+
+    span {
+      white-space: pre-wrap;
+      word-wrap: normal;
+      word-break: break-all;
+      &.cont-variate {
+        color: #a0a0a0;
+        margin: 0 2px;
+      }
+    }
+  }
+}
+// elem-barcode
+.elem-barcode {
+  height: 100%;
+  border-color: transparent;
+  border-width: 1pt;
+  position: relative;
+  > img {
+    max-height: 100%;
+    max-width: 100%;
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    margin: auto;
+  }
+}
+// elem-image
+.elem-image {
+  height: 100%;
+  border-color: transparent;
+  border-width: 1pt;
+  position: relative;
+  > p {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    display: table;
+    text-align: center;
+    color: #b0b0b0;
+    font-size: 30pt;
+
+    i {
+      display: table-cell;
+      vertical-align: middle;
+    }
+  }
+  > img {
+    max-height: 100%;
+    max-width: 100%;
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    margin: auto;
+  }
+}
+// elem-girds
+.elem-grids {
+  > table {
+    table-layout: fixed;
+    border-spacing: 0;
+    border-collapse: collapse;
+    td {
+      border: 1px solid #333;
+    }
+  }
+  &-halving {
+    > table {
+      table-layout: auto;
+      width: 100%;
+    }
+  }
+}
+// elem-fill-question
+.elem-fill-question {
+  white-space: normal;
+
+  // 客观题强调标记
+  &-first::before {
+    content: "";
+    position: absolute;
+    right: 0;
+    top: 0;
+    z-index: 99;
+    color: #fff;
+    padding: 6px 10px;
+    line-height: 1;
+    font-size: 18px;
+    border-bottom-left-radius: 10px;
+  }
+
+  &-first.elem-fill-question-simple {
+    &::before {
+      content: "单选";
+      background-color: mix(#fff, $--color-success, 20%);
+    }
+  }
+  &-first.elem-fill-question-multiply {
+    &::before {
+      content: "多选";
+      background-color: $--color-primary-light;
+    }
+  }
+  &-first.elem-fill-question-boolean {
+    &::before {
+      content: "判断";
+      background-color: mix(#fff, $--color-warning, 20%);
+    }
+  }
+
+  .elem-body {
+    padding: 18px 0 18px 16px;
+  }
+
+  .group-item {
+    font-family: "Times New Roman", Arial, sans-serif;
+    display: inline-block;
+    vertical-align: top;
+    font-size: 0;
+    // margin-bottom: 20px;
+  }
+  .question-item {
+    font-size: 0;
+  }
+  .option-item {
+    display: inline-block;
+    vertical-align: middle;
+    padding: 0;
+    width: 18px;
+    height: 14px;
+    text-align: center;
+    font-size: 12px;
+    line-height: 1;
+    // border-rect
+    border: 1px solid #000;
+    color: #000;
+    box-sizing: border-box;
+
+    > i {
+      display: inline-block;
+      transform: scale(0.67, 0.67);
+    }
+
+    &:first-child {
+      text-align: right;
+      border-color: transparent;
+      font-size: 12px;
+      color: #000;
+      > i {
+        transform: scale(1, 1);
+      }
+    }
+    &:last-child {
+      margin-right: 0 !important;
+    }
+  }
+
+  &-vertical {
+    .question-item {
+      display: inline-block;
+      vertical-align: top;
+      &:last-child {
+        margin-right: 0 !important;
+      }
+    }
+    .option-item {
+      display: block;
+
+      &:first-child {
+        padding: 0;
+        text-align: center;
+      }
+      &:last-child {
+        margin-bottom: 0 !important;
+      }
+    }
+  }
+}
+// elem-fill-area
+.elem-fill-area {
+  .option-item {
+    display: inline-block;
+    vertical-align: middle;
+    width: 30px;
+    height: 16px;
+    border: 1px solid #000;
+
+    &:last-child {
+      margin-right: 0 !important;
+    }
+  }
+
+  &-vertical {
+    .option-item {
+      display: block;
+      &:last-child {
+        margin-bottom: 0 !important;
+      }
+    }
+  }
+}
+// elem-fill-line
+.elem-fill-line {
+  white-space: normal;
+
+  .elem-body {
+    padding: 0 15px 0 10px;
+    font-size: 0;
+  }
+  .elem-fill-quesiton {
+    display: inline-block;
+    vertical-align: top;
+    position: relative;
+    padding: 0 1px;
+    font-size: 12px;
+
+    li {
+      &.elem-fill-line {
+        height: 40px;
+        position: relative;
+        margin: 0 10px 0 20px;
+        z-index: 8;
+        &::after {
+          content: "";
+          display: block;
+          position: absolute;
+          width: 100%;
+          border-bottom: 1px solid #000;
+          bottom: 8px;
+        }
+      }
+      &.elem-fill-no {
+        position: absolute;
+        top: 2px;
+        bottom: 2px;
+        left: 1px;
+        z-index: 9;
+        min-width: 20px;
+        text-align: left;
+        background-color: #fff;
+        border: none;
+
+        span {
+          display: block;
+          position: relative;
+          padding-bottom: 5px;
+          padding-right: 3px;
+          transform: translateY(-100%);
+        }
+      }
+      &.elem-fill-comma {
+        position: absolute;
+        top: 0;
+        right: -10px;
+        z-index: 9;
+        transform: translateY(-100%);
+        width: 10px;
+        padding-bottom: 4px;
+        background-color: #fff;
+        text-align: center;
+        border: none;
+      }
+    }
+  }
+}
+
+// elem-explain
+.elem-explain {
+  .elem-title {
+    padding-bottom: 0;
+  }
+  .elem-body {
+    min-height: 60px;
+    position: relative;
+  }
+  .elem-explain-no {
+    position: absolute;
+    left: 20px;
+    top: 10px;
+    font-size: 12px;
+    z-index: 9;
+  }
+  .elem-explain-elements {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    z-index: 8;
+  }
+  .elem-explain-element {
+    .explain-element-body {
+      position: absolute;
+    }
+  }
+}
+// .elem-composition
+.elem-composition {
+  .elem-title {
+    padding-bottom: 0;
+  }
+  .elem-body {
+    min-height: 60px;
+    position: relative;
+  }
+  &-elements {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    z-index: 8;
+  }
+
+  .elem-composition-element {
+    .composition-element-body {
+      position: absolute;
+      overflow: hidden;
+    }
+  }
+}
+// elem-fill-number
+.elem-fill-number {
+  border: 1px solid #000;
+  .fill-number {
+    &-rect {
+      font-size: 0;
+      height: 27px;
+      border-bottom: 1px solid #333;
+    }
+    &-number {
+      display: inline-block;
+      vertical-align: top;
+      width: 7.692%;
+      height: 100%;
+      &:not(:last-child) {
+        border-right: 1px solid #333;
+      }
+    }
+
+    &-head {
+      height: 51px;
+
+      > h5 {
+        border-bottom: 1px solid #333;
+        line-height: 24px;
+        font-size: 16px;
+        font-weight: bold;
+        text-align: center;
+      }
+    }
+
+    &-body {
+      display: table;
+      width: 100%;
+    }
+    &-list {
+      display: table-cell;
+      width: 7.692%;
+      padding: 1px 0;
+    }
+    &-option {
+      margin: 8px auto;
+      width: 20px;
+      height: 14px;
+      font-size: 12px;
+      line-height: 1;
+      text-align: center;
+      color: #000;
+      // border-rect
+      border: 1px solid #000;
+      font-family: "Times New Roman", Arial, sans-serif;
+      > i {
+        display: inline-block;
+        transform: scale(0.67, 0.67);
+      }
+    }
+  }
+}
+// elem-fill-field
+.elem-fill-field {
+  white-space: normal;
+  overflow: hidden;
+}
+.fill-field {
+  &-item {
+    display: inline-block;
+    padding: 0 10px;
+    width: 100%;
+  }
+  &-content {
+    height: 30px;
+    line-height: 26px;
+    overflow: hidden;
+    position: relative;
+
+    &::after {
+      content: "";
+      display: block;
+      position: absolute;
+      width: 100%;
+      border-bottom: 1px solid #333;
+      bottom: 2px;
+      left: 0;
+      z-index: 1;
+    }
+
+    > span {
+      z-index: 2;
+      display: inline-block;
+      position: relative;
+      font-size: 14px;
+      vertical-align: top;
+
+      &:first-child {
+        background-color: #fff;
+        text-align: justify;
+
+        &::after {
+          content: "";
+          display: inline-block;
+          width: 100%;
+          height: 0;
+          line-height: 0;
+        }
+      }
+      &:nth-of-type(2) {
+        width: 10px;
+        background-color: #fff;
+      }
+    }
+  }
+}
+
+// elem-fill-pane
+.elem-fill-pane {
+  font-size: 0;
+  white-space: normal;
+  overflow: hidden;
+  .fill-pane {
+    &-item {
+      display: inline-block;
+      vertical-align: top;
+      font-size: 14px;
+    }
+    &-cont {
+      border: 1px solid #000;
+    }
+  }
+}
+
+// card-free-preview
+.card-free-preview {
+  &:not(.card-print) {
+    padding: 10px 0;
+    background-color: #f0f0f0;
+
+    .page-box {
+      margin: 10px auto;
+      box-shadow: 0 0 4px #ddd;
+    }
+  }
+  .page-column-element {
+    .element-item {
+      position: absolute;
+
+      &::before {
+        display: none;
+      }
+    }
+  }
+}

+ 1257 - 0
src/modules/card/assets/styles/card-temp.css

@@ -0,0 +1,1257 @@
+.card-preview {
+  padding: 10px 0;
+  background-color: #f0f0f0;
+  color: #000;
+}
+.card-preview .page-box {
+  margin: 10px auto;
+  -webkit-box-shadow: 0 0 4px #ddd;
+  box-shadow: 0 0 4px #ddd;
+}
+
+.card-print {
+  padding: 0;
+}
+.card-print .page-box {
+  margin: 0 auto;
+  -webkit-box-shadow: none;
+  box-shadow: none;
+  page-break-after: always;
+}
+.card-print .elem-fill-question::before {
+  display: none;
+}
+
+.page-box {
+  position: relative;
+  background: #fff;
+  margin: 0 auto;
+  font-weight: normal;
+}
+.page-box .page-main {
+  height: 100%;
+  position: relative;
+  white-space: nowrap;
+  margin: 0 -10px;
+}
+.page-box .page-main-2 .page-column {
+  width: 50%;
+}
+.page-box .page-main-3 .page-column {
+  width: 33.33%;
+}
+.page-box .page-main-4 .page-column {
+  width: 25%;
+}
+.page-box-A3 {
+  width: 1586px;
+  height: 1122px;
+}
+.page-box-A3 .page-main-inner {
+  padding: 60px 80px 86px;
+}
+.page-box-A3 .page-main-1 .page-column-forbid-area::before {
+  width: 2000px;
+  -webkit-transform: rotate(34.326deg);
+  transform: rotate(34.326deg);
+}
+.page-box-A3 .page-main-1 .page-column-forbid-area::after {
+  width: 2000px;
+  -webkit-transform: rotate(-34.326deg);
+  transform: rotate(-34.326deg);
+}
+.page-box-A3 .page-main-2 .page-column-forbid-area::before {
+  -webkit-transform: rotate(54.216deg);
+  transform: rotate(54.216deg);
+}
+.page-box-A3 .page-main-2 .page-column-forbid-area::after {
+  -webkit-transform: rotate(-54.216deg);
+  transform: rotate(-54.216deg);
+}
+.page-box-A3 .page-main-3 .page-column-forbid-area::before {
+  -webkit-transform: rotate(64.6555deg);
+  transform: rotate(64.6555deg);
+}
+.page-box-A3 .page-main-3 .page-column-forbid-area::after {
+  -webkit-transform: rotate(-64.6555deg);
+  transform: rotate(-64.6555deg);
+}
+.page-box-A3 .page-main-4 .page-column-forbid-area::before {
+  -webkit-transform: rotate(70.3109deg);
+  transform: rotate(70.3109deg);
+}
+.page-box-A3 .page-main-4 .page-column-forbid-area::after {
+  -webkit-transform: rotate(-70.3109deg);
+  transform: rotate(-70.3109deg);
+}
+.page-box-A4 {
+  width: 793px;
+  height: 1122px;
+}
+.page-box-A4 .page-main-inner {
+  padding: 60px 45px 86px;
+}
+.page-box-A4 .page-main-1 .page-column-forbid-area::before {
+  -webkit-transform: rotate(54.216deg);
+  transform: rotate(54.216deg);
+}
+.page-box-A4 .page-main-1 .page-column-forbid-area::after {
+  -webkit-transform: rotate(-54.216deg);
+  transform: rotate(-54.216deg);
+}
+.page-box-A4 .page-main-2 .page-column-forbid-area::before {
+  -webkit-transform: rotate(70.5109deg);
+  transform: rotate(70.5109deg);
+}
+.page-box-A4 .page-main-2 .page-column-forbid-area::after {
+  -webkit-transform: rotate(-70.5109deg);
+  transform: rotate(-70.5109deg);
+}
+
+.page-main-inner {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  padding: 60px 80px 86px;
+  z-index: 9;
+  font-size: 0;
+}
+
+.page-main-outer {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 7;
+  background-color: transparent;
+  overflow: hidden;
+}
+
+.page-column {
+  display: inline-block;
+  vertical-align: middle;
+  position: relative;
+  height: 100%;
+  width: 100%;
+  font-size: 14px;
+  padding: 0 10px;
+}
+.page-column-forbid-area {
+  position: absolute;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  right: 0;
+  z-index: 1;
+  border: 1px solid #333;
+  overflow: hidden;
+}
+.page-column-forbid-area::before {
+  content: "";
+  display: block;
+  position: absolute;
+  width: 1200px;
+  border-bottom: 1px solid #acacac;
+  top: 0;
+  left: 0;
+  -webkit-transform: rotate(54.216deg);
+  transform: rotate(54.216deg);
+  -webkit-transform-origin: left;
+  transform-origin: left;
+  z-index: 1;
+}
+.page-column-forbid-area::after {
+  content: "";
+  display: block;
+  position: absolute;
+  width: 1200px;
+  border-bottom: 1px solid #acacac;
+  bottom: 0;
+  left: 0;
+  -webkit-transform: rotate(-54.216deg);
+  transform: rotate(-54.216deg);
+  -webkit-transform-origin: left;
+  transform-origin: left;
+  z-index: 1;
+}
+.page-column-forbid-area > p {
+  padding: 20px;
+  position: absolute;
+  width: 260px;
+  height: 82px;
+  top: 50%;
+  left: 50%;
+  margin-top: -41px;
+  margin-left: -130px;
+  z-index: 9;
+  font-weight: bold;
+  font-size: 30px;
+  color: #999;
+  background-color: #fff;
+  text-align: center;
+}
+.page-column-main {
+  position: relative;
+  height: 100%;
+}
+.page-column-body {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 9;
+}
+.page-column-element .element-item {
+  position: relative;
+}
+.page-column-element .element-item-width {
+  width: 100% !important;
+}
+.page-column-element .element-item::before {
+  content: "";
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  -webkit-box-sizing: border-box;
+  box-sizing: border-box;
+  z-index: 2;
+  border: 1px solid #333;
+  border-top: 0;
+}
+.page-column-element .element-item > div {
+  z-index: 9;
+  position: relative;
+}
+.page-column-element .element-item-card-head::before {
+  border: 0;
+}
+.page-column-element .element-item-topic-head::before {
+  border: 0;
+}
+.page-column-element .element-item-fill-question::before,
+.page-column-element .element-item-fill-line::before {
+  border-bottom: 0;
+}
+.page-column-element .element-item-explain::before {
+  border-bottom-color: transparent;
+}
+.page-column-element .element-item-type-last::before {
+  border-bottom: 1px solid #333;
+}
+.page-column-element:last-child .element-item::before {
+  border-bottom: 1px solid #333;
+}
+.page-column-element:last-child
+  .element-item-card-head.element-item-type-pre::before {
+  border-bottom: none;
+}
+
+.page-locators {
+  position: absolute;
+  top: 60px;
+  left: 80px;
+  right: 80px;
+  bottom: 86px;
+  z-index: 8;
+}
+
+.page-locator-group {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 24px;
+}
+.page-locator-group:first-child {
+  left: 0;
+}
+.page-locator-group:nth-of-type(2) {
+  left: 50%;
+  margin-left: -12px;
+}
+.page-locator-group:last-child {
+  left: auto;
+  right: 96px;
+}
+.page-locator-group li {
+  position: absolute;
+  width: 24px;
+  border-bottom: 16px solid #000;
+  z-index: 99;
+}
+.page-locator-group li:first-child {
+  top: -20px;
+}
+.page-locator-group li:last-child {
+  bottom: -46px;
+}
+
+.page-box-1 .page-locator-group:first-child {
+  left: -30px;
+}
+
+.page-number {
+  position: absolute;
+  bottom: 40px;
+}
+.page-number-rect {
+  left: 152px;
+}
+.page-number-rect-list {
+  font-size: 0;
+}
+.page-number-rect-list li {
+  display: inline-block;
+  vertical-align: top;
+  font-size: 14px;
+  width: 24px;
+  height: 16px;
+  border: 1px solid #000;
+  margin-right: 10px;
+}
+.page-number-rect-list li.rect-li-act {
+  height: 0;
+  border: none;
+  border-bottom: 16px solid #000;
+}
+.page-number-text {
+  right: 25%;
+}
+.page-number-text-cont {
+  height: 16px;
+  line-height: 16px;
+}
+
+.elem-title {
+  padding: 10px;
+  font-size: 14px;
+  color: black;
+  line-height: 1;
+  white-space: normal;
+}
+.elem-body {
+  padding: 10px;
+}
+
+.grid-container {
+  margin-left: -10px;
+  margin-right: -10px;
+}
+
+.grid-row {
+  display: table;
+  width: 100%;
+  border-spacing: 10px 0;
+  border-collapse: separate;
+}
+.grid-row:nth-of-type(2) {
+  margin-top: 10px;
+}
+.grid-row .grid-col {
+  display: table-cell;
+  width: 50%;
+  vertical-align: top;
+  border: 1px solid #333;
+}
+.grid-row .grid-col-dash {
+  border-style: dashed;
+  vertical-align: middle;
+}
+
+.card-head-top {
+  text-align: center;
+  color: #000;
+}
+.card-head-title {
+  font-size: 24px;
+  font-weight: bold;
+  overflow: hidden;
+}
+.card-head-title > h1 {
+  line-height: 33px;
+  white-space: nowrap;
+  letter-spacing: -1px;
+}
+.card-head-subtitle {
+  height: 44px;
+  font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
+    "Microsoft YaHei", Arial, sans-serif;
+  font-size: 14px;
+  overflow: hidden;
+  white-space: normal;
+  margin-bottom: 10px;
+}
+.card-head-subtitle > p {
+  padding: 0 10px;
+  line-height: 22px;
+  white-space: pre;
+}
+.card-head-body {
+  font-weight: normal;
+}
+.card-head-body .el-col {
+  padding-top: 5px;
+  padding-bottom: 5px;
+}
+.card-head-body-spin {
+  padding: 5px 12px;
+  white-space: normal;
+  word-break: break-all;
+}
+.card-head-body .stdinfo-item {
+  height: 30px;
+  line-height: 30px;
+  position: relative;
+  overflow: hidden;
+}
+.card-head-body .stdinfo-item::after {
+  content: "";
+  display: block;
+  position: absolute;
+  width: 100%;
+  border-bottom: 1px solid #333;
+  bottom: 6px;
+  left: 0;
+  z-index: 1;
+}
+.card-head-body .stdinfo-item > span {
+  z-index: 2;
+  display: block;
+  position: relative;
+  font-size: 14px;
+}
+.card-head-body .stdinfo-item > span:first-child {
+  float: left;
+  background-color: #fff;
+  text-align: justify;
+}
+.card-head-body .stdinfo-item > span:first-child::after {
+  content: "";
+  display: inline-block;
+  width: 100%;
+  height: 0;
+  line-height: 0;
+}
+.card-head-body .stdinfo-item > span:nth-of-type(2) {
+  float: left;
+  width: 20px;
+  background-color: #fff;
+}
+.card-head-body .stdinfo-item > span:last-child {
+  margin-left: 80px;
+  height: 100%;
+}
+.card-head-body .head-stdno {
+  height: 100%;
+  padding: 0;
+}
+.card-head-body .head-stdno .stdno-empty {
+  font-weight: bold;
+  letter-spacing: 3px;
+  text-align: center;
+}
+.card-head-body .head-stdno .stdno-fill {
+  min-height: 284px;
+  height: 100%;
+  position: relative;
+}
+.card-head-body .head-stdno .stdno-fill-rect {
+  font-size: 0;
+  height: 27px;
+  border-bottom: 1px solid #333;
+}
+.card-head-body .head-stdno .stdno-fill-number {
+  display: inline-block;
+  vertical-align: top;
+  width: 7.692%;
+  height: 100%;
+}
+.card-head-body .head-stdno .stdno-fill-number:not(:last-child) {
+  border-right: 1px solid #333;
+}
+.card-head-body .head-stdno .stdno-fill-head {
+  position: absolute;
+  width: 100%;
+  height: 51px;
+  top: 0;
+  left: 0;
+  z-index: 9;
+}
+.card-head-body .head-stdno .stdno-fill-head > h5 {
+  border-bottom: 1px solid #333;
+  line-height: 24px;
+  font-size: 16px;
+  font-weight: bold;
+  text-align: center;
+}
+.card-head-body .head-stdno .stdno-fill-body {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  padding-top: 51px;
+  display: table;
+  width: 100%;
+}
+.card-head-body .head-stdno .stdno-fill-list {
+  display: table-cell;
+  width: 7.692%;
+  padding: 1px 0;
+}
+.card-head-body .head-stdno .stdno-fill-option {
+  margin: 8px auto;
+  width: 20px;
+  height: 14px;
+  font-size: 12px;
+  line-height: 1;
+  text-align: center;
+  color: #000;
+  border: 1px solid #000;
+  font-family: "Times New Roman", Arial, sans-serif;
+}
+.card-head-body .head-stdno .stdno-fill-option > i {
+  display: inline-block;
+  -webkit-transform: scale(0.67, 0.67);
+  transform: scale(0.67, 0.67);
+}
+.card-head-body .head-stdno .stdno-auto-barcode {
+  height: 70px;
+  text-align: center;
+}
+.card-head-body .head-stdno .stdno-auto-barcode > img {
+  display: block;
+  height: 50px;
+  width: 300px;
+  margin: 0 auto;
+}
+.card-head-body .head-stdno .stdno-auto-barcode > p {
+  line-height: 20px;
+}
+.card-head-body .head-notice > h4 {
+  font-weight: normal;
+  margin-bottom: 8px;
+}
+.card-head-body .head-notice-cont {
+  line-height: 1.5;
+  font-size: 12px;
+  margin-bottom: 5px;
+}
+.card-head-body .head-notice-cont > span {
+  display: block;
+}
+.card-head-body .head-notice-cont > span:first-child {
+  width: 20px;
+  white-space: nowrap;
+  float: left;
+}
+.card-head-body .head-notice-cont > span:last-child {
+  margin-left: 20px;
+}
+.card-head-body .head-notice-exam-number-fill span {
+  display: inline;
+}
+.card-head-body .head-notice-exam-number-fill span:first-child {
+  float: none;
+}
+.card-head-body .head-notice-exam-number-fill span:last-child {
+  margin: 0;
+}
+.card-head-body .head-dynamic {
+  padding: 0;
+  font-size: 12px;
+  border-spacing: 0;
+  border-collapse: collapse;
+}
+.card-head-body .head-dynamic-part:not(:last-child) {
+  border-bottom: 1px solid #000;
+}
+.card-head-body .head-dynamic-write {
+  padding: 5px 12px;
+}
+.card-head-body .head-dynamic-write .stdinfo-item {
+  margin-bottom: 0;
+}
+.card-head-body .head-dynamic-write > p {
+  line-height: 18px;
+}
+.card-head-body .head-dynamic-missfill {
+  display: table;
+  width: 100%;
+}
+.card-head-body .head-dynamic-miss {
+  padding: 10px;
+  display: table-cell;
+  vertical-align: middle;
+}
+.card-head-body .head-dynamic-miss:nth-of-type(2) {
+  border-left: 1px solid #000;
+}
+.card-head-body .head-dynamic-miss span {
+  display: block;
+}
+.card-head-body .head-dynamic-miss .dynamic-miss-title {
+  width: 54px;
+  float: left;
+}
+.card-head-body .head-dynamic-miss .dynamic-miss-body {
+  margin-left: 54px;
+  text-align: center;
+}
+.card-head-body .head-dynamic-miss .head-dynamic-rect {
+  margin: auto;
+  vertical-align: middle;
+}
+.card-head-body .head-dynamic-fill {
+  padding: 10px;
+}
+.card-head-body .head-dynamic-fill p {
+  display: inline-block;
+  vertical-align: middle;
+  line-height: 18px;
+  word-wrap: normal;
+}
+.card-head-body .head-dynamic-fill p:first-child {
+  margin-right: 20px;
+}
+.card-head-body .head-dynamic-fill p > span,
+.card-head-body .head-dynamic-fill p > i {
+  display: inline-block;
+  vertical-align: middle;
+  -webkit-box-sizing: border-box;
+  box-sizing: border-box;
+}
+.card-head-body .head-dynamic-fill p:first-child i {
+  width: 28px;
+  height: 14px;
+  background-color: #000;
+}
+.card-head-body .head-dynamic-fill p:last-child > i {
+  width: 28px;
+  height: 14px;
+  border: 1px solid #000;
+  font-size: 14px;
+  font-weight: bold;
+  margin-right: 6px;
+  line-height: 12px;
+  text-align: center;
+}
+.card-head-body .head-dynamic-fill p:last-child > i:last-child {
+  margin-right: 0;
+}
+.card-head-body .head-dynamic-fill p:last-child > i:nth-of-type(3)::before {
+  content: "";
+  display: inline-block;
+  vertical-align: top;
+  margin-left: -5px;
+  height: 100%;
+  width: 5px;
+  background-color: #000;
+}
+.card-head-body .head-dynamic-fill p:last-child > i:nth-of-type(4)::before {
+  content: "";
+  display: inline-block;
+  margin-top: 1px;
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background-color: #000;
+}
+.card-head-body .head-dynamic-rect {
+  display: inline-block;
+  width: 30px;
+  height: 14px;
+  border: 1px solid #000;
+  font-size: 12px;
+  text-align: center;
+  line-height: 1;
+  color: #000;
+  margin: 0 5px;
+  font-family: "Times New Roman", Arial, sans-serif;
+}
+.card-head-body .head-dynamic-rect > i {
+  display: inline-block;
+  -webkit-transform: scale(0.67, 0.67);
+  transform: scale(0.67, 0.67);
+}
+.card-head-body .head-dynamic-aorb {
+  display: table;
+  width: 100%;
+}
+.card-head-body .head-dynamic-aorb .dynamic-aorb-item {
+  display: table-cell;
+  vertical-align: middle;
+  text-align: center;
+}
+.card-head-body .head-dynamic-aorb .dynamic-aorb-item:not(:last-child) {
+  border-right: 1px solid #333;
+}
+.card-head-body .head-dynamic-aorb-fill .dynamic-aorb-item:first-child {
+  border: none;
+}
+.card-head-body .head-dynamic-aorb .dynamic-aorb-title {
+  width: 83px;
+}
+.card-head-body .head-dynamic-aorb .dynamic-aorb-info {
+  width: 50px;
+  font-size: 16px;
+  position: relative;
+  overflow: hidden;
+}
+.card-head-body .head-dynamic-aorb .dynamic-aorb-info .dynamic-aorb-content {
+  position: absolute;
+  top: 50%;
+  left: 0;
+  width: 100%;
+  -webkit-transform: translateY(-50%);
+  transform: translateY(-50%);
+  z-index: auto;
+}
+.card-head-body .head-dynamic-aorb .dynamic-aorb-barcode img {
+  display: block;
+  position: relative;
+  margin: 0 auto;
+  width: 200px;
+  height: 26px;
+  padding: 7px 0;
+}
+.card-head-body .head-dynamic-aorb .dynamic-aorb-rects {
+  padding: 16px 10px;
+}
+.card-head-part {
+  border: 1px solid #333;
+}
+.card-head-part:not(:last-child) {
+  margin-bottom: 10px;
+}
+.card-head-normal .head-dynamic-1 .head-dynamic-part {
+  height: 100%;
+}
+.card-head-narrow .head-stdno {
+  height: 138px;
+}
+.card-head-narrow .head-stdno .stdno-auto {
+  position: relative;
+  top: 50%;
+  margin-top: -40px;
+}
+.card-head-handle.card-head-narrow .head-stdno {
+  height: 286px;
+}
+
+.card-head-body-auto-resize {
+  margin-left: -5px;
+  margin-right: -5px;
+  overflow: hidden;
+}
+.card-head-body-auto-resize.col-item-auto-height .card-head-body-spin {
+  height: auto;
+}
+.card-head-body-auto-resize .head-dynamic-2 .head-dynamic-part {
+  height: auto;
+}
+.card-head-body-auto-resize .rect-col {
+  padding: 5px;
+}
+.card-head-body-auto-resize .rect-col:first-child {
+  float: left;
+  width: 289px;
+}
+.card-head-body-auto-resize .rect-col:last-child {
+  float: right;
+  width: 424px;
+}
+.card-head-body-auto-resize .rect-col-item {
+  border: 1px solid #333;
+}
+.card-head-body-auto-resize .rect-col-item:nth-of-type(2) {
+  margin-top: 10px;
+}
+.card-head-body-auto-resize .rect-col-item-none {
+  border: none;
+  margin: 0 !important;
+}
+
+.elem-topic-head {
+  text-align: center;
+}
+.elem-topic-head .elem-body {
+  padding: 0;
+  border: 1px solid #333;
+}
+.elem-topic-head-pad {
+  padding-top: 10px;
+}
+.elem-topic-head h3 {
+  font-size: 16px;
+  height: 29px;
+  line-height: 28px;
+  border-bottom: 1px dotted #333;
+  font-weight: normal;
+}
+.elem-topic-head p {
+  font-size: 12px;
+  height: 29px;
+  line-height: 29px;
+  white-space: nowrap;
+  overflow: hidden;
+}
+
+.elem-line-horizontal {
+  height: 100%;
+  line-height: 30px;
+}
+.elem-line-horizontal .line-body {
+  display: inline-block;
+  vertical-align: middle;
+  width: 100%;
+  border-bottom: 1px solid #000;
+}
+
+.elem-line-vertical {
+  height: 100%;
+  text-align: center;
+}
+.elem-line-vertical .line-body {
+  display: inline-block;
+  vertical-align: top;
+  height: 100%;
+  border-left: 1px solid #000;
+}
+
+.elem-lines .line-item {
+  display: inline-block;
+  vertical-align: top;
+}
+
+.elem-rect .rect-body {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+}
+
+.elem-text .text-body {
+  padding: 5px;
+  line-height: 1.4;
+}
+.elem-text .text-body span {
+  white-space: pre-wrap;
+  word-wrap: normal;
+  word-break: break-all;
+}
+.elem-text .text-body span.cont-variate {
+  color: #a0a0a0;
+  margin: 0 2px;
+}
+
+.elem-barcode {
+  height: 100%;
+  border-color: transparent;
+  border-width: 1pt;
+  position: relative;
+}
+.elem-barcode > img {
+  max-height: 100%;
+  max-width: 100%;
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  margin: auto;
+}
+
+.elem-image {
+  height: 100%;
+  border-color: transparent;
+  border-width: 1pt;
+  position: relative;
+}
+.elem-image > p {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  display: table;
+  text-align: center;
+  color: #b0b0b0;
+  font-size: 30pt;
+}
+.elem-image > p i {
+  display: table-cell;
+  vertical-align: middle;
+}
+.elem-image > img {
+  max-height: 100%;
+  max-width: 100%;
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  margin: auto;
+}
+
+.elem-grids > table {
+  table-layout: fixed;
+  border-spacing: 0;
+  border-collapse: collapse;
+}
+.elem-grids > table td {
+  border: 1px solid #333;
+}
+.elem-grids-halving > table {
+  table-layout: auto;
+  width: 100%;
+}
+
+.elem-fill-question {
+  white-space: normal;
+}
+.elem-fill-question::before {
+  content: "";
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 99;
+  color: #fff;
+  padding: 6px 10px;
+  line-height: 1;
+  font-size: 18px;
+  border-bottom-left-radius: 10px;
+}
+.elem-fill-question-simple::before {
+  content: "单选";
+  background-color: #65d5ad;
+}
+.elem-fill-question-multiply::before {
+  content: "多选";
+  background-color: #617bea;
+}
+.elem-fill-question-boolean::before {
+  content: "判断";
+  background-color: #ffa952;
+}
+.elem-fill-question .elem-body {
+  padding: 18px 0 18px 16px;
+}
+.elem-fill-question .group-item {
+  font-family: "Times New Roman", Arial, sans-serif;
+  display: inline-block;
+  vertical-align: top;
+  font-size: 0;
+}
+.elem-fill-question .question-item {
+  font-size: 0;
+}
+.elem-fill-question .option-item {
+  display: inline-block;
+  vertical-align: middle;
+  padding: 0;
+  width: 18px;
+  height: 14px;
+  text-align: center;
+  font-size: 12px;
+  line-height: 1;
+  border: 1px solid #000;
+  color: #000;
+  -webkit-box-sizing: border-box;
+  box-sizing: border-box;
+}
+.elem-fill-question .option-item > i {
+  display: inline-block;
+  -webkit-transform: scale(0.67, 0.67);
+  transform: scale(0.67, 0.67);
+}
+.elem-fill-question .option-item:first-child {
+  text-align: right;
+  border-color: transparent;
+  font-size: 12px;
+  color: #000;
+}
+.elem-fill-question .option-item:first-child > i {
+  -webkit-transform: scale(1, 1);
+  transform: scale(1, 1);
+}
+.elem-fill-question .option-item:last-child {
+  margin-right: 0 !important;
+}
+.elem-fill-question-vertical .question-item {
+  display: inline-block;
+  vertical-align: top;
+}
+.elem-fill-question-vertical .question-item:last-child {
+  margin-right: 0 !important;
+}
+.elem-fill-question-vertical .option-item {
+  display: block;
+}
+.elem-fill-question-vertical .option-item:first-child {
+  padding: 0;
+  text-align: center;
+}
+.elem-fill-question-vertical .option-item:last-child {
+  margin-bottom: 0 !important;
+}
+
+.elem-fill-area .option-item {
+  display: inline-block;
+  vertical-align: middle;
+  width: 30px;
+  height: 16px;
+  border: 1px solid #000;
+}
+.elem-fill-area .option-item:last-child {
+  margin-right: 0 !important;
+}
+.elem-fill-area-vertical .option-item {
+  display: block;
+}
+.elem-fill-area-vertical .option-item:last-child {
+  margin-bottom: 0 !important;
+}
+
+.elem-fill-line {
+  white-space: normal;
+}
+.elem-fill-line .elem-body {
+  padding: 0 15px 0 10px;
+  font-size: 0;
+}
+.elem-fill-line .elem-fill-quesiton {
+  display: inline-block;
+  vertical-align: top;
+  position: relative;
+  padding: 0 1px;
+  font-size: 12px;
+}
+.elem-fill-line .elem-fill-quesiton li.elem-fill-line {
+  height: 40px;
+  position: relative;
+  margin: 0 10px 0 20px;
+  z-index: 8;
+}
+.elem-fill-line .elem-fill-quesiton li.elem-fill-line::after {
+  content: "";
+  display: block;
+  position: absolute;
+  width: 100%;
+  border-bottom: 1px solid #000;
+  bottom: 8px;
+}
+.elem-fill-line .elem-fill-quesiton li.elem-fill-no {
+  position: absolute;
+  top: 2px;
+  bottom: 2px;
+  left: 1px;
+  z-index: 9;
+  min-width: 20px;
+  text-align: left;
+  background-color: #fff;
+  border: none;
+}
+.elem-fill-line .elem-fill-quesiton li.elem-fill-no span {
+  display: block;
+  position: relative;
+  padding-bottom: 5px;
+  padding-right: 3px;
+  -webkit-transform: translateY(-100%);
+  transform: translateY(-100%);
+}
+.elem-fill-line .elem-fill-quesiton li.elem-fill-comma {
+  position: absolute;
+  top: 0;
+  right: -10px;
+  z-index: 9;
+  -webkit-transform: translateY(-100%);
+  transform: translateY(-100%);
+  width: 10px;
+  padding-bottom: 4px;
+  background-color: #fff;
+  text-align: center;
+  border: none;
+}
+
+.elem-explain .elem-title {
+  padding-bottom: 0;
+}
+.elem-explain .elem-body {
+  min-height: 60px;
+  position: relative;
+}
+.elem-explain .elem-explain-no {
+  position: absolute;
+  left: 20px;
+  top: 10px;
+  font-size: 12px;
+  z-index: 9;
+}
+.elem-explain .elem-explain-elements {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  z-index: 8;
+}
+.elem-explain .elem-explain-element .explain-element-body {
+  position: absolute;
+}
+
+.elem-composition .elem-title {
+  padding-bottom: 0;
+}
+.elem-composition .elem-body {
+  min-height: 60px;
+  position: relative;
+}
+.elem-composition-elements {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  z-index: 8;
+}
+.elem-composition .elem-composition-element .composition-element-body {
+  position: absolute;
+  overflow: hidden;
+}
+
+.elem-fill-number {
+  border: 1px solid #000;
+}
+.elem-fill-number .fill-number-rect {
+  font-size: 0;
+  height: 27px;
+  border-bottom: 1px solid #333;
+}
+.elem-fill-number .fill-number-number {
+  display: inline-block;
+  vertical-align: top;
+  width: 7.692%;
+  height: 100%;
+}
+.elem-fill-number .fill-number-number:not(:last-child) {
+  border-right: 1px solid #333;
+}
+.elem-fill-number .fill-number-head {
+  height: 51px;
+}
+.elem-fill-number .fill-number-head > h5 {
+  border-bottom: 1px solid #333;
+  line-height: 24px;
+  font-size: 16px;
+  font-weight: bold;
+  text-align: center;
+}
+.elem-fill-number .fill-number-body {
+  display: table;
+  width: 100%;
+}
+.elem-fill-number .fill-number-list {
+  display: table-cell;
+  width: 7.692%;
+  padding: 1px 0;
+}
+.elem-fill-number .fill-number-option {
+  margin: 8px auto;
+  width: 20px;
+  height: 14px;
+  font-size: 12px;
+  line-height: 1;
+  text-align: center;
+  color: #000;
+  border: 1px solid #000;
+  font-family: "Times New Roman", Arial, sans-serif;
+}
+.elem-fill-number .fill-number-option > i {
+  display: inline-block;
+  -webkit-transform: scale(0.67, 0.67);
+  transform: scale(0.67, 0.67);
+}
+
+.elem-fill-field {
+  white-space: normal;
+  overflow: hidden;
+}
+
+.fill-field-item {
+  display: inline-block;
+  padding: 0 10px;
+  width: 100%;
+}
+.fill-field-content {
+  height: 30px;
+  line-height: 26px;
+  overflow: hidden;
+  position: relative;
+}
+.fill-field-content::after {
+  content: "";
+  display: block;
+  position: absolute;
+  width: 100%;
+  border-bottom: 1px solid #333;
+  bottom: 2px;
+  left: 0;
+  z-index: 1;
+}
+.fill-field-content > span {
+  z-index: 2;
+  display: inline-block;
+  position: relative;
+  font-size: 14px;
+  vertical-align: top;
+}
+.fill-field-content > span:first-child {
+  background-color: #fff;
+  text-align: justify;
+}
+.fill-field-content > span:first-child::after {
+  content: "";
+  display: inline-block;
+  width: 100%;
+  height: 0;
+  line-height: 0;
+}
+.fill-field-content > span:nth-of-type(2) {
+  width: 10px;
+  background-color: #fff;
+}
+
+.elem-fill-pane {
+  font-size: 0;
+  white-space: normal;
+  overflow: hidden;
+}
+.elem-fill-pane .fill-pane-item {
+  display: inline-block;
+  vertical-align: top;
+  font-size: 14px;
+}
+.elem-fill-pane .fill-pane-cont {
+  border: 1px solid #000;
+}
+
+.card-free-preview:not(.card-print) {
+  padding: 10px 0;
+  background-color: #f0f0f0;
+}
+.card-free-preview:not(.card-print) .page-box {
+  margin: 10px auto;
+  -webkit-box-shadow: 0 0 4px #ddd;
+  box-shadow: 0 0 4px #ddd;
+}
+.card-free-preview .page-column-element .element-item {
+  position: absolute;
+}
+.card-free-preview .page-column-element .element-item::before {
+  display: none;
+}

+ 619 - 0
src/modules/card/assets/styles/element-ui-costom.scss

@@ -0,0 +1,619 @@
+/*
+* element-ui不管是自行构建的主题还是动态设置的主题,
+* 产生的css文件中存在近乎1/3的冗余样式,过于累赘,不如直接覆盖样式简洁。
+*/
+// dialog
+.el-dialog {
+  border-radius: 8px;
+  overflow: hidden;
+  border: 1px solid #c8c8ca;
+  box-shadow: 5px 5px 4px 0px rgba(0, 0, 0, 0.1);
+
+  &.is-fullscreen {
+    border-radius: 0;
+
+    .el-dialog__header {
+      width: 100%;
+      position: fixed;
+      z-index: 9;
+      background-color: #fff;
+      border-bottom: 1px solid $--color-border;
+    }
+    .el-dialog__body {
+      padding-top: 90px;
+    }
+  }
+}
+.el-dialog__header {
+  padding: 15px 20px;
+  .el-dialog__title {
+    color: $--color-text-dark;
+    font-size: 16px;
+    line-height: 19px;
+  }
+  .el-dialog__headerbtn {
+    top: 15px;
+    width: 16px;
+    height: 16px;
+    background-image: url(../images/icon-close.png);
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+
+    &:hover {
+      background-image: url(../images/icon-close-act.png);
+    }
+
+    .el-dialog__close {
+      display: none;
+    }
+  }
+}
+.el-dialog__body {
+  padding: 30px 40px;
+  position: relative;
+  border-top: 1px solid $--color-border;
+  color: $--color-text-dark-1;
+
+  .el-form-item__label {
+    padding-right: 2px;
+  }
+  .el-input-tips {
+    color: rgba(187, 187, 187, 1);
+    margin-left: 13px;
+  }
+}
+.el-dialog__footer {
+  overflow: hidden;
+  .el-button {
+    width: 100px;
+    border-radius: 8px;
+    float: right;
+    margin-left: 10px;
+  }
+}
+
+// .opacity-dialog
+.opacity-dialog {
+  .el-dialog {
+    background-color: transparent;
+  }
+  .el-dialog__header,
+  .el-dialog__footer {
+    display: none;
+  }
+  .el-dialog__body {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    padding: 0;
+    background-color: transparent;
+  }
+}
+
+// form
+.el-form {
+  &-item {
+    &__error {
+      font-size: 12px;
+      color: rgba(254, 108, 105, 1);
+    }
+    &__content {
+      .el-table {
+        line-height: 1;
+      }
+    }
+  }
+  // form-info
+  &.form-info {
+    .el-form-item {
+      margin-bottom: 0;
+
+      .el-form-item__label {
+        color: $--color-text-gray-2;
+      }
+    }
+  }
+  &--label-top {
+    .el-form-item__label {
+      line-height: 20px;
+      padding-bottom: 5px;
+      font-size: 12px;
+    }
+  }
+}
+// input
+.el-input {
+  &.is-focus {
+    .el-input__inner {
+      border-color: $--color-primary !important;
+    }
+  }
+  &.is-disabled {
+    .el-input__inner {
+      color: $--color-text-gray-2;
+    }
+  }
+  .el-input__inner {
+    border-radius: 8px;
+    border-color: #ddd;
+    background-color: #fff;
+  }
+  // .el-input__suffix {
+  //   right: 0;
+  //   border-left: 1px solid #ddd;
+  // }
+}
+// textarea
+.el-textarea {
+  &.is-disabled {
+    .el-textarea__inner {
+      color: $--color-text-gray-2;
+    }
+  }
+}
+.el-select {
+  .el-input__suffix {
+    right: 0;
+    border-left: 1px solid #ddd;
+  }
+  .el-input {
+    .el-select__caret {
+      width: 30px;
+    }
+    .el-icon-arrow-up:before {
+      font-size: 12px;
+      content: "\e78f";
+    }
+  }
+}
+.el-select-dropdown {
+  &.popper-filter {
+    .el-scrollbar {
+      display: block !important;
+      padding-top: 52px;
+    }
+    .el-select-filter {
+      padding: 0 10px;
+      position: absolute;
+      width: 100%;
+      top: 10px;
+      left: 0;
+      z-index: 9;
+    }
+  }
+}
+// upload
+.el-upload,
+.el-upload-dragger {
+  width: 100%;
+}
+// radio
+.el-radio-button {
+  &:hover {
+    .el-radio-button__inner {
+      color: $--color-primary;
+    }
+  }
+}
+.el-radio-button__orig-radio:checked + .el-radio-button__inner {
+  color: $--color-white;
+  border-color: $--color-primary;
+  background: $--color-primary;
+}
+// button
+.el-button {
+  border-radius: $--border-radius;
+
+  > .icon {
+    margin-right: 5px;
+  }
+  > span {
+    display: inline-block;
+  }
+  &.is-disabled {
+    color: $--color-text-gray-3 !important;
+    background: $--color-background !important;
+    border: 1px solid $--color-border !important;
+  }
+}
+
+.el-button + .popover-button,
+.popover-button + .el-button {
+  margin-left: 10px;
+}
+.el-button + .el-button {
+  margin-left: 10px;
+}
+.el-button--text + .el-button--text {
+  margin-left: 5px;
+}
+.el-button--small {
+  padding-top: 8px;
+  padding-bottom: 8px;
+  font-size: 12px;
+}
+.el-button--text {
+  color: $--color-text-gray-2;
+
+  & + .el-button--text {
+    margin-left: 10px;
+  }
+}
+.el-button--info {
+  background-color: $--color-cyan;
+  border-color: $--color-cyan;
+
+  &:hover,
+  &:focus {
+    background-color: $--color-cyan-light;
+    border-color: $--color-cyan-light;
+  }
+}
+.el-button--primary {
+  background-color: $--color-primary;
+  border-color: $--color-primary;
+
+  &:hover,
+  &:focus {
+    background-color: $--color-primary-light;
+    border-color: $--color-primary-light;
+  }
+}
+.el-button--success {
+  background-color: $--color-success;
+  border-color: $--color-success;
+
+  &:hover,
+  &:focus {
+    background-color: $--color-success-light;
+    border-color: $--color-success-light;
+  }
+}
+
+// table
+.el-table {
+  color: $--color-text-dark-1;
+
+  thead th {
+    color: $--color-text-gray-2;
+  }
+
+  thead.is-group th {
+    background-color: $--color-white;
+  }
+
+  tr.el-table__row {
+    color: $--color-text-dark;
+  }
+  td,
+  th {
+    border-color: $--color-border !important;
+    padding: 14px 0;
+    font-weight: 500;
+  }
+  .el-table__row.row-danger {
+    color: $--color-danger;
+  }
+  &.el-table--noback {
+    tr.el-table__row {
+      background-color: $--color-white;
+    }
+  }
+
+  .cell-head {
+    display: inline-block;
+    vertical-align: middle;
+    line-height: 1.3;
+  }
+  // caret-wrapper
+  .caret-wrapper {
+    width: 20px;
+    height: 20px;
+    top: -1px;
+    .sort-caret {
+      &.ascending {
+        top: -1px;
+      }
+      &.descending {
+        bottom: -1px;
+      }
+    }
+  }
+  // action-column
+  td.action-column {
+    padding-left: 10px;
+    padding-right: 10px;
+    .cell {
+      padding: 0;
+      margin: 0 -5px;
+    }
+    .el-button--text {
+      padding: 0;
+      margin: 0 5px;
+      border: none !important;
+      outline: none !important;
+      &:hover {
+        transform: scale(1.1);
+      }
+    }
+  }
+}
+.el-table--border {
+  border-radius: 10px;
+  th {
+    padding: 12px 0;
+    background-color: #fcfcfd;
+    border-right: none;
+  }
+  td {
+    border-right: none;
+  }
+}
+// el-checkbox
+.el-checkbox {
+  .el-checkbox__label {
+    color: $--color-text-gray-2 !important;
+  }
+  .el-checkbox__inner::after {
+    border-width: 2px;
+  }
+}
+.el-checkbox__input.is-checked .el-checkbox__inner {
+  background-color: $--color-white;
+  border-color: $--color-primary;
+
+  &::after {
+    border-color: $--color-primary;
+  }
+}
+.el-checkbox__input.is-indeterminate .el-checkbox__inner {
+  background-color: $--color-white;
+  border-color: $--color-primary;
+  &::before {
+    background-color: $--color-primary;
+  }
+}
+
+.el-radio {
+  .el-radio__label {
+    color: $--color-text-gray-2 !important;
+  }
+}
+.el-radio__input.is-checked .el-radio__inner {
+  background-color: $--color-white;
+  border-color: $--color-primary;
+  &::after {
+    width: 6px;
+    height: 6px;
+    background-color: $--color-primary;
+  }
+}
+
+// el-switch
+.el-switch {
+  &.is-checked {
+    .el-switch__core {
+      background-color: $--color-primary;
+      border-color: $--color-primary;
+    }
+  }
+}
+
+// el-pagination
+.el-pagination-li {
+  min-width: 32px;
+  height: 32px;
+  border-radius: 8px;
+  overflow: hidden;
+  background-color: $--color-white;
+  border: 1px solid #e1e3eb;
+}
+.el-pagination {
+  padding: 0;
+  .el-pagination__total {
+    float: left;
+  }
+  span:not([class*="suffix"]) {
+    line-height: 32px;
+    height: 32px;
+  }
+  &.is-background {
+    .btn-prev,
+    .btn-next {
+      color: $--color-text-gray-2;
+      margin: 0 5px;
+      @extend .el-pagination-li;
+    }
+    .btn-prev:disabled,
+    .btn-next:disabled {
+      opacity: 0.7;
+    }
+
+    .el-pager li {
+      color: $--color-text-gray-2;
+      margin: 0 5px;
+      padding: 0 8px;
+      line-height: 32px;
+
+      @extend .el-pagination-li;
+      &:not(.disabled).active {
+        color: #fff;
+        background-color: $--color-primary;
+      }
+    }
+  }
+}
+// el-message-box
+.el-message-box {
+  width: 320px;
+  background-color: #f6f6f6;
+  border-radius: 10px;
+  &__title {
+    display: none;
+  }
+  &__headerbtn {
+    display: none;
+  }
+  &__content {
+    text-align: center;
+
+    .el-message-box__status {
+      position: relative;
+      top: 0;
+      height: 48px;
+      width: 48px;
+      transform: none;
+      margin-bottom: 10px;
+
+      &.el-icon-warning {
+        border-radius: 50%;
+        &::before {
+          content: "";
+          position: absolute;
+          top: 0;
+          left: 0;
+          width: 100%;
+          height: 100%;
+          background-image: url(../images/icon-doubt.png);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+        }
+      }
+    }
+    .el-message-box__message {
+      padding: 0;
+    }
+  }
+  &__btns {
+    height: 75px;
+    padding: 30px 20px 10px;
+    text-align: center;
+
+    > .el-button {
+      width: 100px;
+    }
+  }
+}
+
+.alert-message {
+  .el-message-box__btns {
+    text-align: center;
+    > .el-button {
+      position: relative;
+      left: auto;
+      top: 0;
+      margin: 0;
+    }
+  }
+}
+// .el-message
+.el-message-loading {
+  border-color: mix($--color-white, $--color-success, 80%);
+  background-color: mix($--color-white, $--color-success, 90%);
+}
+// el-date-editor
+.el-date-editor {
+  border-radius: 8px;
+  .el-range-separator {
+    width: auto;
+  }
+  .el-range-input {
+    background-color: transparent;
+  }
+}
+
+// el-step
+.el-step {
+  &__title.is-success,
+  &__description.is-success,
+  &__title.is-process,
+  &__description.is-process {
+    color: $--color-success;
+  }
+  &__title.is-process {
+    font-weight: normal;
+  }
+  &__head.is-success {
+    .el-step__line {
+      background-color: $--color-success;
+    }
+    .el-step__icon.is-text {
+      color: $--color-white;
+      border-color: $--color-success;
+      background-color: $--color-success;
+    }
+  }
+  &__head.is-process {
+    .el-step__icon.is-text {
+      color: $--color-success;
+      border-color: $--color-success;
+    }
+  }
+
+  &__title.is-wait,
+  &__description.is-wait {
+    color: $--color-text-gray-2;
+  }
+  &__head.is-wait {
+    .el-step__icon.is-text {
+      color: $--color-text-gray-2;
+      border-color: #e1e3eb;
+      background-color: #e1e3eb;
+    }
+  }
+}
+// el-popover
+.el-popper-dark {
+  background-color: $--color-text-dark-1;
+  color: #fff;
+  font-size: 12px;
+  line-height: 18px;
+  padding: 16px;
+  border: none;
+}
+.el-popper-dark {
+  box-shadow: 0px 10px 10px 0px rgba(54, 61, 89, 0.2);
+}
+.el-popper-dark[x-placement^="right"] .popper__arrow {
+  border-right-color: $--color-text-dark-1;
+
+  &::after {
+    border-right-color: $--color-text-dark-1;
+  }
+}
+.el-popper-dark[x-placement^="top"] .popper__arrow {
+  border-top-color: $--color-text-dark-1;
+
+  &::after {
+    border-top-color: $--color-text-dark-1;
+  }
+}
+.el-popper-dark[x-placement^="bottom"] .popper__arrow {
+  border-bottom-color: $--color-text-dark-1;
+
+  &::after {
+    border-bottom-color: $--color-text-dark-1;
+  }
+}
+.el-popper-dark[x-placement^="left"] .popper__arrow {
+  border-left-color: $--color-text-dark-1;
+
+  &::after {
+    border-left-color: $--color-text-dark-1;
+  }
+}
+// popper-list
+.popper-list {
+  min-width: auto;
+
+  .el-button {
+    display: block;
+    width: 100%;
+    margin: 0;
+    &:not(:last-child) {
+      margin-bottom: 5px;
+    }
+  }
+}

+ 341 - 0
src/modules/card/assets/styles/home.scss

@@ -0,0 +1,341 @@
+/* home */
+.home {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  z-index: auto;
+}
+.home-body {
+  position: absolute;
+  left: 0;
+  top: 50px;
+  right: 0;
+  bottom: 0;
+  overflow: auto;
+  background: $--color-background;
+  z-index: 98;
+}
+.home-main {
+  position: relative;
+  padding: 20px 30px 50px 250px;
+  min-height: 100%;
+}
+
+/* navs */
+.home-navs {
+  position: absolute;
+  width: 220px;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  z-index: 100;
+  overflow: auto;
+  font-size: 14px;
+  background: $--color-white;
+  border-top-right-radius: $--border-radius-huge;
+  border-bottom-right-radius: $--border-radius-huge;
+
+  &::before {
+    content: "";
+    display: block;
+    position: absolute;
+    height: 100%;
+    width: 1px;
+    right: 0;
+    bottom: 0;
+    z-index: 9;
+    background: rgba(229, 229, 229, 1);
+  }
+
+  .head-logo {
+    padding: 0 40px;
+    font-size: 20px;
+    line-height: 40px;
+    text-align: center;
+    &-content {
+      display: block;
+      padding: 30px 0;
+      border-bottom: 1px solid #eff0f5;
+    }
+    img {
+      display: block;
+      max-width: 160px;
+      height: 40px;
+    }
+  }
+
+  .nav-part {
+    padding: 20px 0;
+    border-top: 1px solid $--color-border;
+  }
+
+  .nav-head {
+    padding: 10px 0;
+    color: $--color-text-gray-2;
+    font-size: $--font-size-base;
+    line-height: 20px;
+    position: relative;
+    font-weight: 500;
+    > span {
+      display: inline-block;
+      vertical-align: top;
+      font-weight: 600;
+    }
+    &-right-icon {
+      position: absolute;
+      right: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      color: #d3d5e0;
+      font-size: 12px;
+    }
+  }
+
+  // .nav-list {
+  //   padding: 0 0 0 23px;
+  // }
+  .nav-item {
+    overflow: hidden;
+    color: $--color-text-dark-1;
+    &-main {
+      padding: 10px 0;
+      line-height: 20px;
+      position: relative;
+      font-weight: 500;
+      cursor: pointer;
+      &-act,
+      &:hover {
+        font-weight: 600;
+        color: $--color-primary;
+      }
+    }
+    &-icon {
+      display: block;
+      position: absolute;
+      width: 20px;
+      height: 20px;
+      top: 50%;
+      margin-top: -10px;
+      text-align: center;
+      line-height: 20px;
+    }
+    &-icon-right {
+      right: 5px;
+    }
+    &-info {
+      display: block;
+      position: absolute;
+      padding: 0 3px;
+      min-width: 16px;
+      height: 16px;
+      font-size: 12px;
+      line-height: 16px;
+      top: 12px;
+      right: 40px;
+      background-color: $--color-warning;
+      color: #fff;
+      text-align: center;
+      border-radius: 3px;
+    }
+  }
+}
+.el-menu-home {
+  padding-top: 20px;
+  .el-submenu {
+    margin-bottom: 20px;
+  }
+  .el-submenu__title {
+    padding: 0 40px !important;
+    height: 50px;
+    line-height: 50px;
+    font-weight: 600;
+
+    > .icon {
+      margin-right: 12px;
+    }
+  }
+  .el-menu-item {
+    height: auto;
+    min-height: 40px;
+    line-height: 20px;
+    padding: 10px 40px !important;
+    white-space: normal;
+  }
+  .el-menu-item.is-active {
+    font-weight: 600;
+  }
+  .el-submenu__icon-arrow {
+    right: 40px;
+  }
+}
+
+/* head */
+.home-header {
+  position: absolute;
+  width: 100%;
+  height: 50px;
+  top: 0;
+  left: 0;
+  z-index: 99;
+  color: #fff;
+  padding-left: 220px;
+  background-color: $--color-text-dark;
+  overflow: hidden;
+
+  .menu-list {
+    li {
+      display: inline-block;
+      vertical-align: top;
+      padding: 10px 25px;
+      height: 50px;
+      line-height: 30px;
+      opacity: 0.4;
+      font-size: 16px;
+      position: relative;
+      text-align: center;
+      cursor: pointer;
+
+      &:hover {
+        opacity: 1;
+      }
+
+      &.menu-item-act {
+        opacity: 1;
+      }
+
+      span {
+        display: inline-block;
+        vertical-align: top;
+        margin-left: 8px;
+      }
+      .icon {
+        margin-top: -3px;
+      }
+    }
+  }
+  .head-menu {
+    float: left;
+  }
+  .head-user {
+    float: right;
+    padding-right: 10px;
+    li {
+      padding: 10px;
+    }
+    .menu-item-account {
+      white-space: nowrap;
+      padding: 10px;
+      span {
+        max-width: 156px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+    }
+  }
+  // .head-menu-btn
+  .head-menu-btn {
+    display: none;
+    float: right;
+    line-height: 36px;
+    padding: 12px 15px;
+    text-align: center;
+    > span {
+      display: block;
+      height: 36px;
+      width: 36px;
+      border-radius: 5px;
+      background-color: rgba($color: #fff, $alpha: 0.3);
+    }
+    i {
+      font-size: 22px;
+      vertical-align: middle;
+    }
+  }
+}
+// menu-dialog
+.menu-dialog {
+  .el-dialog.is-fullscreen {
+    border-radius: 0;
+    box-shadow: none;
+
+    .el-dialog__body {
+      padding: 10px;
+      &::after {
+        display: none;
+      }
+    }
+  }
+
+  .menu-logout {
+    padding: 10px;
+    width: 52px;
+    height: 52px;
+    margin: 0 auto;
+    border: 1px solid $--color-text-gray-3;
+    border-radius: 50%;
+    font-size: 30px;
+    text-align: center;
+    color: $--color-text-gray-3;
+    cursor: pointer;
+
+    &:hover {
+      border-color: $--color-danger;
+      color: $--color-danger;
+    }
+  }
+}
+
+// home-breadcrumb
+.home-breadcrumb {
+  margin-bottom: 18px;
+  font-size: 12px;
+  font-weight: 500;
+  color: $--color-text-gray-2;
+
+  .breadcrumb-tips {
+    display: inline-block;
+    vertical-align: middle;
+    > i {
+      margin-top: -2px;
+      margin-right: 8px;
+    }
+  }
+  .el-breadcrumb {
+    line-height: 16px;
+    display: inline-block;
+    vertical-align: middle;
+    font-size: 12px;
+
+    .el-breadcrumb__item {
+      .el-breadcrumb__inner {
+        color: $--color-text-gray-2;
+      }
+    }
+    .el-breadcrumb__separator {
+      margin: 0 5px;
+    }
+  }
+}
+
+// home-view
+
+/* view-footer */
+.home-footer {
+  position: absolute;
+  width: 100%;
+  height: 60px;
+  bottom: 0;
+  left: 0;
+  z-index: auto;
+  padding: 20px 0;
+  line-height: 20px;
+  color: $--color-text-gray-3;
+  text-align: center;
+  font-size: 13px;
+  a {
+    color: $--color-text-gray-3;
+  }
+  a:hover {
+    color: $--color-text-gray;
+  }
+}

+ 102 - 0
src/modules/card/assets/styles/icons.scss

@@ -0,0 +1,102 @@
+// icon
+.icon {
+  display: inline-block;
+  vertical-align: middle;
+  background-repeat: no-repeat;
+  background-size: 100% 100%;
+
+  &-checked {
+    width: 20px;
+    height: 20px;
+    background-image: url(../images/icon-checked.png);
+  }
+  &-doubt {
+    background-image: url(../images/icon-doubt.png);
+    width: 50px;
+    height: 50px;
+  }
+
+  &-help {
+    background-image: url(../images/icon-help.png);
+    width: 32px;
+    height: 32px;
+  }
+  &-back {
+    background-image: url(../images/icon-back.png);
+    width: 32px;
+    height: 32px;
+  }
+  &-two-gray {
+    background-image: url(../images/icon-two-gray.png);
+    width: 12px;
+    height: 10px;
+  }
+  &-two-white {
+    background-image: url(../images/icon-two-white.png);
+    width: 12px;
+    height: 10px;
+  }
+  &-three-gray {
+    background-image: url(../images/icon-three-gray.png);
+    width: 16px;
+    height: 10px;
+  }
+  &-three-white {
+    background-image: url(../images/icon-three-white.png);
+    width: 16px;
+    height: 10px;
+  }
+  &-four-gray {
+    background-image: url(../images/icon-four-gray.png);
+    width: 18px;
+    height: 10px;
+  }
+  &-four-white {
+    background-image: url(../images/icon-four-white.png);
+    width: 18px;
+    height: 10px;
+  }
+  // column
+  &-column {
+    &-one {
+      background-image: url(../images/icon-column-one.png);
+      width: 20px;
+      height: 20px;
+    }
+    &-one-act {
+      background-image: url(../images/icon-column-one-act.png);
+      width: 20px;
+      height: 20px;
+    }
+    &-two {
+      background-image: url(../images/icon-column-two.png);
+      width: 20px;
+      height: 20px;
+    }
+    &-two-act {
+      background-image: url(../images/icon-column-two-act.png);
+      width: 20px;
+      height: 20px;
+    }
+    &-three {
+      background-image: url(../images/icon-column-three.png);
+      width: 20px;
+      height: 20px;
+    }
+    &-three-act {
+      background-image: url(../images/icon-column-three-act.png);
+      width: 20px;
+      height: 20px;
+    }
+    &-four {
+      background-image: url(../images/icon-column-four.png);
+      width: 20px;
+      height: 20px;
+    }
+    &-four-act {
+      background-image: url(../images/icon-column-four-act.png);
+      width: 20px;
+      height: 20px;
+    }
+  }
+}

+ 10 - 0
src/modules/card/assets/styles/index.scss

@@ -0,0 +1,10 @@
+@import "./variables.scss";
+@import "./base.scss";
+@import "./icons.scss";
+@import "./home.scss";
+
+// card
+@import "./card-preview.scss";
+@import "./card-design.scss";
+// element-ui
+@import "./element-ui-costom.scss";

+ 5 - 0
src/modules/card/assets/styles/module.scss

@@ -0,0 +1,5 @@
+@import "./variables.scss";
+
+@import "./icons.scss";
+@import "./card-preview.scss";
+@import "./card-design.scss";

+ 42 - 0
src/modules/card/assets/styles/variables.scss

@@ -0,0 +1,42 @@
+// color ------------------->
+$--color-text-dark: #1f2230 !default;
+$--color-text-dark-1: #434656 !default;
+$--color-text-gray: #6f7482 !default;
+$--color-text-gray-1: #7a7c87 !default;
+$--color-text-gray-2: #8b8fa1 !default;
+$--color-text-gray-3: #aaa !default;
+$--color-text-gray-4: #ccc !default;
+$--color-text-gray-5: #d3d5e0 !default;
+$--color-text-gray-6: #e0e1eb !default;
+$--color-text-gray-7: #f2f4fa !default;
+$--color-border: #eff0f5;
+$--color-background: #eff0f5;
+// status
+$--color-primary: #3a5ae5 !default;
+$--color-primary-light: mix(#fff, $--color-primary, 20%) !default;
+$--color-success: #3fcb98 !default;
+$--color-success-light: #32cf8a !default;
+$--color-warning: #ff9427 !default;
+$--color-danger: #fe5d4e !default;
+$--color-cyan: #2abcff !default;
+$--color-cyan-light: #5fc9fa !default;
+$--color-blue: #556dff !default;
+$--color-blue-white: #4f79ff !default;
+$--color-blue-dark: #172666 !default;
+$--color-purple: #9877ff !default;
+$--color-white: #ffffff;
+$--color-dark: #1f2230;
+
+// shadow
+$--shadow-light: 0 0 1px rgba(0, 0, 0, 0.15) !default;
+
+// size ------------------->
+$--font-size-base: 14px !default;
+$--font-size-medium: 16px !default;
+$--font-size-large: 18px !default;
+$--border-radius: 8px;
+$--border-radius-large: 12px;
+$--border-radius-huge: 20px;
+// font-family
+$--font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
+  "Microsoft YaHei", Arial, sans-serif;

File diff suppressed because it is too large
+ 0 - 0
src/modules/card/card.temp.json


+ 137 - 0
src/modules/card/components/CardConfigPropEdit.vue

@@ -0,0 +1,137 @@
+<template>
+  <div class="card-config-prop-edit">
+    <el-button type="primary" @click="drawer = true"> 配置题卡信息 </el-button>
+    <el-drawer
+      title="配置题卡信息"
+      :visible.sync="drawer"
+      :with-header="false"
+      append-to-body
+    >
+      <div style="padding: 20px">
+        <el-form ref="form" :model="form" label-width="100px">
+          <el-form-item label="学校名称">
+            <el-input v-model="form.schoolName" @change="editChange">
+            </el-input>
+          </el-form-item>
+          <el-form-item label="考号类型">
+            <el-select
+              v-model="form.examNumberStyle"
+              placeholder="请选择学生考号类型"
+              @change="editChange"
+            >
+              <el-option
+                v-for="item in examNumberStyleOptions"
+                :key="item.value"
+                :label="item.label"
+                :value="item.value"
+              >
+              </el-option>
+            </el-select>
+          </el-form-item>
+          <el-form-item label="AB卷类型">
+            <el-select
+              v-model="form.aOrBType"
+              placeholder="请选择AB卷类型"
+              @change="editChange"
+            >
+              <el-option
+                v-for="item in aOrBTypeOptions"
+                :key="item.value"
+                :label="item.label"
+                :value="item.value"
+              >
+              </el-option>
+            </el-select>
+          </el-form-item>
+          <el-form-item>
+            <el-checkbox v-model="form.aOrB" @change="editChange"
+              >启用AB卷</el-checkbox
+            >
+          </el-form-item>
+          <el-form-item>
+            <el-checkbox v-model="form.examAbsent" @change="editChange"
+              >启用缺考和涂填提示</el-checkbox
+            >
+          </el-form-item>
+          <el-form-item>
+            <el-checkbox v-model="form.writeSign" @change="editChange"
+              >启用手写签名</el-checkbox
+            >
+          </el-form-item>
+          <el-form-item>
+            <el-checkbox v-model="form.showForbidArea" @change="editChange"
+              >启用禁填区</el-checkbox
+            >
+          </el-form-item>
+        </el-form>
+      </div>
+    </el-drawer>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations } from "vuex";
+import { objAssign } from "../plugins/utils";
+
+export default {
+  name: "CardConfigPropEdit",
+  data() {
+    return {
+      examNumberStyleOptions: [
+        {
+          label: "自动条码",
+          value: "auto",
+        },
+        {
+          label: "手动条码",
+          value: "empty",
+        },
+        {
+          label: "手动涂填",
+          value: "fill",
+        },
+      ],
+      aOrBTypeOptions: [
+        {
+          label: "自动条码",
+          value: "auto",
+        },
+        {
+          label: "手动涂填",
+          value: "fill",
+        },
+      ],
+      drawer: false,
+      form: {
+        schoolName: "河南财经政法大学",
+        examNumberStyle: "fill",
+        aOrBType: "auto",
+        aOrB: true,
+        examAbsent: true,
+        writeSign: true,
+        showForbidArea: true,
+      },
+    };
+  },
+  computed: {
+    ...mapState("card", ["cardConfig"]),
+  },
+  watch: {
+    cardConfig(val) {
+      this.form = objAssign(this.form, val);
+    },
+  },
+  methods: {
+    ...mapMutations("card", ["setCardConfig"]),
+    editChange() {
+      this.setCardConfig({ ...this.form });
+    },
+  },
+};
+</script>
+
+<style lang="scss">
+.card-config-prop-edit {
+  display: inline-block;
+}
+</style>

+ 524 - 0
src/modules/card/components/CardDesign.vue

@@ -0,0 +1,524 @@
+<template>
+  <div class="card-design">
+    <div class="design-header">
+      <div class="design-steps">
+        <div v-for="(step, index) in steps" :key="index" class="step-item">
+          <i>{{ index + 1 }}</i>
+          <span>{{ step }}</span>
+        </div>
+      </div>
+    </div>
+
+    <!-- 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
+              v-for="(item, index) in TOPIC_LIST"
+              :key="index"
+              class="type-item"
+            >
+              <el-button @click="addNewTopic(item)"
+                ><i class="el-icon-plus"></i>{{ item.name }}</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>
+            <p class="tips-info">提示:拖动插入元素</p>
+          </div>
+          <!-- Develop btns -->
+          <!-- <card-config-prop-edit></card-config-prop-edit> -->
+        </div>
+        <!-- <br /><br /> -->
+        <!-- <el-button @click="initCard">新建页面</el-button> -->
+      </div>
+      <!-- <div class="action-part">
+          <div class="action-part-title"><h2>阅卷参数</h2></div>
+          <div class="action-part-body">
+            <el-button type="primary" @click="modifyParams"
+              >上传阅卷参数<span class="color-danger"
+                >({{ paperParams["pageSumScore"] || 0 }}分)</span
+              ></el-button
+            >
+          </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 + 1 }}页</el-button
+          >
+        </div>
+        <div class="control-right">
+          <el-button
+            type="success"
+            :loading="isSubmit"
+            :disabled="!pages.length"
+            @click="toPreview"
+            >预览</el-button
+          >
+          <el-button
+            v-if="showSaveBtn"
+            type="primary"
+            :loading="isSubmit"
+            :disabled="canSave || !pages.length"
+            @click="toSave"
+            >暂存</el-button
+          >
+          <el-button type="primary" :loading="isSubmit" @click="toSubmit"
+            >提交</el-button
+          >
+        </div>
+      </div>
+
+      <!-- edit body -->
+      <div class="design-body">
+        <div
+          v-if="curPage.locators"
+          :class="[
+            'page-box',
+            `page-box-${cardConfig.pageSize}`,
+            `page-box-${curPageNo % 2}`,
+          ]"
+        >
+          <div
+            :class="[
+              'page-locators',
+              `page-locators-${curPage.locators.length}`,
+            ]"
+          >
+            <ul
+              v-for="(locator, iind) in curPage.locators"
+              :key="iind"
+              class="page-locator-group"
+            >
+              <li
+                v-for="(elem, eindex) in locator"
+                :id="elem.id"
+                :key="eindex"
+              ></li>
+            </ul>
+          </div>
+          <!-- 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 v-if="column.elements.length" 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 v-else class="page-column-body">
+                    <div
+                      v-if="cardConfig.showForbidArea"
+                      class="page-column-forbid-area"
+                    >
+                      <p>该区域严禁作答</p>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <!-- outer edit area -->
+          <div class="page-main-outer">
+            <page-number
+              type="rect"
+              :total="pages.length"
+              :current="curPageNo + 1"
+            ></page-number>
+            <page-number
+              type="text"
+              :total="pages.length"
+              :current="curPageNo + 1"
+            ></page-number>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- all topics -->
+    <div class="topic-list">
+      <div :class="['page-box', `page-box-${cardConfig.pageSize}`]">
+        <div class="page-main-inner">
+          <div
+            :class="['page-main', `page-main-${cardConfig.columnNumber}`]"
+            :style="{ margin: `0 -${cardConfig.columnGap / 2}px` }"
+          >
+            <div
+              class="page-column"
+              :style="{ padding: `0 ${cardConfig.columnGap / 2}px` }"
+            >
+              <div id="topic-column" class="page-column-main">
+                <div class="page-column-body">
+                  <!-- card-head-sample -->
+                  <card-head-sample
+                    v-if="topics.length && cardHeadSampleData"
+                    id="simple-card-head"
+                    :data="cardHeadSampleData"
+                  ></card-head-sample>
+                  <!-- 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 @inset-topic="insetNewTopic"></right-click-menu>
+    <!-- paper-params -->
+    <paper-params
+      ref="PaperParams"
+      :pages="pages"
+      :paper-params="paperParams"
+      @confirm="paperParamsModified"
+    ></paper-params>
+    <!-- topic select dialog -->
+    <topic-select-dialog
+      ref="TopicSelectDialog"
+      :topics="topicList"
+      @confirm="addNewTopic"
+    ></topic-select-dialog>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import {
+  getElementModel,
+  getCardHeadModel,
+  ELEMENT_LIST,
+  TOPIC_LIST,
+} from "../elementModel";
+import { CARD_VERSION } from "../enumerate";
+// import CardConfigPropEdit from "../components/CardConfigPropEdit";
+import TopicElementEdit from "../components/TopicElementEdit";
+import TopicElementPreview from "../components/TopicElementPreview";
+import PagePropEdit from "../components/PagePropEdit";
+import ElementPropEdit from "../components/ElementPropEdit";
+import RightClickMenu from "../components/RightClickMenu";
+import PageNumber from "../components/PageNumber";
+import PaperParams from "../components/PaperParams";
+import CardHeadSample from "../elements/card-head/CardHead";
+import TopicSelectDialog from "../components/TopicSelectDialog";
+
+export default {
+  name: "CardDesign",
+  components: {
+    // CardConfigPropEdit,
+    TopicElementEdit,
+    TopicElementPreview,
+    PagePropEdit,
+    ElementPropEdit,
+    RightClickMenu,
+    CardHeadSample,
+    PageNumber,
+    PaperParams,
+    TopicSelectDialog,
+  },
+  props: {
+    content: {
+      type: Object,
+      default() {
+        return {
+          pages: [],
+          cardConfig: {},
+          paperParams: {},
+        };
+      },
+    },
+    showSaveBtn: {
+      type: Boolean,
+      default: true,
+    },
+  },
+  data() {
+    return {
+      ELEMENT_LIST,
+      TOPIC_LIST,
+      topicList: [],
+      steps: ["添加标题", "基本设置", "试题配置", "预览生成"],
+      columnWidth: 0,
+      isSubmit: false,
+      canSave: false,
+    };
+  },
+  computed: {
+    ...mapState("card", [
+      "cardConfig",
+      "topics",
+      "pages",
+      "paperParams",
+      "curElement",
+      "curPage",
+      "curPageNo",
+    ]),
+    cardHeadSampleData() {
+      if (!this.cardConfig["pageSize"]) return;
+      const data = getCardHeadModel(this.cardConfig);
+      data.isSimple = true;
+      return data;
+    },
+  },
+  mounted() {
+    this.initCard();
+  },
+  beforeDestroy() {
+    this.initState();
+  },
+  methods: {
+    ...mapMutations("card", [
+      "addPage",
+      "setCurPage",
+      "setCurElement",
+      "setCardConfig",
+      "setOpenElementEditDialog",
+      "setCurDragElement",
+      "setPages",
+      "setPaperParams",
+      "setInsetTarget",
+      "initState",
+    ]),
+    ...mapActions("card", [
+      "resetTopicSeries",
+      "removePage",
+      "addElement",
+      "modifyCardHead",
+      "modifyElement",
+      "rebuildPages",
+      "initTopicsFromPages",
+    ]),
+    async initCard() {
+      const { cardConfig, pages, paperParams } = this.content;
+      this.setCardConfig(cardConfig);
+      this.setPaperParams(paperParams);
+
+      if (pages && pages.length) {
+        this.setPages(pages);
+        this.initTopicsFromPages();
+        this.resetTopicSeries();
+        this.setCurPage(0);
+      } else {
+        this.initPageData();
+      }
+      this.addWatch();
+    },
+    initPageData() {
+      this.modifyCardHead({
+        ...getCardHeadModel(this.cardConfig),
+      });
+      this.$nextTick(() => {
+        this.rebuildPages();
+        this.setCurPage(0);
+      });
+    },
+    addNewTopic(item) {
+      let element = getElementModel(item.type);
+      element.w = document.getElementById("topic-column").offsetWidth;
+
+      this.setCurElement(element);
+      this.$refs.ElementPropEdit.open();
+      // to elementPropEdit/ElementPropEdit open topic edit dialog
+    },
+    insetNewTopic({ id, type }) {
+      console.log(id, type);
+      this.setInsetTarget({ id, type });
+      if (type === "FILL_QUESTION") {
+        this.topicList = this.TOPIC_LIST;
+      } else {
+        this.topicList = this.TOPIC_LIST.filter(
+          (item) => item.type !== "FILL_QUESTION"
+        );
+      }
+      this.$refs.TopicSelectDialog.open();
+    },
+    // 元件编辑
+    dragstart(element) {
+      this.setCurDragElement(getElementModel(element.type));
+    },
+    addWatch() {
+      this.$watch("cardConfig", (val) => {
+        const element = getCardHeadModel(val);
+        this.modifyCardHead(element);
+        this.$nextTick(() => {
+          this.rebuildPages();
+        });
+      });
+    },
+    swithPage(pindex) {
+      if (this.curPageNo === pindex) return;
+      this.setCurPage(pindex);
+      this.setCurElement({});
+    },
+    // paper-params
+    modifyParams() {
+      this.$refs.PaperParams.open();
+    },
+    paperParamsModified(paperParams) {
+      this.setPaperParams(paperParams);
+    },
+    // save
+    getCardData(htmlContent = "", model = "") {
+      const data = {
+        title: this.cardConfig.cardTitle,
+        content: model,
+        htmlContent,
+      };
+      return data;
+    },
+    checkElementCovered() {
+      let elements = [];
+      this.pages.forEach((page) => {
+        page.columns.forEach((column) => {
+          column.elements.forEach((element) => {
+            if (element.isCovered) {
+              elements.push(element.id);
+            }
+          });
+        });
+      });
+      return elements.length;
+    },
+    checkCardValid() {
+      if (!this.cardConfig.cardTitle) {
+        this.$message.error("题卡标题不能为空!");
+        this.setCurPageNo(0);
+        setTimeout(() => {
+          document.getElementById("cardTitleInput").focus();
+        });
+        return false;
+      }
+      // if (!this.cardConfig.cardDesc) {
+      //   this.$message.error("题卡描述信息不能为空!");
+      //   this.setCurPage(0);
+      //   setTimeout(() => {
+      //     document.getElementById("cardDescInput").focus();
+      //   });
+      //   return false;
+      // }
+      if (this.checkElementCovered()) {
+        this.$message.error("题卡中存在被遮挡的元件,请注意调整!");
+        return false;
+      }
+
+      return true;
+    },
+    getCardJson() {
+      // 防止页面未渲染完成,各试题高度未及时更新,保存数据有误的问题
+      return new Promise((resolve) => {
+        setTimeout(() => {
+          const data = JSON.stringify(
+            {
+              version: CARD_VERSION,
+              cardType: "STANDARD",
+              cardConfig: this.cardConfig,
+              paperParams: this.paperParams,
+              pages: this.pages,
+            },
+            (k, v) => (k.startsWith("_") ? undefined : v)
+          );
+          resolve(data);
+        }, 100);
+      });
+    },
+    async toPreview() {
+      if (this.isSubmit) return;
+      this.isSubmit = true;
+      const model = await this.getCardJson();
+      const datas = this.getCardData("", model);
+      this.$emit("on-preview", datas);
+    },
+    async toSave() {
+      if (!this.checkCardValid()) return;
+
+      if (this.isSubmit) return;
+      this.isSubmit = true;
+      const model = await this.getCardJson();
+      const datas = this.getCardData("", model);
+      this.$emit("on-save", datas);
+    },
+    toSubmit() {
+      if (this.isSubmit) return;
+      if (!this.checkCardValid()) return;
+
+      this.$emit("on-submit", {
+        cardConfig: this.cardConfig,
+        pages: this.pages,
+        paperParams: this.paperParams,
+      });
+    },
+    toExit() {
+      this.$emit("on-exit");
+    },
+    loading() {
+      this.isSubmit = true;
+    },
+    unloading() {
+      this.isSubmit = false;
+    },
+  },
+};
+</script>

+ 111 - 0
src/modules/card/components/CardView.vue

@@ -0,0 +1,111 @@
+<template>
+  <div class="card-view">
+    <template v-for="(page, pageNo) in pages">
+      <div
+        :key="pageNo"
+        :class="[
+          'page-box',
+          `page-box-${cardConfig.pageSize}`,
+          `page-box-${pageNo % 2}`,
+        ]"
+      >
+        <div
+          :class="['page-locators', `page-locators-${page.locators.length}`]"
+        >
+          <ul
+            v-for="(locator, iind) in page.locators"
+            :key="iind"
+            class="page-locator-group"
+          >
+            <li
+              v-for="(elem, eindex) in locator"
+              :id="elem.id"
+              :key="eindex"
+            ></li>
+          </ul>
+        </div>
+        <!-- inner edit area -->
+        <div class="page-main-inner">
+          <div
+            :class="['page-main', `page-main-${page.columns.length}`]"
+            :style="{ margin: `0 -${page.columnGap / 2}px` }"
+          >
+            <div
+              v-for="(column, columnNo) in page.columns"
+              :key="columnNo"
+              class="page-column"
+              :style="{ padding: `0 ${page.columnGap / 2}px` }"
+            >
+              <div
+                :id="[`column-${pageNo}-${columnNo}`]"
+                class="page-column-main"
+              >
+                <div v-if="column.elements.length" class="page-column-body">
+                  <topic-element-preview
+                    v-for="element in column.elements"
+                    :key="element.id"
+                    class="page-column-element"
+                    :data="element"
+                  ></topic-element-preview>
+                </div>
+                <div v-else class="page-column-body">
+                  <div
+                    v-if="cardConfig.showForbidArea"
+                    class="page-column-forbid-area"
+                  >
+                    <p>该区域严禁作答</p>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <!-- outer edit area -->
+        <div class="page-main-outer">
+          <page-number
+            type="rect"
+            :total="pages.length"
+            :current="pageNo + 1"
+          ></page-number>
+          <page-number
+            type="text"
+            :total="pages.length"
+            :current="pageNo + 1"
+          ></page-number>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+import TopicElementPreview from "./TopicElementPreview";
+import PageNumber from "./PageNumber";
+import previewTemp from "../previewTemp";
+import exchangeMixins from "../mixins/exchange";
+
+export default {
+  name: "CardView",
+  components: { TopicElementPreview, PageNumber },
+  mixins: [exchangeMixins],
+  props: {
+    pages: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+    cardConfig: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  methods: {
+    getPreviewTemp(htmlContent) {
+      return previewTemp(htmlContent);
+    },
+  },
+};
+</script>

+ 114 - 0
src/modules/card/components/ElementPropEdit.vue

@@ -0,0 +1,114 @@
+<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
+  >
+    <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) {
+      // 编辑试题
+      // 属性存在的条件:parent:大题的小题,container:题目内的子元素
+      if (this.curElement["_edit"]) {
+        if (element["container"]) {
+          this.modifyElementChild(element);
+        } else {
+          this.modifyElement(element);
+        }
+      } else {
+        this.addElement(element);
+      }
+      this.cancel();
+      this.$nextTick(() => {
+        this.rebuildPages();
+      });
+    },
+  },
+};
+</script>

+ 42 - 0
src/modules/card/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>

+ 192 - 0
src/modules/card/components/PagePropEdit.vue

@@ -0,0 +1,192 @@
+<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="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 label-width="0px">
+        <el-checkbox v-model="form.aOrB" disabled>启用A/B卷</el-checkbox>
+      </el-form-item> -->
+      <el-form-item label="禁答区域">
+        <el-checkbox
+          v-model="form.showForbidArea"
+          @change="showForbidAreaChange"
+          >启用</el-checkbox
+        >
+      </el-form-item>
+      <el-form-item label="大题数">
+        <ul v-if="topicSeries.length" class="topicno-list">
+          <li>{{ topicSeries.length }}</li>
+        </ul>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { objAssign } from "../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"],
+      // pageSizeOptions: ["A3", "A4"],
+      form: {
+        pageSize: "A3",
+        columnNumber: 2,
+        columnGap: 4,
+        aOrB: false,
+        showForbidArea: false,
+      },
+      prePageSize: "A3",
+    };
+  },
+  computed: {
+    ...mapState("card", ["curPageNo", "pages", "cardConfig", "topicSeries"]),
+    curPage() {
+      return this.pages[this.curPageNo];
+    },
+    // aOrBDisabled() {
+    //   return this.cardConfig.hasOwnProperty("aOrBSystem");
+    // }
+  },
+  watch: {
+    cardConfig: {
+      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)
+        );
+        if (this.form.pageSize === "A3") {
+          this.columnOptions[2].disabled = val.examNumberStyle === "fill";
+        }
+      },
+    },
+  },
+  methods: {
+    ...mapMutations("card", [
+      "setPages",
+      "setTopics",
+      "setCurElement",
+      "setCardConfig",
+    ]),
+    ...mapActions("card", ["rebuildPages", "resetElementProp"]),
+    modifyColumnNum(item) {
+      this.$confirm(
+        "此操作会导致当前题卡编辑的所有元素清空, 是否继续?",
+        "提示",
+        {
+          type: "warning",
+        }
+      )
+        .then(() => {
+          this.columnNumChange(item.value);
+        })
+        .catch(() => {});
+    },
+    columnNumChange(val) {
+      this.form.columnNumber = val;
+      this.setCardConfig(this.form);
+      this.setPages([]);
+      this.setTopics([]);
+      this.$emit("init-page");
+    },
+    showForbidAreaChange() {
+      this.setCardConfig(this.form);
+    },
+    configChange() {
+      this.setCardConfig(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.configChange();
+        })
+        .catch(() => {
+          this.form.pageSize = this.prePageSize;
+        });
+    },
+  },
+};
+</script>

+ 358 - 0
src/modules/card/components/PaperParams.vue

@@ -0,0 +1,358 @@
+<template>
+  <el-dialog
+    class="paper-params"
+    :visible.sync="modalIsShow"
+    top="10px"
+    width="785px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="dialogOpen"
+  >
+    <h1 slot="title" class="params-dialog-title">
+      请上传阅卷参数
+      <span
+        >卷面总分合计:<em class="param-sum-score">{{ pageSumScore }}分</em>
+      </span>
+    </h1>
+    <div class="params-main">
+      <div class="params-head">
+        <el-button
+          :type="!topicType ? 'primary' : 'default'"
+          @click="selectType(0)"
+          >客观题区</el-button
+        >
+        <el-button
+          :type="topicType ? 'primary' : 'default'"
+          @click="selectType(1)"
+          >主观题区</el-button
+        >
+      </div>
+      <div class="params-body">
+        <div style="text-align: right">
+          <upload-button
+            v-if="topicType"
+            btn-icon="el-icon-upload"
+            btn-content="选择上传主观题标答文件(doc/docx)"
+            btn-type="warning"
+            :upload-url="uploadUrl"
+            :format="['doc', 'docx']"
+            style="margin: 0"
+            @valid-error="validError"
+            @upload-success="uploadSuccess"
+          >
+          </upload-button>
+        </div>
+        <div v-for="topic in curTopicList" :key="topic.id" class="params-part">
+          <h2 class="params-title">{{ topic.topicName }}</h2>
+          <p class="params-subtitle">
+            <span>题型:{{ topic.name }}</span>
+            <span v-if="topic.type !== 'COMPOSITION'">
+              通配小题分值:
+              <el-input-number
+                v-model="topic.commonQuestionScore"
+                size="small"
+                :min="0.1"
+                :max="200"
+                :step="0.1"
+                step-strictly
+                :controls="false"
+                @change="commonQuestionScoreChange(topic)"
+              ></el-input-number
+              >分,</span
+            >
+            <span
+              >本大题总分合计:<em class="param-sum-score"
+                >{{ topic.sumScore }}分</em
+              ></span
+            >
+          </p>
+          <div v-if="topic.type !== 'COMPOSITION'" class="params-table">
+            <table class="table table-striped">
+              <tr>
+                <th>题号</th>
+                <th>分值</th>
+                <th>题号</th>
+                <th>分值</th>
+              </tr>
+              <tr v-for="(group, gindex) in topic.questions" :key="gindex">
+                <template v-for="question in group">
+                  <td :key="`${question.questionNo}-1`">
+                    <span>{{ question.questionNo }}</span>
+                  </td>
+                  <td :key="`${question.questionNo}-2`">
+                    <el-input-number
+                      v-if="question.questionNo"
+                      v-model="question.score"
+                      size="small"
+                      :min="0.1"
+                      :max="200"
+                      :step="0.1"
+                      step-strictly
+                      :controls="false"
+                      @change="questionScoreChange(topic)"
+                    ></el-input-number>
+                  </td>
+                </template>
+              </tr>
+            </table>
+          </div>
+          <div v-else class="params-table">
+            <el-input-number
+              v-model="topic.sumScore"
+              :min="0.1"
+              :max="200"
+              :step="0.1"
+              step-strictly
+              :controls="false"
+              @change="getPageSumScore"
+            ></el-input-number>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div slot="footer" style="text-align: right">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button type="danger" plain @click="cancel">取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { calcSum, isEmptyObject, objAssign } from "../plugins/utils";
+import UploadButton from "./UploadButton";
+
+export default {
+  name: "PaperParams",
+  components: { UploadButton },
+  props: {
+    pages: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+    paperParams: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      modalIsShow: false,
+      topicType: 0,
+      pageSumScore: 0,
+      subjectiveAttachmentId: "",
+      curTopicList: [],
+      objectives: [],
+      subjectives: [],
+      cacheScores: {},
+      initTopic: {
+        id: "",
+        topicNo: "",
+        topicName: "",
+        type: "",
+      },
+      // import
+      uploadUrl: "/api/print/basic/sys/saveAttachment",
+    };
+  },
+  methods: {
+    selectType(topicType) {
+      this.topicType = topicType;
+      this.curTopicList = topicType ? this.subjectives : this.objectives;
+    },
+    listIncludeItem(list, item) {
+      const index = list.findIndex((elem) => elem.id === item.id);
+      return index !== -1;
+    },
+    dialogOpen() {
+      if (!isEmptyObject(this.paperParams)) {
+        this.getCacheScore([
+          ...this.paperParams.objectives,
+          ...this.paperParams.subjectives,
+        ]);
+        this.subjectiveAttachmentId = this.paperParams.subjectiveAttachmentId;
+      }
+      // 每次打开时,重新生成试题结构,以便避免题卡结构变化之后,当前页面的试题结构没有变的问题
+      let objectiveList = [];
+      let subjectiveList = [];
+      let objectives = [];
+      let subjectives = [];
+      this.pages.forEach((page) => {
+        page.columns.forEach((column) => {
+          column.elements.forEach((element) => {
+            if (
+              element.sign &&
+              element.type !== "TOPIC_HEAD" &&
+              element.type !== "CARD_HEAD"
+            ) {
+              if (element.sign === "objective") objectiveList.push(element);
+              if (element.sign === "subjective") subjectiveList.push(element);
+            }
+          });
+        });
+      });
+      // 客观题
+      objectiveList.forEach((item) => {
+        const topic = item.parent || item;
+        if (this.listIncludeItem(objectives, topic)) return;
+        let data = {
+          sumScore: 0,
+          commonQuestionScore: 1,
+          name: this.getObjectiveTopicName(topic),
+          choiceList: this.getChoiceList(topic),
+          questions: this.getQuestions(topic),
+          ...objAssign(this.initTopic, topic),
+        };
+        data.sumScore = this.getTopicSumScore(data);
+        objectives.push(data);
+      });
+
+      // 主观题
+      subjectiveList.forEach((item) => {
+        const topic = item.parent || item;
+        if (this.listIncludeItem(subjectives, topic)) return;
+        let data = {
+          sumScore: 0,
+          commonQuestionScore: 1,
+          name: this.getSubjectiveTopicName(topic),
+          ...objAssign(this.initTopic, topic),
+        };
+        if (topic.type === "COMPOSITION") {
+          data.sumScore = this.cacheScores[topic.topicNo] || 1;
+        } else {
+          data.questions = this.getQuestions(topic);
+          data.sumScore = this.getTopicSumScore(data);
+        }
+        subjectives.push(data);
+      });
+
+      this.subjectives = subjectives;
+      this.objectives = objectives;
+      this.getPageSumScore();
+      this.selectType(0);
+    },
+    getQuestions(topic) {
+      let questions = [];
+      let numPerColumn = Math.ceil(topic.questionsCount / 2);
+      for (let j = topic.startNumber; j <= numPerColumn; j++) {
+        let group = [];
+        let question = {
+          questionNo: j,
+          score: this.cacheScores[`${topic.topicNo}-${j}`] || 1,
+        };
+        if (topic.type === "FILL_QUESTION")
+          question.answers = topic.isMultiply ? [] : "";
+        group[0] = question;
+
+        if (numPerColumn + j <= topic.questionsCount) {
+          group[1] = {
+            questionNo: numPerColumn + j,
+            score:
+              this.cacheScores[`${topic.topicNo}-${numPerColumn + j}`] || 1,
+            answers:
+              topic.type === "FILL_QUESTION" && topic.isMultiply ? [] : "",
+          };
+        } else {
+          group[1] = { questionNo: "" };
+        }
+
+        questions.push(group);
+      }
+
+      return questions;
+    },
+    getObjectiveTopicName(data) {
+      if (data.isMultiply) {
+        return "选择题(多选)";
+      } else if (data.isBoolean) {
+        return "填空题";
+      } else {
+        return "选择题(单选)";
+      }
+    },
+    getSubjectiveTopicName(data) {
+      const names = {
+        EXPLAIN: "解答题",
+        COMPOSITION: "作文题",
+        FILL_LINE: "填空题",
+      };
+      return names[data.type];
+    },
+    getChoiceList(data) {
+      const options = data.isBoolean
+        ? data.booleanType
+        : "abcdefghijklmnopqrstuv";
+      return options.toUpperCase().slice(0, data.optionCount).split("");
+    },
+    commonQuestionScoreChange(topic) {
+      topic.questions.map((group) => {
+        group.map((question) => {
+          question.score = topic.commonQuestionScore;
+        });
+      });
+      this.questionScoreChange(topic);
+    },
+    questionScoreChange(topic) {
+      topic.sumScore = this.getTopicSumScore(topic);
+      this.getPageSumScore();
+    },
+    getTopicSumScore(topic) {
+      const scoreList = topic.questions.map((group) => {
+        return calcSum(group.map((question) => question.score || 0));
+      });
+      return calcSum(scoreList);
+    },
+    getPageSumScore() {
+      this.pageSumScore =
+        calcSum(this.objectives.map((item) => item.sumScore)) +
+        calcSum(this.subjectives.map((item) => item.sumScore));
+    },
+    getCacheScore(topics) {
+      topics = topics || [...this.objectives, ...this.subjectives];
+      let cacheScores = {};
+      topics.map((topic) => {
+        if (topic.type === "COMPOSITION") {
+          cacheScores[`${topic.topicNo}`] = topic.sumScore;
+        } else {
+          topic.questions.map((group) => {
+            group.map((question) => {
+              cacheScores[`${topic.topicNo}-${question.questionNo}`] =
+                question.score;
+            });
+          });
+        }
+      });
+
+      this.cacheScores = cacheScores;
+    },
+    // import
+    validError(errorData) {
+      this.$message.error(errorData.message);
+    },
+    uploadSuccess(data) {
+      this.$message.success("上传成功!");
+      this.subjectiveAttachmentId = data.id;
+    },
+    // dialog
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    submit() {
+      this.$emit("confirm", {
+        pageSumScore: this.pageSumScore,
+        objectives: this.objectives,
+        subjectives: this.subjectives,
+        subjectiveAttachmentId: this.subjectiveAttachmentId,
+      });
+      this.modalIsShow = false;
+    },
+  },
+};
+</script>

+ 309 - 0
src/modules/card/components/RightClickMenu.vue

@@ -0,0 +1,309 @@
+<template>
+  <div class="right-click-menu">
+    <div
+      v-if="visible"
+      ref="RightMenuBody"
+      v-clickoutside="close"
+      class="right-menu-body"
+      :style="styles"
+    >
+      <ul>
+        <li @click="toEdit">
+          <i class="el-icon-edit-outline"></i>
+          {{ IS_CONTAINER_ELEMENT ? "编辑元素" : "编辑大题" }}
+        </li>
+        <li class="li-danger" @click="toDelete">
+          <i class="el-icon-delete"></i>
+          {{ IS_CONTAINER_ELEMENT ? "删除元素" : "删除大题" }}
+        </li>
+        <li
+          v-if="IS_CONTAINER_ELEMENT && (IS_EXPLAIN || IS_COMPOSITION)"
+          @click="toCopyExplainElement"
+        >
+          <i class="el-icon-copy-document"></i> 复制元素
+        </li>
+        <li
+          v-if="(IS_EXPLAIN || IS_COMPOSITION) && curCopyElement"
+          @click="toPasteExplainElement"
+        >
+          <i class="el-icon-document-copy"></i> 粘贴元素
+        </li>
+        <template v-if="IS_EXPLAIN || IS_COMPOSITION">
+          <li @click="toCopyChildren">
+            <i class="el-icon-circle-plus-outline"></i> 新增答题区
+          </li>
+          <li
+            v-if="showDeleteChildBtn"
+            class="li-danger"
+            @click="toDeleteChildren"
+          >
+            <i class="el-icon-delete"></i> 删除答题区
+          </li>
+        </template>
+        <li v-if="CAN_MOVE_UP" @click="toMoveUpTopic">
+          <i class="el-icon-upload2"></i> 上移大题
+        </li>
+        <li v-if="CAN_MOVE_DOWN" @click="toMoveDownTopic">
+          <i class="el-icon-download"></i> 下移大题
+        </li>
+        <li v-if="!IS_CONTAINER_ELEMENT" @click="toInsetTopic">
+          <i class="el-icon-add-location"></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,
+      showDeleteChildBtn: false,
+      curCopyElement: null,
+      styles: {
+        position: "fixed",
+        zIndex: 3000,
+      },
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement", "topics", "topicSeries"]),
+    IS_CONTAINER_ELEMENT() {
+      return !!this.curElement.container;
+    },
+    IS_EXPLAIN() {
+      return (
+        this.curElement.type === "EXPLAIN" ||
+        (this.curElement.container &&
+          this.curElement.container.type === "EXPLAIN")
+      );
+    },
+    IS_COMPOSITION() {
+      return (
+        this.curElement.type === "COMPOSITION" ||
+        (this.curElement.container &&
+          this.curElement.container.type === "COMPOSITION")
+      );
+    },
+    CAN_MOVE_UP() {
+      if (this.IS_CONTAINER_ELEMENT) return false;
+
+      const curTopicPos = this.topicSeries.findIndex(
+        (item) => item.id === this.curElement.parent.id
+      );
+
+      return (
+        curTopicPos &&
+        this.topicSeries[curTopicPos - 1].sign ===
+          this.topicSeries[curTopicPos].sign
+      );
+    },
+    CAN_MOVE_DOWN() {
+      if (this.IS_CONTAINER_ELEMENT) return false;
+
+      const curTopicPos = this.topicSeries.findIndex(
+        (item) => item.id === this.curElement.parent.id
+      );
+
+      return (
+        curTopicPos !== this.topicSeries.length - 1 &&
+        this.topicSeries[curTopicPos + 1].sign ===
+          this.topicSeries[curTopicPos].sign
+      );
+    },
+  },
+  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 = 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"]) {
+        this.removeElementChild(this.curElement);
+      } else {
+        this.removeElement(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>

+ 132 - 0
src/modules/card/components/TopicElementEdit.vue

@@ -0,0 +1,132 @@
+<template>
+  <div class="topic-element-edit">
+    <div
+      v-if="data.type === 'CARD_HEAD' || data.type === 'TOPIC_HEAD'"
+      :id="data.id"
+      :class="classes"
+      :data-type="data.type"
+    >
+      <!-- card-head 和 topic-head不需要调整高度 -->
+      <component :is="compName" :data="data"></component>
+    </div>
+    <element-resize
+      v-else
+      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="resizeOver"
+      @change="sizeChange"
+    >
+      <div :id="data.id" :class="classes" :data-type="data.type">
+        <component :is="compName" :data="data"></component>
+      </div>
+      <!-- topic-number -->
+      <topic-number
+        :data="data.topicNo"
+        title="点击选中当前题目"
+        @click="activeCurElement"
+      ></topic-number>
+    </element-resize>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { objAssign } from "../plugins/utils";
+import { checkElementisCovered } from "../store/card";
+
+import EditCardHead from "../elements/card-head/CardHead";
+import EditFillQuestion from "../elements/fill-question/ElemFillQuestion";
+import EditFillLine from "../elements/fill-line/ElemFillLine";
+import EditExplain from "../elements/explain/ElemExplainEdit";
+import EditComposition from "../elements/composition/ElemCompositionEdit";
+import EditTopicHead from "../elements/topic-head/TopicHead";
+import ElementResize from "./common/ElementResize";
+import TopicNumber from "./common/TopicNumber";
+
+export default {
+  name: "TopicElementEdit",
+  components: {
+    EditCardHead,
+    EditTopicHead,
+    EditFillQuestion,
+    EditFillLine,
+    EditExplain,
+    EditComposition,
+    ElementResize,
+    TopicNumber,
+  },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      elemData: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0,
+        init: 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}`,
+        this.data["isLast"]
+          ? `element-item-type-last`
+          : "element-item-type-pre",
+        {
+          "element-item-error": this.data.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.modifyTopic(
+        Object.assign({}, this.curElement, {
+          isCovered: checkElementisCovered(this.data.id, this.data.type),
+        })
+      );
+    },
+    resizeOver() {
+      this.modifyTopic(Object.assign({}, this.curElement, this.elemData));
+      // 注意:当前组件并没有实时更新元件的尺寸信息,只是在rebuildPages时统一更新。
+      this.$nextTick(() => {
+        this.rebuildPages();
+      });
+    },
+  },
+};
+</script>

+ 72 - 0
src/modules/card/components/TopicElementPreview.vue

@@ -0,0 +1,72 @@
+<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 PreviewCardHead from "../elements/card-head/CardHead";
+import PreviewExplain from "../elements/explain/ElemExplain";
+import PreviewComposition from "../elements/composition/ElemComposition";
+import PreviewFillQuestion from "../elements/fill-question/ElemFillQuestion";
+import PreviewFillLine from "../elements/fill-line/ElemFillLine";
+import PreviewTopicHead from "../elements/topic-head/TopicHead";
+
+export default {
+  name: "TopicElementPreview",
+  components: {
+    PreviewCardHead,
+    PreviewTopicHead,
+    PreviewFillQuestion,
+    PreviewFillLine,
+    PreviewExplain,
+    PreviewComposition,
+  },
+  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}`,
+        this.data["isLast"]
+          ? `element-item-type-last`
+          : "element-item-type-pre",
+      ];
+    },
+    styles() {
+      return {
+        left: this.data.x + "px",
+        top: this.data.y + "px",
+        width: this.data.w + "px",
+        height: this.data.h + "px",
+      };
+    },
+  },
+  methods: {},
+};
+</script>

+ 66 - 0
src/modules/card/components/TopicSelectDialog.vue

@@ -0,0 +1,66 @@
+<template>
+  <el-dialog
+    class="topic-select-dialog"
+    :visible.sync="modalIsShow"
+    title="试题选择"
+    top="10vh"
+    width="500px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="visibleChange"
+  >
+    <div class="text-center">
+      <el-button
+        v-for="(item, index) in topics"
+        :key="index"
+        :type="curTopic.type === item.type ? 'primary' : ''"
+        @click="selectTopic(item)"
+        ><i class="el-icon-plus"></i>{{ item.name }}</el-button
+      >
+    </div>
+
+    <div slot="footer">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button @click="cancel">取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+export default {
+  name: "TopicSelectDialog",
+  props: {
+    topics: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+  },
+  data() {
+    return {
+      modalIsShow: false,
+      curTopic: {},
+    };
+  },
+  methods: {
+    visibleChange() {
+      this.curTopic = {};
+    },
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    selectTopic(topic) {
+      this.curTopic = topic;
+    },
+    submit() {
+      this.$emit("confirm", this.curTopic);
+      this.cancel();
+    },
+  },
+};
+</script>

+ 148 - 0
src/modules/card/components/UploadButton.vue

@@ -0,0 +1,148 @@
+<template>
+  <el-upload
+    ref="UploadComp"
+    :action="uploadUrl"
+    :headers="headers"
+    :max-size="maxSize"
+    :format="format"
+    :data="uploadDataDict"
+    :before-upload="handleBeforeUpload"
+    :on-error="handleError"
+    :on-success="handleSuccess"
+    :http-request="upload"
+    :show-file-list="false"
+    style="display: inline-block; margin: 0 18px"
+  >
+    <el-button :type="btnType" :icon="btnIcon" :loading="loading">{{
+      btnContent
+    }}</el-button>
+  </el-upload>
+</template>
+
+<script>
+import { fileMD5 } from "../plugins/md5";
+import ajax from "../plugins/ajax";
+
+export default {
+  name: "UploadButton",
+  props: {
+    btnIcon: {
+      type: String,
+      default: "",
+    },
+    btnType: {
+      type: String,
+      default: "default",
+    },
+    btnContent: {
+      type: String,
+      default: "",
+    },
+    format: {
+      type: Array,
+      default() {
+        return ["jpg", "jpeg", "png"];
+      },
+    },
+    uploadUrl: {
+      type: String,
+      required: true,
+    },
+    uploadData: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+    maxSize: {
+      type: Number,
+      default: 20 * 1024 * 1024,
+    },
+    addFilenameParam: {
+      type: String,
+      default: "filename",
+    },
+  },
+  data() {
+    return {
+      headers: {
+        token: "",
+        md5: "",
+      },
+      res: {},
+      loading: false,
+      uploadDataDict: {},
+    };
+  },
+  methods: {
+    checkFileFormat(fileType) {
+      const _file_format = fileType.split(".").pop().toLocaleLowerCase();
+      return this.format.length
+        ? this.format.some((item) => item.toLocaleLowerCase() === _file_format)
+        : true;
+    },
+    async handleBeforeUpload(file) {
+      this.uploadDataDict = {
+        ...this.uploadData,
+      };
+      this.uploadDataDict[this.addFilenameParam] = file.name;
+
+      if (file.size > this.maxSize) {
+        this.handleExceededSize();
+        return Promise.reject();
+      }
+
+      if (!this.checkFileFormat(file.name)) {
+        this.handleFormatError();
+        return Promise.reject();
+      }
+
+      const md5 = await fileMD5(file);
+      this.headers["md5"] = md5;
+      this.loading = true;
+
+      return true;
+    },
+    upload(options) {
+      return ajax(options);
+    },
+    handleError(error) {
+      this.loading = false;
+      this.res = {
+        success: false,
+        message: error.message,
+      };
+      this.$emit("upload-error", error);
+    },
+    handleSuccess(response) {
+      this.loading = false;
+      if (response.code === "200") {
+        this.res = {
+          success: true,
+          message: "导入成功!",
+        };
+        this.$emit("upload-success", response);
+      } else {
+        this.handleError(response);
+      }
+    },
+    handleFormatError() {
+      const content = "只支持文件格式为" + this.format.join("/");
+      this.res = {
+        success: false,
+        message: content,
+      };
+      this.$emit("upload-error", this.res);
+    },
+    handleExceededSize() {
+      const content =
+        "文件大小不能超过" + Math.floor(this.maxSize / (1024 * 1024)) + "M";
+      this.res = {
+        success: false,
+        message: content,
+      };
+      this.$emit("upload-error", this.res);
+    },
+  },
+};
+</script>

+ 79 - 0
src/modules/card/components/common/ColorSelect.vue

@@ -0,0 +1,79 @@
+<template>
+  <el-dropdown
+    trigger="click"
+    class="color-select custom-select"
+    placement="bottom-start"
+    @command="select"
+  >
+    <div class="select-preview">
+      <div class="select-preview-main">
+        <div
+          v-if="selected"
+          class="color-item"
+          :style="{ backgroundColor: selected }"
+        ></div>
+      </div>
+      <div class="select-preview-icon">
+        <i class="el-icon-arrow-down"></i>
+      </div>
+    </div>
+    <el-dropdown-menu slot="dropdown" class="color-menu">
+      <el-dropdown-item v-if="showEmpty" command="">无</el-dropdown-item>
+      <el-dropdown-item
+        v-for="(option, index) in optionList"
+        :key="index"
+        :command="option"
+      >
+        <div class="color-item" :style="{ backgroundColor: option }"></div>
+      </el-dropdown-item>
+    </el-dropdown-menu>
+  </el-dropdown>
+</template>
+
+<script>
+const PREDEFINE_OPTIONS = ["#000000", "#666666", "#999999", "#ffffff"];
+
+export default {
+  name: "ColorSelect",
+  props: {
+    value: { type: String, default: null },
+    predefine: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+    showEmpty: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      optionList: [],
+      selected: "",
+    };
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(val) {
+        this.selected = val;
+      },
+    },
+  },
+  created() {
+    this.optionList =
+      this.predefine && this.predefine.length
+        ? this.predefine
+        : PREDEFINE_OPTIONS;
+  },
+  methods: {
+    select(option) {
+      this.selected = option;
+      this.$emit("input", this.selected);
+      this.$emit("change", this.selected);
+    },
+  },
+};
+</script>

+ 68 - 0
src/modules/card/components/common/DirectionSelect.vue

@@ -0,0 +1,68 @@
+<template>
+  <el-select
+    v-model="selected"
+    class="direction-select"
+    placeholder="请选择"
+    @change="select"
+  >
+    <el-option
+      v-for="item in optionList"
+      :key="item.value"
+      :label="item.label"
+      :value="item.value"
+    >
+    </el-option>
+  </el-select>
+</template>
+
+<script>
+const PREDEFINE_OPTIONS = [
+  {
+    value: "horizontal",
+    label: "水平",
+  },
+  {
+    value: "vertical",
+    label: "垂直",
+  },
+];
+
+export default {
+  name: "DirectionSelect",
+  props: {
+    value: { type: String, default: null },
+    predefine: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+  },
+  data() {
+    return {
+      optionList: [],
+      selected: "",
+    };
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(val) {
+        this.selected = val;
+      },
+    },
+  },
+  created() {
+    this.optionList =
+      this.predefine && this.predefine.length
+        ? this.predefine
+        : PREDEFINE_OPTIONS;
+  },
+  methods: {
+    select() {
+      this.$emit("input", this.selected);
+      this.$emit("change", this.selected);
+    },
+  },
+};
+</script>

+ 582 - 0
src/modules/card/components/common/ElementResize.vue

@@ -0,0 +1,582 @@
+<template>
+  <div
+    v-move-ele.prevent.stop="{
+      moveStart,
+      moveElement,
+      moveStop: moveElementOver,
+    }"
+    :class="classes"
+    :style="styles"
+  >
+    <slot></slot>
+    <div class="resize-control">
+      <div
+        v-for="(control, index) in controlPoints"
+        :key="index"
+        v-move-ele.prevent.stop="{
+          moveElement: control.movePoint,
+          moveStop: control.movePointOver,
+        }"
+        :class="control.classes"
+      ></div>
+      <div class="control-line control-line-left"></div>
+      <div class="control-line control-line-right"></div>
+      <div class="control-line control-line-top"></div>
+      <div class="control-line control-line-bottom"></div>
+    </div>
+  </div>
+</template>
+
+<script>
+import MoveEle from "../../directives/move-ele";
+
+export default {
+  name: "ElementResize",
+  directives: { MoveEle },
+  props: {
+    value: {
+      type: Object,
+      required: true,
+    },
+    active: {
+      type: Array,
+      default() {
+        return ["r", "rb", "b", "lb", "l", "lt", "t", "rt"];
+      },
+    },
+    move: {
+      type: Boolean,
+      default: true,
+    },
+    minWidth: {
+      type: Number,
+      default: 30,
+      validator(val) {
+        return val >= 0;
+      },
+    },
+    maxWidth: {
+      type: Number,
+      default: 0,
+      validator(val) {
+        return val >= 0;
+      },
+    },
+    minHeight: {
+      type: Number,
+      default: 30,
+      validator(val) {
+        return val >= 0;
+      },
+    },
+    maxHeight: {
+      type: Number,
+      default: 0,
+      validator(val) {
+        return val >= 0;
+      },
+    },
+    fitParent: {
+      type: Array,
+      default() {
+        return ["w", "h"];
+      },
+    },
+    isCompact: {
+      type: Boolean,
+      default: false,
+    },
+    transformFit: {
+      type: Function,
+      default: null,
+    },
+    elementPk: {
+      type: String,
+      default: "",
+    },
+  },
+  data() {
+    return {
+      sizePosOrigin: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0,
+      },
+      offsetTopOrigin: 0,
+      sizePos: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0,
+      },
+      lastSizePos: {},
+      initOver: false,
+      controlPoints: [],
+      positionType: "static",
+      parentNodeSize: {
+        w: 0,
+        h: 0,
+      },
+    };
+  },
+  computed: {
+    styles() {
+      return this.initOver
+        ? {
+            left: this.sizePos.x + "px",
+            top: this.sizePos.y + "px",
+            width: this.sizePos.w + "px",
+            height: this.sizePos.h + "px",
+            zIndex: this.sizePos.zindex,
+            position: this.positionType,
+          }
+        : {};
+    },
+    classes() {
+      return [
+        "element-resize",
+        {
+          "element-resize-move": this.move,
+          "element-resize-init": this.initOver,
+          "element-resize-compact": this.isCompact,
+        },
+      ];
+    },
+    fitParentTypeWidth() {
+      return this.fitParent.includes("w");
+    },
+    fitParentTypeHeight() {
+      return this.fitParent.includes("h");
+    },
+  },
+  created() {
+    this.initControlPoints();
+  },
+  mounted() {
+    this.initSize();
+  },
+  methods: {
+    initControlPoints() {
+      const posName = {
+        l: "Left",
+        r: "Right",
+        t: "Top",
+        b: "Bottom",
+      };
+      this.controlPoints = this.active.map((type) => {
+        const posFullName = type
+          .split("")
+          .map((item) => {
+            return posName[item];
+          })
+          .join("");
+        return {
+          classes: ["control-point", `control-point-${type}`],
+          movePoint: this[`move${posFullName}Point`],
+          movePointOver: this.moveOver,
+        };
+      });
+    },
+    initSize() {
+      const resizeDom = this.$el.childNodes[0];
+      this.positionType = window.getComputedStyle(resizeDom).position;
+      this.sizePos = { ...this.value };
+      this.lastSizePos = { ...this.value };
+      this.sizePosOrigin = { ...this.value };
+      if (this.positionType === "relative")
+        this.offsetTopOrigin = this.$el.offsetTop;
+      this.initOver = true;
+    },
+    fetchValidSizePos(sizePos, actionType) {
+      if (sizePos.w <= this.minWidth) {
+        sizePos.w = this.minWidth;
+      }
+      if (this.maxWidth !== 0 && sizePos.w >= this.maxWidth) {
+        sizePos.w = this.maxWidth;
+      }
+
+      if (sizePos.h <= this.minHeight) {
+        sizePos.h = this.minHeight;
+      }
+      if (this.maxHeight !== 0 && sizePos.h >= this.maxHeight) {
+        sizePos.h = this.maxHeight;
+      }
+
+      if (!this.fitParent.length) {
+        this.lastSizePos = { ...sizePos };
+        return sizePos;
+      }
+
+      // 不同的定位方式,计算方式有差异
+      this.parentNodeSize = {
+        w: this.$el.offsetParent.offsetWidth,
+        h: this.$el.offsetParent.offsetHeight,
+      };
+
+      if (this.fitParentTypeWidth) {
+        if (sizePos.x <= 0) {
+          sizePos.x = 0;
+          if (actionType.includes("left")) sizePos.w = this.lastSizePos.w;
+        }
+
+        if (sizePos.x + sizePos.w > this.parentNodeSize.w) {
+          sizePos.x = this.lastSizePos.x;
+          sizePos.w = this.parentNodeSize.w - sizePos.x;
+        }
+      }
+
+      if (this.fitParentTypeHeight) {
+        if (this.positionType === "relative") {
+          const elOffsetTop = this.$el.offsetTop;
+          if (this.sizePosOrigin.y - sizePos.y >= this.offsetTopOrigin) {
+            sizePos.h = this.lastSizePos.h;
+            sizePos.y = this.sizePosOrigin.y - this.offsetTopOrigin;
+          }
+          if (elOffsetTop + sizePos.h >= this.parentNodeSize.h) {
+            sizePos.y = this.lastSizePos.y;
+            sizePos.h = this.lastSizePos.h;
+            // TODO:这里如果拖动太快,会有问题
+            // console.log(this.parentNodeSize.h, elOffsetTop, sizePos.h);
+          }
+        } else {
+          if (sizePos.y <= 0) {
+            sizePos.y = 0;
+            if (actionType.includes("top")) sizePos.h = this.lastSizePos.h;
+          }
+          if (sizePos.y + sizePos.h > this.parentNodeSize.h) {
+            sizePos.y = this.lastSizePos.y;
+            sizePos.h = this.parentNodeSize.h - sizePos.y;
+          }
+        }
+      }
+
+      this.lastSizePos = { ...sizePos };
+      return this.transformFit
+        ? Object.assign(
+            {},
+            sizePos,
+            this.transformFit({ id: this.elementPk, ...sizePos }, actionType)
+          )
+        : sizePos;
+    },
+    getLeftSize(left) {
+      return {
+        w: -left + this.sizePosOrigin.w,
+        x: left + this.sizePosOrigin.x,
+      };
+    },
+    getRightSize(left) {
+      return {
+        w: left + this.sizePosOrigin.w,
+      };
+    },
+    getTopSize(top) {
+      return {
+        h: -top + this.sizePosOrigin.h,
+        y: top + this.sizePosOrigin.y,
+      };
+    },
+    getBottomSize(top) {
+      return {
+        h: top + this.sizePosOrigin.h,
+      };
+    },
+    moveLeftPoint({ left }) {
+      const sp = { ...this.sizePos, ...this.getLeftSize(left) };
+      this.sizePos = { ...this.fetchValidSizePos(sp, "left") };
+      this.emitChange();
+    },
+    moveRightPoint({ left }) {
+      const sp = { ...this.sizePos, ...this.getRightSize(left) };
+      this.sizePos = { ...this.fetchValidSizePos(sp, "right") };
+      this.emitChange();
+    },
+    moveTopPoint({ top }) {
+      const sp = { ...this.sizePos, ...this.getTopSize(top) };
+      this.sizePos = { ...this.fetchValidSizePos(sp, "top") };
+      this.emitChange();
+    },
+    moveBottomPoint({ top }) {
+      const sp = { ...this.sizePos, ...this.getBottomSize(top) };
+      this.sizePos = { ...this.fetchValidSizePos(sp, "bottom") };
+      this.emitChange();
+    },
+    moveLeftTopPoint({ left, top }) {
+      const sp = {
+        ...this.sizePos,
+        ...this.getLeftSize(left),
+        ...this.getTopSize(top),
+      };
+      this.sizePos = { ...this.fetchValidSizePos(sp, "left-top") };
+      this.emitChange();
+    },
+    moveRightTopPoint({ left, top }) {
+      const sp = {
+        ...this.sizePos,
+        ...this.getRightSize(left),
+        ...this.getTopSize(top),
+      };
+      this.sizePos = { ...this.fetchValidSizePos(sp, "right-top") };
+      this.emitChange();
+    },
+    moveLeftBottomPoint({ left, top }) {
+      const sp = {
+        ...this.sizePos,
+        ...this.getLeftSize(left),
+        ...this.getBottomSize(top),
+      };
+      this.sizePos = { ...this.fetchValidSizePos(sp, "left-bottom") };
+      this.emitChange();
+    },
+    moveRightBottomPoint({ left, top }) {
+      const sp = {
+        ...this.sizePos,
+        ...this.getRightSize(left),
+        ...this.getBottomSize(top),
+      };
+      this.sizePos = { ...this.fetchValidSizePos(sp, "right-bottom") };
+      this.emitChange();
+    },
+    moveOver() {
+      this.sizePosOrigin = { ...this.sizePos };
+      this.lastSizePos = { ...this.sizePos };
+      if (this.positionType === "relative")
+        this.offsetTopOrigin = this.$el.offsetTop < 0 ? 0 : this.$el.offsetTop;
+      this.emitChange();
+      this.$emit("resize-over", this.sizePos);
+    },
+    moveStart() {
+      this.$emit("on-click");
+    },
+    moveElement({ left, top }) {
+      if (!this.move) return;
+
+      const sp = {
+        ...this.sizePos,
+        ...{
+          x: left + this.sizePosOrigin.x,
+          y: top + this.sizePosOrigin.y,
+        },
+      };
+      this.sizePos = { ...this.fetchValidSizePos(sp, "move") };
+      this.emitChange();
+    },
+    moveElementOver() {
+      if (!this.move) return;
+      this.moveOver();
+    },
+    emitChange() {
+      this.$emit("input", this.sizePos);
+      this.$emit("change", this.sizePos);
+    },
+  },
+};
+</script>
+
+<style lang="scss" scope>
+.element-resize {
+  position: static;
+  z-index: auto;
+  background: #fff;
+  box-sizing: content-box;
+
+  &-move {
+    cursor: move;
+  }
+
+  &-init {
+    > div:first-child {
+      width: 100% !important;
+      height: 100% !important;
+      position: relative !important;
+      top: 0 !important;
+      left: 0 !important;
+      overflow: hidden;
+    }
+  }
+  .control-point {
+    position: absolute;
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: #617bea;
+    z-index: 99;
+    &-l {
+      left: 0;
+      top: 50%;
+      width: 5px;
+      height: 20px;
+      margin-top: -10px;
+      margin-left: -3px;
+      border-radius: 0;
+      padding-top: 3px;
+      cursor: w-resize;
+      text-align: center;
+      color: #fff;
+
+      &::before {
+        content: ".";
+        display: block;
+        font-size: 16px;
+        line-height: 1;
+        margin-top: -9px;
+      }
+      &::after {
+        content: ".";
+        display: block;
+        font-size: 16px;
+        line-height: 1;
+        margin-top: -9px;
+      }
+    }
+    &-lt {
+      left: 0;
+      top: 0;
+      margin-top: -5px;
+      margin-left: -5px;
+      cursor: nw-resize;
+    }
+    &-lb {
+      left: 0;
+      bottom: 0;
+      margin-bottom: -5px;
+      margin-left: -5px;
+      cursor: sw-resize;
+    }
+    &-r {
+      right: 0;
+      top: 50%;
+      width: 5px;
+      height: 20px;
+      margin-top: -10px;
+      margin-right: -3px;
+      cursor: e-resize;
+      border-radius: 0;
+      padding-top: 3px;
+      text-align: center;
+      color: #fff;
+
+      &::before {
+        content: ".";
+        display: block;
+        font-size: 16px;
+        line-height: 1;
+        margin-top: -9px;
+      }
+      &::after {
+        content: ".";
+        display: block;
+        font-size: 16px;
+        line-height: 1;
+        margin-top: -9px;
+      }
+    }
+    &-rt {
+      right: 0;
+      top: 0;
+      margin-top: -5px;
+      margin-right: -5px;
+      cursor: ne-resize;
+    }
+    &-rb {
+      right: 0;
+      bottom: 0;
+      margin-bottom: -5px;
+      margin-right: -5px;
+      cursor: se-resize;
+    }
+    &-t {
+      left: 50%;
+      top: 0;
+      width: 30px;
+      height: 5px;
+      border-radius: 0;
+      margin-top: -3px;
+      margin-left: -15px;
+      cursor: n-resize;
+      text-align: center;
+      color: #fff;
+      &::before {
+        content: "...";
+        display: inline-block;
+        vertical-align: top;
+        font-size: 16px;
+        line-height: 1;
+        margin-top: -10px;
+      }
+    }
+    &-b {
+      left: 50%;
+      bottom: 0;
+      width: 30px;
+      height: 5px;
+      border-radius: 0;
+      margin-bottom: -3px;
+      margin-left: -15px;
+      cursor: s-resize;
+      text-align: center;
+      color: #fff;
+
+      &::before {
+        content: "...";
+        display: inline-block;
+        vertical-align: top;
+        font-size: 16px;
+        line-height: 1;
+        margin-top: -10px;
+      }
+    }
+  }
+  .control-line {
+    position: absolute;
+    z-index: 98;
+
+    &-left {
+      height: 100%;
+      left: -1px;
+      top: 0;
+      border-left: 1px solid #617bea;
+    }
+    &-right {
+      height: 100%;
+      right: -1px;
+      top: 0;
+      border-left: 1px solid #617bea;
+    }
+    &-top {
+      width: 100%;
+      left: 0;
+      top: -1px;
+      border-top: 1px solid #617bea;
+    }
+    &-bottom {
+      width: 100%;
+      left: 0;
+      bottom: -1px;
+      border-top: 1px solid #617bea;
+    }
+  }
+
+  &-compact {
+    .control-line {
+      &-left {
+        left: 0;
+        border-left: 1px dashed #bbb;
+      }
+      &-right {
+        right: 0;
+        border-left: 1px dashed #bbb;
+      }
+      &-top {
+        top: 0;
+        border-top: 1px dashed #bbb;
+      }
+      &-bottom {
+        bottom: 0;
+        border-top: 1px dashed #bbb;
+      }
+    }
+  }
+}
+</style>

+ 60 - 0
src/modules/card/components/common/FontFamilySelect.vue

@@ -0,0 +1,60 @@
+<template>
+  <el-select
+    v-model="selected"
+    class="font-family-select"
+    placeholder="请选择"
+    @change="select"
+  >
+    <el-option
+      v-for="item in optionList"
+      :key="item"
+      :label="item"
+      :value="item"
+    >
+      <span :style="{ fontFamily: item }">{{ item }}</span>
+    </el-option>
+  </el-select>
+</template>
+
+<script>
+const PREDEFINE_OPTIONS = ["宋体", "黑体"];
+
+export default {
+  name: "FontFamilySelect",
+  props: {
+    value: { type: String, default: null },
+    predefine: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+  },
+  data() {
+    return {
+      optionList: [],
+      selected: "",
+    };
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(val) {
+        this.selected = val;
+      },
+    },
+  },
+  created() {
+    this.optionList =
+      this.predefine && this.predefine.length
+        ? this.predefine
+        : PREDEFINE_OPTIONS;
+  },
+  methods: {
+    select() {
+      this.$emit("input", this.selected);
+      this.$emit("change", this.selected);
+    },
+  },
+};
+</script>

+ 79 - 0
src/modules/card/components/common/LineStyleSelect.vue

@@ -0,0 +1,79 @@
+<template>
+  <el-dropdown
+    trigger="click"
+    class="line-style-select custom-select"
+    placement="bottom-start"
+    @command="select"
+  >
+    <div class="select-preview">
+      <div class="select-preview-main">
+        <div
+          v-if="selected"
+          class="style-item"
+          :style="{ borderBottomStyle: selected }"
+        ></div>
+      </div>
+      <div class="select-preview-icon">
+        <i class="el-icon-arrow-down"></i>
+      </div>
+    </div>
+    <el-dropdown-menu slot="dropdown" class="line-style-menu">
+      <el-dropdown-item v-if="showEmpty" command="none">无</el-dropdown-item>
+      <el-dropdown-item
+        v-for="(option, index) in optionList"
+        :key="index"
+        :command="option"
+      >
+        <div class="style-item" :style="{ borderBottomStyle: option }"></div>
+      </el-dropdown-item>
+    </el-dropdown-menu>
+  </el-dropdown>
+</template>
+
+<script>
+const PREDEFINE_OPTIONS = ["dotted", "dashed", "solid"];
+
+export default {
+  name: "LineStyleSelect",
+  props: {
+    value: { type: String, default: null },
+    predefine: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+    showEmpty: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      optionList: [],
+      selected: "",
+    };
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(val) {
+        this.selected = val;
+      },
+    },
+  },
+  created() {
+    this.optionList =
+      this.predefine && this.predefine.length
+        ? this.predefine
+        : PREDEFINE_OPTIONS;
+  },
+  methods: {
+    select(option) {
+      this.selected = option;
+      this.$emit("input", this.selected);
+      this.$emit("change", this.selected);
+    },
+  },
+};
+</script>

+ 76 - 0
src/modules/card/components/common/LineWidthSelect.vue

@@ -0,0 +1,76 @@
+<template>
+  <el-dropdown
+    trigger="click"
+    class="line-width-select custom-select"
+    placement="bottom-start"
+    @command="select"
+  >
+    <div class="select-preview">
+      <div class="select-preview-main">
+        <div v-if="selected" class="width-item">
+          <!-- <span>{{ selected }}</span> -->
+          <i :style="{ borderBottomWidth: selected }"></i>
+        </div>
+      </div>
+      <div class="select-preview-icon">
+        <i class="el-icon-arrow-down"></i>
+      </div>
+    </div>
+    <el-dropdown-menu slot="dropdown" class="line-width-menu">
+      <el-dropdown-item
+        v-for="(option, index) in optionList"
+        :key="index"
+        :command="option"
+      >
+        <div class="width-item">
+          <!-- <span>{{ option }}</span> -->
+          <i :style="{ borderBottomWidth: option }"></i>
+        </div>
+      </el-dropdown-item>
+    </el-dropdown-menu>
+  </el-dropdown>
+</template>
+
+<script>
+const PREDEFINE_OPTIONS = ["1px", "2px", "3px"];
+
+export default {
+  name: "LineWidthSelect",
+  props: {
+    value: { type: String, default: "" },
+    predefine: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+  },
+  data() {
+    return {
+      optionList: [],
+      selected: "",
+    };
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(val) {
+        this.selected = val;
+      },
+    },
+  },
+  created() {
+    this.optionList =
+      this.predefine && this.predefine.length
+        ? this.predefine
+        : PREDEFINE_OPTIONS;
+  },
+  methods: {
+    select(option) {
+      this.selected = option;
+      this.$emit("input", this.selected);
+      this.$emit("change", this.selected);
+    },
+  },
+};
+</script>

+ 49 - 0
src/modules/card/components/common/PopoverButton.vue

@@ -0,0 +1,49 @@
+<template>
+  <el-popover
+    v-model="visible"
+    class="popover-button"
+    placement="top"
+    width="240"
+  >
+    <p><i class="el-icon-info color-warning"></i> {{ confirmText }}</p>
+    <br />
+    <div style="text-align: right; margin: 0">
+      <el-button size="mini" @click="visible = false">取消</el-button>
+      <el-button type="primary" size="mini" @click="confirm">确定</el-button>
+    </div>
+    <el-button
+      slot="reference"
+      class="btn--danger font-bold"
+      type="text"
+      icon="el-icon-delete"
+      >{{ btnName }}</el-button
+    >
+  </el-popover>
+</template>
+
+<script>
+export default {
+  name: "PopoverButton",
+  props: {
+    confirmText: {
+      type: String,
+      default: "你确定要删除当前记录吗?",
+    },
+    btnName: {
+      type: String,
+      default: "删除",
+    },
+  },
+  data() {
+    return {
+      visible: false,
+    };
+  },
+  methods: {
+    confirm() {
+      this.visible = false;
+      this.$emit("confirm");
+    },
+  },
+};
+</script>

+ 59 - 0
src/modules/card/components/common/RotationSelect.vue

@@ -0,0 +1,59 @@
+<template>
+  <el-select
+    v-model="selected"
+    class="rotation-select"
+    placeholder="请选择"
+    @change="select"
+  >
+    <el-option
+      v-for="item in optionList"
+      :key="item"
+      :label="item"
+      :value="item"
+    >
+    </el-option>
+  </el-select>
+</template>
+
+<script>
+const PREDEFINE_OPTIONS = [0, 90, 180, 270];
+
+export default {
+  name: "RotationSelect",
+  props: {
+    value: { type: Number, default: null },
+    predefine: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+  },
+  data() {
+    return {
+      optionList: [],
+      selected: "",
+    };
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(val) {
+        this.selected = val;
+      },
+    },
+  },
+  created() {
+    this.optionList =
+      this.predefine && this.predefine.length
+        ? this.predefine
+        : PREDEFINE_OPTIONS;
+  },
+  methods: {
+    select() {
+      this.$emit("input", this.selected);
+      this.$emit("change", this.selected);
+    },
+  },
+};
+</script>

+ 33 - 0
src/modules/card/components/common/ShortcutKeySpin.vue

@@ -0,0 +1,33 @@
+<template>
+  <span class="shortcut-key-spin">
+    <i v-if="ARROW_CONT" :class="ARROW_CONT"></i>
+    <i v-else>{{ data }}</i>
+  </span>
+</template>
+
+<script>
+export default {
+  name: "ShortcutKeySpin",
+  props: {
+    data: {
+      type: String,
+      default: "",
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    ARROW_CONT() {
+      const arrowIcon = {
+        ArrowUp: "el-icon-top",
+        ArrowDown: "el-icon-bottom",
+        ArrowLeft: "el-icon-back",
+        ArrowRight: "el-icon-right",
+      };
+      return arrowIcon[this.data];
+    },
+  },
+  methods: {},
+};
+</script>

+ 81 - 0
src/modules/card/components/common/SizeSelect.vue

@@ -0,0 +1,81 @@
+<template>
+  <el-select
+    v-model="selected"
+    class="size-select"
+    placeholder="请选择"
+    @change="select"
+  >
+    <el-option
+      v-for="item in optionList"
+      :key="item.value"
+      :label="item.label"
+      :value="item.value"
+    >
+      <span :style="{ fontSize: item.value }">{{ item.label }}</span>
+    </el-option>
+  </el-select>
+</template>
+
+<script>
+const PREDEFINE_OPTIONS = [
+  {
+    value: "14px",
+    label: "小(5号)",
+  },
+  {
+    value: "18.7px",
+    label: "中(4号)",
+  },
+  {
+    value: "21.3px",
+    label: "大(3号)",
+  },
+  {
+    value: "29.3px",
+    label: "特大(2号)",
+  },
+  {
+    value: "34.7px",
+    label: "超大(1号)",
+  },
+];
+
+export default {
+  name: "SizeSelect",
+  props: {
+    value: { type: String, default: "" },
+    predefine: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+  },
+  data() {
+    return {
+      optionList: [],
+      selected: "",
+    };
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(val) {
+        this.selected = val;
+      },
+    },
+  },
+  created() {
+    this.optionList =
+      this.predefine && this.predefine.length
+        ? this.predefine
+        : PREDEFINE_OPTIONS;
+  },
+  methods: {
+    select() {
+      this.$emit("input", this.selected);
+      this.$emit("change", this.selected);
+    },
+  },
+};
+</script>

+ 21 - 0
src/modules/card/components/common/TopicNumber.vue

@@ -0,0 +1,21 @@
+<template>
+  <div class="element-item-topic-number">
+    {{ data }}
+  </div>
+</template>
+
+<script>
+export default {
+  name: "TopicNumber",
+  props: {
+    data: {
+      type: [Number, String],
+      default: "",
+    },
+  },
+  data() {
+    return {};
+  },
+  methods: {},
+};
+</script>

+ 49 - 0
src/modules/card/directives/move-ele.js

@@ -0,0 +1,49 @@
+module.exports = {
+  inserted(el, { value, modifiers }) {
+    let [_x, _y] = [0, 0];
+    // 只允许鼠标左键触发
+    let moveHandle = function (e) {
+      if (e.button !== 0) return;
+      if (modifiers.prevent) {
+        e.preventDefault();
+      }
+      if (modifiers.stop) {
+        e.stopPropagation();
+      }
+
+      let left = e.pageX - _x;
+      let top = e.pageY - _y;
+
+      value.moveElement({ left, top });
+    };
+
+    let upHandle = function (e) {
+      if (e.button !== 0) return;
+      if (modifiers.prevent) {
+        e.preventDefault();
+      }
+      if (modifiers.stop) {
+        e.stopPropagation();
+      }
+      value.moveStop && value.moveStop();
+      document.removeEventListener("mousemove", moveHandle);
+      document.removeEventListener("mouseup", upHandle);
+    };
+
+    el.addEventListener("mousedown", function (e) {
+      if (e.button !== 0) return;
+      if (modifiers.prevent) {
+        e.preventDefault();
+      }
+      if (modifiers.stop) {
+        e.stopPropagation();
+      }
+      _x = e.pageX;
+      _y = e.pageY;
+      value.moveStart && value.moveStart();
+
+      document.addEventListener("mousemove", moveHandle);
+      document.addEventListener("mouseup", upHandle);
+    });
+  },
+};

+ 174 - 0
src/modules/card/elementModel.js

@@ -0,0 +1,174 @@
+/* eslint-disable no-unused-vars */
+import { deepCopy, getNumList } from "./plugins/utils";
+import { getModel as getCardHeadModel } from "./elements/card-head/model";
+import { getModel as getTopicHeadModel } from "./elements/topic-head/model";
+// element
+import { getModel as createLines } from "./elements/lines/model";
+import { getModel as createLine } from "./elements/line/model";
+import { getModel as createText } from "./elements/text/model";
+import { getModel as createImage } from "./elements/image/model";
+import { getModel as createGrids } from "./elements/grids/model";
+import {
+  getModel as createComposition,
+  getFullModel as getCompositionElements,
+} from "./elements/composition/model";
+import {
+  getModel as createFillQuestion,
+  getFullModel as getFillQuesitonElements,
+} from "./elements/fill-question/model";
+import {
+  getModel as createFillLine,
+  getFullModel as getFillLineElements,
+} from "./elements/fill-line/model";
+import {
+  getModel as createExplain,
+  getFullModel as getExplainElements,
+} from "./elements/explain/model";
+
+// page relate ------------------- >
+// 页面
+const PAGE = {
+  type: "PAGE",
+  columnGap: 20,
+  locators: [],
+  globals: [],
+  columns: [],
+};
+// 可编辑栏
+const COLUMN = {
+  type: "COLUMN",
+  x: "",
+  y: "",
+  w: "",
+  h: "",
+  isFull: false, // 是否已经填满元素
+  elements: [],
+};
+// 定位点
+const LOCATOR = {
+  type: "LOCATOR",
+  x: "",
+  y: "",
+  w: "",
+  h: "",
+};
+
+// available infos
+const EDITABLE_ELEMENT = [
+  "LINE_HORIZONTAL",
+  "LINE_VERTICAL",
+  "LINES",
+  "TEXT",
+  "IMAGE",
+  "GRIDS",
+];
+
+const EDITABLE_TOPIC = ["FILL_QUESTION", "FILL_LINE", "EXPLAIN", "COMPOSITION"];
+
+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,
+  },
+  FILL_QUESTION: {
+    name: "选择题",
+    getModel: createFillQuestion,
+  },
+  FILL_LINE: {
+    name: "填空题",
+    getModel: createFillLine,
+  },
+  EXPLAIN: {
+    name: "解答题",
+    getModel: createExplain,
+  },
+  COMPOSITION: {
+    name: "作文题",
+    getModel: createComposition,
+  },
+};
+
+const ELEMENT_LIST = EDITABLE_ELEMENT.map((type) => {
+  return {
+    ...ELEMENT_INFOS[type],
+    type,
+  };
+});
+
+const TOPIC_LIST = EDITABLE_TOPIC.map((type) => {
+  return {
+    ...ELEMENT_INFOS[type],
+    type,
+  };
+});
+
+// 获取元件默认数据结构
+const getElementModel = (type) => {
+  return ELEMENT_INFOS[type].getModel();
+};
+
+const getElementName = (type) => {
+  return ELEMENT_INFOS[type].name;
+};
+
+// 创建新页面
+const getNewPage = (pageNo, { pageSize, columnNumber }) => {
+  let npage = deepCopy(PAGE);
+  if (
+    (pageSize === "A3" && columnNumber === 4) ||
+    (pageSize === "A4" && columnNumber === 2)
+  ) {
+    npage.columnGap = 10;
+  }
+  const num = pageSize === "A3" ? 3 : 2;
+  npage.locators = getNumList(num).map((item, index) => {
+    return [
+      {
+        ...LOCATOR,
+        id: `locator-${pageNo}-${index}0`,
+      },
+      {
+        ...LOCATOR,
+        id: `locator-${pageNo}-${index}1`,
+      },
+    ];
+  });
+  npage.columns = getNumList(columnNumber).map((item, index) => {
+    return deepCopy(COLUMN);
+  });
+  return npage;
+};
+
+export {
+  getElementModel,
+  getElementName,
+  getNewPage,
+  getCardHeadModel,
+  getTopicHeadModel,
+  getFillQuesitonElements,
+  getFillLineElements,
+  getExplainElements,
+  getCompositionElements,
+  ELEMENT_LIST,
+  TOPIC_LIST,
+};

+ 127 - 0
src/modules/card/elements/barcode/EditBarcode.vue

@@ -0,0 +1,127 @@
+<template>
+  <div class="edit-barcode">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="100px"
+    >
+      <el-form-item label="背景颜色:">
+        <color-select
+          v-model="modalForm.bgColor"
+          show-empty
+          :predefine="predefineColors"
+        ></color-select>
+      </el-form-item>
+      <el-form-item label="边框颜色:">
+        <color-select v-model="modalForm.color"></color-select>
+      </el-form-item>
+      <el-form-item label="边框粗细:">
+        <line-width-select v-model="modalForm.bold"></line-width-select>
+      </el-form-item>
+      <el-form-item label="边框形状:">
+        <line-style-select v-model="modalForm.style"></line-style-select>
+      </el-form-item>
+      <el-form-item label="方向:">
+        <el-input-number
+          v-model="modalForm.rotation"
+          :max="180"
+          :min="-180"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="field" label="变量:">
+        <el-select
+          v-model="modalForm.field"
+          placeholder="请选择"
+          style="width: 100%"
+        >
+          <el-option
+            v-for="item in fieldList"
+            :key="item.code"
+            :label="item.name"
+            :value="item.code"
+          >
+          </el-option>
+        </el-select>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import ColorSelect from "../../components/common/ColorSelect";
+import LineStyleSelect from "../../components/common/LineStyleSelect";
+import LineWidthSelect from "../../components/common/LineWidthSelect";
+import { mapState } from "vuex";
+import { objAssign } from "../../plugins/utils";
+
+const initModalForm = {
+  id: "",
+  rotation: 0,
+  bold: "1px",
+  color: "#ffffff",
+  bgColor: "#ffffff",
+  style: "solid",
+  field: "",
+};
+
+export default {
+  name: "EditBarcode",
+  components: { ColorSelect, LineStyleSelect, LineWidthSelect },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      modalForm: { ...initModalForm },
+      predefineColors: ["#000000", "#666666", "#999999", "#ffffff"],
+      fieldList: [],
+      rules: {
+        field: [
+          {
+            required: true,
+            message: "请选择变量",
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  computed: {
+    ...mapState("free", ["cardConfig"]),
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.fieldList = [
+        ...this.cardConfig.requiredFields,
+        ...this.cardConfig.extendFields,
+      ].filter((item) => item.enable);
+      this.modalForm = objAssign(initModalForm, val);
+      this.modalForm.field = val.fields[0] && val.fields[0].code;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      const data = { ...this.modalForm };
+      data.fields = this.fieldList.filter(
+        (item) => item.code === this.modalForm.field
+      );
+      const model = objAssign(this.instance, data);
+      this.$emit("modified", model);
+    },
+  },
+};
+</script>

+ 45 - 0
src/modules/card/elements/barcode/ElemBarcode.vue

@@ -0,0 +1,45 @@
+<template>
+  <div class="elem-barcode" :style="styles">
+    <img v-if="imageSrc" :src="imageSrc" alt="条形码" />
+    <img
+      v-else
+      src="../../assets/images/barcode-sample-notext.png"
+      alt="条形码"
+    />
+  </div>
+</template>
+
+<script>
+export default {
+  name: "ElemBarcode",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    styles() {
+      return {
+        backgroundColor: this.data.bgColor,
+        borderStyle: this.data.style,
+        borderWidth: this.data.bold,
+        borderColor: this.data.color,
+        transform: `rotate(${this.data.rotation}deg)`,
+      };
+    },
+    imageSrc() {
+      // 设置变量之后的条形码,在渲染时将content的内容填充为base64字符图片。
+      const content = this.data.content;
+
+      return content && content.indexOf("base64") !== -1 ? content : "";
+    },
+  },
+  methods: {},
+};
+</script>

+ 27 - 0
src/modules/card/elements/barcode/model.js

@@ -0,0 +1,27 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+
+const MODEL = {
+  type: "BARCODE",
+  x: 0,
+  y: 0,
+  w: 300,
+  h: 60,
+  sign: "",
+  rotation: 0,
+  bold: "1px",
+  color: "#ffffff",
+  bgColor: "#ffffff",
+  style: "solid",
+  fields: [],
+  content: "",
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...deepCopy(MODEL),
+  };
+};
+
+export { MODEL, getModel };

+ 190 - 0
src/modules/card/elements/card-head/CardHead.vue

@@ -0,0 +1,190 @@
+<template>
+  <div :class="classes">
+    <div class="card-head-top">
+      <!-- 高度变化之后会印象内容排版,先固定高度 -->
+      <div class="card-head-title">
+        <el-input
+          v-if="!preview && !data.isSimple"
+          id="cardTitleInput"
+          v-model="cardTitle"
+          size="small"
+          placeholder="请输入题卡标题"
+          :disabled="disabledEditCardName"
+          @blur="nameChange"
+        >
+        </el-input>
+        <h1 v-else>{{ data.cardTitle }}</h1>
+      </div>
+      <div class="card-head-subtitle">
+        <div v-if="!preview && !data.isSimple">
+          <el-input
+            v-model="cardDescLineOne"
+            placeholder="请输入题卡描述信息"
+            @blur="nameChange"
+          >
+          </el-input>
+          <el-input
+            v-model="cardDescLineTwo"
+            placeholder="更多题卡描述信息"
+            @blur="nameChange"
+          >
+          </el-input>
+        </div>
+        <p v-else>{{ data.cardDesc }}</p>
+      </div>
+    </div>
+
+    <template v-if="!narrowCard">
+      <div v-if="data.examNumberStyle !== 'FILL'" class="card-head-body">
+        <div class="grid-container">
+          <div class="grid-row">
+            <div class="grid-col grid-col-dash">
+              <head-stdno :data="data"></head-stdno>
+            </div>
+            <div class="grid-col">
+              <head-stdinfo :data="data"></head-stdinfo>
+            </div>
+          </div>
+          <div v-if="!data.isSimple" class="grid-row">
+            <div class="grid-col">
+              <head-notice :data="data"></head-notice>
+            </div>
+            <div class="grid-col">
+              <head-dynamic :data="data"></head-dynamic>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div v-else class="card-head-body">
+        <card-head-body-auto-resize>
+          <head-stdinfo slot="stdinfo" :data="data"></head-stdinfo>
+          <head-notice slot="notice" :data="data"></head-notice>
+          <head-stdno slot="stdno" :data="data"></head-stdno>
+          <head-dynamic
+            v-if="!data.isSimple && hasDynamicArea"
+            slot="dynamic"
+            :data="data"
+          ></head-dynamic>
+        </card-head-body-auto-resize>
+      </div>
+    </template>
+
+    <template v-if="narrowCard">
+      <div v-if="data.examNumberStyle !== 'FILL'" class="card-head-body">
+        <head-stdno class="card-head-part" :data="data"></head-stdno>
+        <head-stdinfo class="card-head-part" :data="data"></head-stdinfo>
+        <head-dynamic
+          v-if="!data.isSimple && hasDynamicArea"
+          class="card-head-part"
+          :data="data"
+        ></head-dynamic>
+        <head-notice
+          v-if="!data.isSimple"
+          class="card-head-part"
+          :data="data"
+        ></head-notice>
+      </div>
+      <div v-else class="card-head-body">
+        <head-stdinfo class="card-head-part" :data="data"></head-stdinfo>
+        <head-stdno class="card-head-part" :data="data"></head-stdno>
+        <head-dynamic
+          v-if="!data.isSimple && hasDynamicArea"
+          class="card-head-part"
+          :data="data"
+        ></head-dynamic>
+        <head-notice class="card-head-part" :data="data"></head-notice>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+import HeadDynamic from "./cardHeadSpin/HeadDynamic";
+import HeadNotice from "./cardHeadSpin/HeadNotice";
+import HeadStdinfo from "./cardHeadSpin/HeadStdinfo";
+import HeadStdno from "./cardHeadSpin/HeadStdno";
+import CardHeadBodyAutoResize from "./CardHeadBodyAutoResize";
+import { mapMutations, mapState } from "vuex";
+
+export default {
+  name: "CardHead",
+  components: {
+    HeadStdno,
+    HeadStdinfo,
+    HeadNotice,
+    HeadDynamic,
+    CardHeadBodyAutoResize,
+  },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+    preview: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      cardTitle: this.data.cardTitle,
+      cardDesc: this.data.cardDesc,
+      cardDescLineOne: "",
+      cardDescLineTwo: "",
+    };
+  },
+  computed: {
+    ...mapState("card", ["cardConfig"]),
+    classes() {
+      return [
+        "page-element",
+        "card-head",
+        {
+          "card-head-narrow": this.narrowCard,
+          "card-head-handle": this.data.examNumberStyle === "FILL",
+          "card-head-normal":
+            this.data.examNumberStyle !== "FILL" && !this.narrowCard,
+        },
+      ];
+    },
+    narrowCard() {
+      return (
+        (this.data.pageSize === "A3" && this.data.columnNumber > 2) ||
+        (this.data.pageSize === "A4" && this.data.columnNumber === 2)
+      );
+    },
+    hasDynamicArea() {
+      const noDynamic =
+        this.data.examNumberStyle === "FILL"
+          ? !this.data.examAbsent && !this.data.aOrB && !this.data.discipline
+          : !this.data.examAbsent &&
+            !this.data.writeSign &&
+            !this.data.aOrB &&
+            !this.data.discipline;
+
+      return !noDynamic;
+    },
+    disabledEditCardName() {
+      // 客服制卡不可修改标题
+      return this.cardConfig["makeMethod"] === "CUST";
+    },
+  },
+  created() {
+    const contents = this.data.cardDesc.split("\n");
+    this.cardDescLineOne = contents[0];
+    this.cardDescLineTwo = contents[1];
+  },
+
+  methods: {
+    ...mapMutations("card", ["setCardConfig"]),
+    nameChange() {
+      this.setCardConfig({
+        cardTitle: this.cardTitle,
+        cardDesc: [this.cardDescLineOne, this.cardDescLineTwo].join("\n"),
+      });
+    },
+  },
+};
+</script>

+ 105 - 0
src/modules/card/elements/card-head/CardHeadBodyAutoResize.vue

@@ -0,0 +1,105 @@
+<template>
+  <div :class="classes">
+    <div class="rect-col">
+      <div
+        ref="stdinfoContainer"
+        class="rect-col-item"
+        :style="{ height: heights.stdinfo + 'px' }"
+      >
+        <slot name="stdinfo"></slot>
+      </div>
+      <div
+        ref="noticeContainer"
+        class="rect-col-item"
+        :style="{ height: heights.notice + 'px' }"
+      >
+        <slot name="notice"></slot>
+      </div>
+    </div>
+    <div class="rect-col">
+      <div
+        ref="stdnoContainer"
+        class="rect-col-item"
+        :style="{ height: heights.stdno + 'px' }"
+      >
+        <slot name="stdno"></slot>
+      </div>
+      <div
+        ref="dynamicContainer"
+        :class="['rect-col-item', { 'rect-col-item-none': !$slots.dynamic }]"
+        :style="{ height: heights.dynamic + 'px' }"
+      >
+        <slot name="dynamic"></slot>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "CardHeadBodyAutoResize",
+  data() {
+    return {
+      orgHeights: {
+        stdinfo: 40,
+        notice: 40,
+        stdno: 40,
+        dynamic: 40,
+      },
+      heights: {
+        stdinfo: 40,
+        notice: 40,
+        stdno: 40,
+        dynamic: 40,
+      },
+    };
+  },
+  computed: {
+    classes() {
+      return ["card-head-body-auto-resize", "col-item-auto-height"];
+    },
+  },
+  mounted() {
+    this.initStyles();
+  },
+  methods: {
+    initStyles() {
+      const containers = ["stdinfo", "notice", "stdno", "dynamic"];
+      containers.forEach((container) => {
+        const dom =
+          this.$refs[`${container}Container`] &&
+          this.$refs[`${container}Container`].firstChild;
+        this.orgHeights[container] = dom ? dom.offsetHeight : 0;
+      });
+      Object.keys(this.orgHeights).map((key) => {
+        this.heights[key] = this.orgHeights[key] + 2;
+      });
+      this.resizeRect();
+    },
+    resizeRect() {
+      let col1 = this.orgHeights.stdinfo + this.orgHeights.notice;
+      let col2 = this.orgHeights.stdno + this.orgHeights.dynamic;
+      if (this.$slots.dynamic) {
+        if (col1 > col2) {
+          this.heights.stdno = col1 - col2 + this.orgHeights.stdno + 2;
+          this.heights.dynamic = this.orgHeights.dynamic + 2;
+        } else {
+          const splitHeight = (col2 - col1) / 2;
+          this.heights.stdinfo = splitHeight + this.orgHeights.stdinfo + 2;
+          this.heights.notice = splitHeight + this.orgHeights.notice + 2;
+        }
+      } else {
+        col1 += 14;
+        col2 -= 2;
+        if (col1 > col2) {
+          this.heights.stdno = col1;
+        } else {
+          const splitHeight = (col2 - col1) / 2;
+          this.heights.stdinfo = splitHeight + this.orgHeights.stdinfo + 2;
+          this.heights.notice = splitHeight + this.orgHeights.notice + 2;
+        }
+      }
+    },
+  },
+};
+</script>

+ 48 - 0
src/modules/card/elements/card-head/CardHeadSample.vue

@@ -0,0 +1,48 @@
+<template>
+  <div class="card-head-sample">
+    <div class="page-box">
+      <!-- inner edit area -->
+      <div class="page-main-inner">
+        <div :class="['page-main', `page-main-${page.columns.length}`]">
+          <div class="page-column">
+            <div class="page-column-main">
+              <div class="page-column-body">
+                <edit-card-head
+                  id="simple-card-head"
+                  :data="cardHeadData"
+                ></edit-card-head>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+import EditCardHead from "./CardHead";
+import { getCardHeadModel } from "../../elementModel";
+
+export default {
+  name: "CardHeadSample",
+  components: { EditCardHead },
+  data() {
+    return {};
+  },
+  computed: {
+    ...mapState("card", ["cardConfig", "pages"]),
+    page() {
+      return this.pages[0];
+    },
+    cardHeadData() {
+      const data = getCardHeadModel(this.cardConfig);
+      data.isSimple = true;
+      return data;
+    },
+  },
+  mounted() {},
+  methods: {},
+};
+</script>

+ 156 - 0
src/modules/card/elements/card-head/cardHeadSpin/HeadDynamic.vue

@@ -0,0 +1,156 @@
+<template>
+  <div :class="classes">
+    <!-- write -->
+    <div
+      v-if="data.examNumberStyle !== 'FILL' && data.writeSign"
+      class="head-dynamic-part head-dynamic-write"
+    >
+      <div class="stdinfo-item">
+        <span>手写签名</span>
+        <span>:</span>
+        <span></span>
+      </div>
+      <p>
+        注意:签名则表示您认可答题卡提供的信息与您本人信息相符;如签名与信息不符或者未签名,试卷作废。
+      </p>
+    </div>
+    <!-- file -->
+    <div class="head-dynamic-part head-dynamic-fill">
+      <div class="head-dynamic-content">
+        <p><span>正确填涂:</span><i></i></p>
+        <p>
+          <span>错误填涂:</span>
+          <i>√</i>
+          <i>×</i>
+          <i></i>
+          <i></i>
+        </p>
+      </div>
+    </div>
+    <!-- miss discipline -->
+    <div
+      v-if="data.examAbsent || data.discipline"
+      class="head-dynamic-part head-dynamic-missfill"
+    >
+      <div v-if="data.examAbsent" class="head-dynamic-miss">
+        <div class="head-dynamic-content">
+          <span class="dynamic-miss-title">缺考标记</span>
+          <span class="dynamic-miss-body"
+            ><i id="dynamic-miss-area" class="head-dynamic-rect"></i
+          ></span>
+        </div>
+      </div>
+      <div v-if="data.discipline" class="head-dynamic-miss">
+        <div class="head-dynamic-content">
+          <span class="dynamic-miss-title">违纪标记</span>
+          <span class="dynamic-miss-body"
+            ><i id="dynamic-miss-area" class="head-dynamic-rect"></i
+          ></span>
+        </div>
+      </div>
+    </div>
+    <!-- aorb -->
+    <div
+      v-if="data.aOrB"
+      id="head-dynamic-aorb"
+      :class="[
+        'head-dynamic-part',
+        'head-dynamic-aorb',
+        `head-dynamic-aorb-${data.paperType.toLowerCase()}`,
+      ]"
+    >
+      <div class="dynamic-aorb-item dynamic-aorb-title">
+        <p class="dynamic-aorb-content">试卷类型:</p>
+      </div>
+      <div
+        v-if="data.paperType === 'FILL'"
+        class="dynamic-aorb-item dynamic-aorb-rects"
+      >
+        <div class="dynamic-aorb-content">
+          <span class="head-dynamic-rect"><i>A</i></span>
+          <span class="head-dynamic-rect"><i>B</i></span>
+        </div>
+      </div>
+      <!-- <div
+        class="dynamic-aorb-item dynamic-aorb-info"
+        v-if="data.paperType === 'PRINT'"
+      >
+        <div class="dynamic-aorb-content">
+          <i>{{ aorbBarcodeName }}</i>
+        </div>
+      </div> -->
+      <div
+        v-if="data.paperType === 'PRINT'"
+        id="dynamic-aorb-barcode"
+        class="dynamic-aorb-item dynamic-aorb-barcode"
+      >
+        <div class="dynamic-aorb-content">
+          <img v-if="aorbBarcodeSrc" :src="aorbBarcodeSrc" />
+          <img v-else src="../../../assets/images/barcode-sample-notext.png" />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { calcSum } from "../../../plugins/utils";
+
+export default {
+  name: "HeadDynamic",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      aorbBarcodeSrc:
+        this.data["fieldInfos"] && this.data["fieldInfos"]["paperType"],
+      aorbBarcodeName:
+        (this.data["fieldInfos"] && this.data["fieldInfos"]["paperTypeName"]) ||
+        "A",
+    };
+  },
+  computed: {
+    classes() {
+      let partNum = 1;
+      if (this.data.examNumberStyle !== "FILL" && this.data.writeSign)
+        partNum++;
+      if (this.data.aOrB) partNum++;
+      if (this.data.examAbsent || this.data.discipline) partNum++;
+
+      return ["head-dynamic", "card-head-body-spin", `head-dynamic-${partNum}`];
+    },
+  },
+  mounted() {
+    this.initStyles();
+  },
+  methods: {
+    initStyles() {
+      const { examNumberStyle, columnNumber, pageSize } = this.data;
+      if (
+        examNumberStyle === "FILL" ||
+        (pageSize === "A3" && columnNumber !== 2) ||
+        (pageSize === "A4" && columnNumber !== 1)
+      )
+        return;
+      const parentHeight = this.$el.parentNode.offsetHeight;
+      this.$el.style.height = parentHeight + "px";
+      const childrenCount = this.$el.children.length;
+      if (childrenCount > 1) {
+        let heights = [];
+        for (let i = 0; i < childrenCount; i++) {
+          heights[i] = this.$el.children[i].offsetHeight;
+        }
+        const lastChildHeight = parentHeight - calcSum(heights.slice(0, -1));
+        this.$el.children[childrenCount - 1].style.height =
+          lastChildHeight + "px";
+      }
+    },
+  },
+};
+</script>

+ 41 - 0
src/modules/card/elements/card-head/cardHeadSpin/HeadNotice.vue

@@ -0,0 +1,41 @@
+<template>
+  <div :class="classes">
+    <h4>注意事项:</h4>
+    <div v-for="(cont, index) in notices" :key="index" class="head-notice-cont">
+      <span>{{ index + 1 }}、</span>
+      <span>{{ cont }}</span>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "HeadNotice",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    classes() {
+      return [
+        "head-notice",
+        "card-head-body-spin",
+        {
+          "head-notice-exam-number-fill": this.data.examNumberStyle === "fill",
+        },
+      ];
+    },
+    notices() {
+      return this.data.attention.split("\n") || [];
+    },
+  },
+  methods: {},
+};
+</script>

+ 55 - 0
src/modules/card/elements/card-head/cardHeadSpin/HeadStdinfo.vue

@@ -0,0 +1,55 @@
+<template>
+  <div class="head-stdinfo card-head-body-spin">
+    <div v-for="(info, index) in fields" :key="index" class="stdinfo-item">
+      <span :style="paramStyle">{{ info.name }}</span>
+      <span>:</span>
+      <span>{{ fieldInfos[info.code] }}</span>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "HeadStdinfo",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      fieldInfos: this.data["fieldInfos"] || {},
+      fields: [],
+      paramStyle: {},
+      lenWidths: {
+        3: 44,
+        4: 62,
+        5: 72,
+        6: 86,
+        7: 100,
+        8: 114,
+      },
+    };
+  },
+  created() {
+    this.init();
+  },
+  methods: {
+    init() {
+      this.fields = [
+        ...this.data.requiredFields,
+        ...this.data.extendFields,
+      ].filter((item) => item.enable);
+      const nameNums = this.fields.map((item) => item.name.length);
+      const maxNameLen = Math.max.apply(null, nameNums);
+      const num = maxNameLen < 3 ? 3 : maxNameLen > 8 ? 8 : maxNameLen;
+      this.paramStyle = {
+        width: this.lenWidths[num] + "px",
+      };
+    },
+  },
+};
+</script>

+ 57 - 0
src/modules/card/elements/card-head/cardHeadSpin/HeadStdno.vue

@@ -0,0 +1,57 @@
+<template>
+  <div :class="classes">
+    <div v-if="data.examNumberStyle === 'PASTE'" class="stdno-empty">
+      <p class="">粘贴条形码区</p>
+    </div>
+    <div v-if="data.examNumberStyle === 'PRINT'" class="stdno-auto">
+      <div class="stdno-auto-barcode">
+        <img v-if="examNumberBarcodeSrc" :src="examNumberBarcodeSrc" />
+        <img v-else src="../../../assets/images/barcode-sample-notext.png" />
+        <p>{{ examNumberBarcodeName || "123456789" }}</p>
+      </div>
+    </div>
+    <div v-if="data.examNumberStyle === 'FILL'" class="stdno-fill">
+      <div class="stdno-fill-head">
+        <h5>准考证号</h5>
+        <div class="stdno-fill-rect">
+          <div v-for="n in 13" :key="n" class="stdno-fill-number"></div>
+        </div>
+      </div>
+      <div class="stdno-fill-body">
+        <div v-for="n in 13" :key="n" class="stdno-fill-list">
+          <div v-for="m in 10" :key="m" class="stdno-fill-option">
+            <i>{{ m - 1 }}</i>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "HeadStdno",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      examNumberBarcodeSrc:
+        this.data["fieldInfos"] && this.data["fieldInfos"]["examNumber"],
+      examNumberBarcodeName:
+        this.data["fieldInfos"] && this.data["fieldInfos"]["examNumberStr"],
+    };
+  },
+  computed: {
+    classes() {
+      return ["head-stdno", "card-head-body-spin"];
+    },
+  },
+  methods: {},
+};
+</script>

+ 33 - 0
src/modules/card/elements/card-head/model.js

@@ -0,0 +1,33 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+
+const MODEL = {
+  type: "CARD_HEAD",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 0,
+  cardTitle: "",
+  cardDesc: "",
+  aOrB: false,
+  paperType: "PRINT", // PRINT: "印刷",FILL: "填涂"
+  examAbsent: true,
+  writeSign: true,
+  examNumberStyle: "PRINT", // PRINT:印刷条码, PASTE:粘贴条码, FILL:考号填涂
+  businessParams: [],
+  attention: [],
+  objectiveAttention: [],
+  subjectiveAttention: [],
+  columnNumber: 2,
+  isSimple: false, // 是否是简化形式
+  sign: "head",
+};
+
+const getModel = (cardConfig) => {
+  const model = Object.assign({}, deepCopy(MODEL), cardConfig);
+  model.id = getElementId();
+  model.key = randomCode();
+
+  return model;
+};
+
+export { MODEL, getModel };

+ 70 - 0
src/modules/card/elements/composition/EditComposition.vue

@@ -0,0 +1,70 @@
+<template>
+  <div class="edit-composition">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="100px"
+    >
+      <el-form-item prop="topicName" label="题目名称:">
+        <el-input
+          v-model="modalForm.topicName"
+          type="textarea"
+          :autosize="{ minRows: 2, maxRows: 5 }"
+          resize="none"
+          placeholder="请输入题目名称"
+          clearable
+        ></el-input>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+const initModalForm = {
+  id: "",
+  topicName: "",
+};
+
+export default {
+  name: "EditComposition",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      modalForm: { ...initModalForm },
+      rules: {
+        topicName: [
+          {
+            required: true,
+            message: "请输入题目名称",
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      const valInfo = val.parent || val;
+      this.modalForm = { ...valInfo };
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+      this.modalForm.topicName = this.modalForm.topicName.trim();
+      this.$emit("modified", this.modalForm);
+    },
+  },
+};
+</script>

+ 48 - 0
src/modules/card/elements/composition/ElemComposition.vue

@@ -0,0 +1,48 @@
+<template>
+  <div class="elem-composition">
+    <div v-if="data.showTitle" ref="ElemTitle" class="elem-title">
+      {{ data.parent.topicName }}
+    </div>
+    <div ref="ElemBody" class="elem-body">
+      <div class="elem-composition-elements">
+        <elem-composition-element
+          v-for="element in data.elements"
+          :key="element.id"
+          :data="element"
+        ></elem-composition-element>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import ElemCompositionElement from "./ElemCompositionElement";
+
+export default {
+  name: "ElemComposition",
+  components: { ElemCompositionElement },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  mounted() {
+    this.modifyBodyStyle();
+  },
+  methods: {
+    modifyBodyStyle() {
+      let height = this.data.h;
+      if (this.data.showTitle) {
+        height = this.data.h - this.$refs.ElemTitle.clientHeight;
+      }
+      this.$refs.ElemBody.style.height = height + "px";
+    },
+  },
+};
+</script>

+ 138 - 0
src/modules/card/elements/composition/ElemCompositionEdit.vue

@@ -0,0 +1,138 @@
+<template>
+  <div class="elem-composition elem-composition-edit">
+    <div v-if="data.showTitle" ref="ElemTitle" class="elem-title">
+      {{ data.parent.topicName }}
+    </div>
+    <div class="elem-body" :style="bodyStyle">
+      <div
+        class="elem-composition-elements"
+        @drop.prevent="dropInnerElement($event)"
+        @dragover.prevent
+        @dragleave.prevent
+      >
+        <elem-composition-element-edit
+          v-for="element in data.elements"
+          :key="element.id"
+          :data="element"
+          :transform-fit="rebuildGuides"
+          @resize-over="elementResizeOver"
+        ></elem-composition-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 ElemCompositionElementEdit from "./ElemCompositionElementEdit";
+import guideLinesMixins from "../../mixins/guideLines";
+
+export default {
+  name: "ElemCompositionEdit",
+  components: { ElemCompositionElementEdit },
+  mixins: [guideLinesMixins],
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      bodyStyle: {},
+    };
+  },
+  computed: {
+    ...mapState("card", ["curDragElement"]),
+  },
+  watch: {
+    "data.parent": {
+      handler() {
+        this.modifyBodyStyle();
+      },
+    },
+  },
+  mounted() {
+    this.modifyBodyStyle();
+  },
+  methods: {
+    ...mapMutations("card", ["setCurDragElement", "setCurElement"]),
+    ...mapActions("card", ["rebuildPages", "modifyElementChild"]),
+    modifyBodyStyle() {
+      this.$nextTick(() => {
+        let height = this.data.h;
+        if (this.data.showTitle) {
+          height = this.data.h - this.$refs.ElemTitle.clientHeight;
+        }
+        this.bodyStyle = {
+          height: height + "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,
+        },
+      };
+      if (["LINES", "GRIDS"].includes(curElement.type)) {
+        curElement.w = this.data.parent.w;
+        curElement.x = 0;
+      }
+      this.elementResizeOver(curElement);
+      this.setCurDragElement({});
+      this.setCurElement(curElement);
+    },
+    getOffsetInfo(dom, endParentClass = "elem-composition-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>

+ 60 - 0
src/modules/card/elements/composition/ElemCompositionElement.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="elem-composition-element">
+    <div :id="data.id" :class="classes" :style="styles">
+      <component :is="compName" :data="data"></component>
+    </div>
+  </div>
+</template>
+
+<script>
+import ElemText from "../text/ElemText";
+import ElemImage from "../image/ElemImage";
+import ElemLine from "../line/ElemLine";
+import ElemLines from "../lines/ElemLines";
+import ElemGrids from "../grids/ElemGrids";
+
+export default {
+  name: "ElemCompositionElement",
+  components: {
+    ElemText,
+    ElemImage,
+    ElemLine,
+    ElemLines,
+    ElemGrids,
+  },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    compName() {
+      if (this.data.type.includes("LINE_")) return "elem-line";
+      return `elem-${this.data.type.toLowerCase()}`;
+    },
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    classes() {
+      return [
+        "composition-element-body",
+        `composition-element-${this.elementName}`,
+      ];
+    },
+    styles() {
+      return {
+        left: this.data.x + "px",
+        top: this.data.y + "px",
+        width: this.data.w + "px",
+        height: this.data.h + "px",
+      };
+    },
+  },
+};
+</script>

+ 120 - 0
src/modules/card/elements/composition/ElemCompositionElementEdit.vue

@@ -0,0 +1,120 @@
+<template>
+  <div class="elem-composition-element elem-composition-element-edit">
+    <element-resize
+      v-model="elemData"
+      :class="{ 'element-resize-act': curElement.id === data.id }"
+      :active="active"
+      :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 ElemText from "../text/ElemText";
+import ElemImage from "../image/ElemImage";
+import ElemLine from "../line/ElemLine";
+import ElemLines from "../lines/ElemLines";
+import ElemGrids from "../grids/ElemGrids";
+
+export default {
+  name: "ElemCompositionElementEdit",
+  components: {
+    ElemText,
+    ElemImage,
+    ElemLine,
+    ElemLines,
+    ElemGrids,
+    ElementResize,
+  },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+    transformFit: {
+      type: Function,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      elemData: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0,
+      },
+      styles: {},
+      actives: {
+        TEXT: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        IMAGE: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        LINES: [],
+        GRIDS: [],
+        LINE_HORIZONTAL: ["l", "r"],
+        LINE_VERTICAL: ["t", "b"],
+      },
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement"]),
+    compName() {
+      if (this.data.type.includes("LINE_")) return "elem-line";
+      return `elem-${this.data.type.toLowerCase()}`;
+    },
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    classes() {
+      return [
+        "composition-element-body",
+        `composition-element-${this.elementName}`,
+      ];
+    },
+    active() {
+      return this.actives[this.data.type];
+    },
+  },
+  created() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("card", ["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",
+      };
+    },
+    resizeOver() {
+      this.$emit("resize-over", Object.assign({}, this.data, this.elemData));
+    },
+    activeCurElement() {
+      this.setCurElement(this.data);
+    },
+  },
+};
+</script>

+ 69 - 0
src/modules/card/elements/composition/model.js

@@ -0,0 +1,69 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+import { getModel as createLines } from "../lines/model";
+
+const COMPOSITION_PROP = {
+  type: "COMPOSITION",
+  sign: "subjective",
+  topicNo: null,
+  topicName: "",
+};
+
+const MODEL = {
+  type: "COMPOSITION",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 350,
+  minHeight: 60,
+  sign: "subjective",
+  topicNo: null,
+  isCovered: false,
+  // 是否是最后一个答题区,初始只有一个答题区,默认为true
+  isLast: true,
+  // 是否是扩展的答题区
+  isExtend: false,
+  // 是否展示作文题题目内容,作文题第1个答题区需要显示作文题题目内容
+  showTitle: true,
+  // 答题区序号,默认为0
+  serialNumber: 0,
+  // 每一个作文题都可以包含其他基础元件
+  // 新建作文题,默认插入5线的多横线
+  elements: [],
+  // 作文题整体信息,COMPOSITION_PROP
+  parent: {},
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...COMPOSITION_PROP,
+  };
+};
+
+const getFullModel = (compositionProp) => {
+  const parent = { ...compositionProp };
+
+  let model = {
+    id: getElementId(),
+    key: randomCode(),
+    ...deepCopy(MODEL),
+  };
+  model.w = parent.w;
+  model.parent = parent;
+  model.topicNo = parent.topicNo;
+
+  let linesModel = createLines();
+  linesModel.lineCount = 5;
+  linesModel.h = linesModel.lineCount * (linesModel.lineSpacing + 3);
+  linesModel.w = parent.w;
+  linesModel.container = {
+    id: model.id,
+    type: model.type,
+  };
+  model.h = linesModel.h + 50;
+  model.elements.push(linesModel);
+  return model;
+};
+
+export { MODEL, getModel, getFullModel };

+ 121 - 0
src/modules/card/elements/explain/EditExplain.vue

@@ -0,0 +1,121 @@
+<template>
+  <div class="edit-explain">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="100px"
+    >
+      <el-form-item prop="topicName" label="题目名称:">
+        <el-input
+          v-model="modalForm.topicName"
+          type="textarea"
+          :autosize="{ minRows: 2, maxRows: 5 }"
+          resize="none"
+          placeholder="请输入题目名称"
+          clearable
+        ></el-input>
+      </el-form-item>
+      <el-form-item prop="startEnd" label="起止题号:">
+        <el-input-number
+          v-model="modalForm.startNumber"
+          style="width: 40px"
+          :min="0"
+          :max="999"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+        <span class="el-input-split"></span>
+        <el-input-number
+          v-model="modalForm.endNumber"
+          style="width: 40px"
+          :min="0"
+          :max="999"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+const initModalForm = {
+  id: "",
+  topicName: "",
+  startNumber: 1,
+  endNumber: 4,
+  questionsCount: 4,
+};
+
+export default {
+  name: "EditExplain",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    const numberRangeValidater = (rule, value, callback) => {
+      if (!this.modalForm.startNumber || !this.modalForm.endNumber) {
+        return callback(new Error("请输入起止题号"));
+      }
+      if (this.modalForm.startNumber > this.modalForm.endNumber) {
+        callback(new Error("开始题号不能大于结束题号"));
+      } else {
+        callback();
+      }
+    };
+    return {
+      modalForm: { ...initModalForm },
+      rules: {
+        topicName: [
+          {
+            required: true,
+            message: "请输入题目名称",
+            trigger: "change",
+          },
+        ],
+        endNumber: [
+          {
+            required: true,
+            message: "请输入起止题号",
+            trigger: "change",
+          },
+          {
+            type: "number",
+            validator: numberRangeValidater,
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      const valInfo = val.parent || val;
+      this.modalForm = { ...valInfo };
+      this.modalForm.endNumber =
+        this.modalForm.startNumber + this.modalForm.questionsCount - 1;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      this.modalForm.questionsCount =
+        this.modalForm.endNumber - this.modalForm.startNumber + 1;
+      this.modalForm.topicName = this.modalForm.topicName.trim();
+      this.$emit("modified", this.modalForm);
+    },
+  },
+};
+</script>

+ 55 - 0
src/modules/card/elements/explain/ElemExplain.vue

@@ -0,0 +1,55 @@
+<template>
+  <div class="elem-explain">
+    <div v-if="data.showTitle" ref="ElemTitle" class="elem-title">
+      {{ data.parent.topicName }}
+    </div>
+    <div ref="ElemBody" class="elem-body">
+      <div
+        v-if="data.parent.questionsCount > 1 && !data.isExtend"
+        class="elem-explain-no"
+      >
+        {{ data.serialNumber }}、
+      </div>
+      <!-- 解答题子元件区域 -->
+      <div class="elem-explain-elements">
+        <elem-explain-element
+          v-for="element in data.elements"
+          :key="element.id"
+          :data="element"
+        ></elem-explain-element>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import ElemExplainElement from "./ElemExplainElement";
+
+export default {
+  name: "ElemExplain",
+  components: { ElemExplainElement },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  mounted() {
+    this.modifyBodyStyle();
+  },
+  methods: {
+    modifyBodyStyle() {
+      let height = this.data.h;
+      if (this.data.showTitle) {
+        height = this.data.h - this.$refs.ElemTitle.clientHeight;
+      }
+      this.$refs.ElemBody.style.height = height + "px";
+    },
+  },
+};
+</script>

+ 141 - 0
src/modules/card/elements/explain/ElemExplainEdit.vue

@@ -0,0 +1,141 @@
+<template>
+  <div class="elem-explain elem-explain-edit">
+    <div v-if="data.showTitle" ref="ElemTitle" class="elem-title">
+      {{ data.parent.topicName }}
+    </div>
+    <div class="elem-body" :style="bodyStyle">
+      <div
+        v-if="data.parent.questionsCount > 1 && !data.isExtend"
+        class="elem-explain-no"
+      >
+        {{ data.serialNumber }}、
+      </div>
+      <!-- 解答题子元件编辑区域 -->
+      <div
+        class="elem-explain-elements"
+        @drop.prevent="dropInnerElement($event)"
+        @dragover.prevent
+        @dragleave.prevent
+      >
+        <elem-explain-element-edit
+          v-for="element in data.elements"
+          :key="element.id"
+          :data="element"
+          :transform-fit="rebuildGuides"
+          @resize-over="elementResizeOver"
+        ></elem-explain-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 ElemExplainElementEdit from "./ElemExplainElementEdit";
+import guideLinesMixins from "../../mixins/guideLines";
+
+export default {
+  name: "ElemExplainEdit",
+  components: { ElemExplainElementEdit },
+  mixins: [guideLinesMixins],
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      bodyStyle: {},
+    };
+  },
+  computed: {
+    ...mapState("card", ["curDragElement"]),
+  },
+  watch: {
+    "data.parent": {
+      handler() {
+        this.modifyBodyStyle();
+      },
+    },
+  },
+  mounted() {
+    this.modifyBodyStyle();
+  },
+  methods: {
+    ...mapMutations("card", ["setCurDragElement", "setCurElement"]),
+    ...mapActions("card", ["rebuildPages", "modifyElementChild"]),
+    modifyBodyStyle() {
+      this.$nextTick(() => {
+        let height = this.data.h;
+        if (this.data.showTitle) {
+          height = this.data.h - this.$refs.ElemTitle.clientHeight;
+        }
+        this.bodyStyle = {
+          height: height + "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-explain-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>

+ 58 - 0
src/modules/card/elements/explain/ElemExplainElement.vue

@@ -0,0 +1,58 @@
+<template>
+  <div class="elem-explain-element">
+    <div :id="data.id" :class="classes" :style="styles">
+      <component :is="compName" :data="data"></component>
+    </div>
+  </div>
+</template>
+
+<script>
+import ElemText from "../text/ElemText";
+import ElemImage from "../image/ElemImage";
+import ElemLine from "../line/ElemLine";
+import ElemLines from "../lines/ElemLines";
+import ElemGrids from "../grids/ElemGrids";
+
+export default {
+  name: "ElemExplainElement",
+  components: {
+    ElemText,
+    ElemImage,
+    ElemLine,
+    ElemLines,
+    ElemGrids,
+  },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    compName() {
+      if (this.data.type.includes("LINE_")) return "elem-line";
+      return `elem-${this.data.type.toLowerCase()}`;
+    },
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    classes() {
+      return ["explain-element-body", `explain-element-${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>

+ 117 - 0
src/modules/card/elements/explain/ElemExplainElementEdit.vue

@@ -0,0 +1,117 @@
+<template>
+  <div class="elem-explain-element elem-explain-element-edit">
+    <element-resize
+      v-model="elemData"
+      :class="{ 'element-resize-act': curElement.id === data.id }"
+      :active="active"
+      :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 ElemText from "../text/ElemText";
+import ElemImage from "../image/ElemImage";
+import ElemLine from "../line/ElemLine";
+import ElemLines from "../lines/ElemLines";
+import ElemGrids from "../grids/ElemGrids";
+
+export default {
+  name: "ElemExplainElementEdit",
+  components: {
+    ElemText,
+    ElemImage,
+    ElemLine,
+    ElemLines,
+    ElemGrids,
+    ElementResize,
+  },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+    transformFit: {
+      type: Function,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      elemData: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0,
+      },
+      styles: {},
+      actives: {
+        TEXT: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        IMAGE: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        LINES: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        GRIDS: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        LINE_HORIZONTAL: ["l", "r"],
+        LINE_VERTICAL: ["t", "b"],
+      },
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement"]),
+    compName() {
+      if (this.data.type.includes("LINE_")) return "elem-line";
+      return `elem-${this.data.type.toLowerCase()}`;
+    },
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    classes() {
+      return ["explain-element-body", `explain-element-${this.elementName}`];
+    },
+    active() {
+      return this.actives[this.data.type];
+    },
+  },
+  created() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("card", ["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",
+      };
+    },
+    resizeOver() {
+      this.$emit("resize-over", Object.assign({}, this.data, this.elemData));
+    },
+    activeCurElement() {
+      this.setCurElement(this.data);
+    },
+  },
+};
+</script>

+ 67 - 0
src/modules/card/elements/explain/model.js

@@ -0,0 +1,67 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+
+const EXPLAIN_PROP = {
+  type: "EXPLAIN",
+  sign: "subjective",
+  topicNo: null,
+  topicName: "",
+  startNumber: 1,
+  questionsCount: 1,
+};
+// 解答题-小题
+const MODEL = {
+  type: "EXPLAIN",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 458,
+  minHeight: 60,
+  sign: "subjective",
+  topicNo: null,
+  isCovered: false,
+  // 是否是小题的最后一个答题区,初始每个小题只有一个答题区,默认为true
+  isLast: true,
+  // 是否是小题扩展的区域
+  isExtend: false,
+  // 是否展示解答题题目内容,解答题第1小题的第1个答题区需要显示解答题题目内容
+  showTitle: false,
+  // 小题序号
+  serialNumber: 0,
+  // 每一个解答题小题都可以包含其他基础元件,这些基础元件都用绝对定位
+  elements: [],
+  // 解答题整体信息,EXPLAIN_PROP
+  parent: {},
+};
+// tip属性存在时的条件:parent:大题的小题,container:题目内的子元素
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...EXPLAIN_PROP,
+  };
+};
+
+const getFullModel = (explainProp) => {
+  const parent = { ...explainProp };
+
+  let elements = [];
+
+  for (let i = 0; i < explainProp.questionsCount; i++) {
+    let child = Object.assign({}, deepCopy(MODEL), {
+      id: getElementId(),
+      key: randomCode(),
+      w: parent.w,
+      topicNo: parent.topicNo,
+      serialNumber: i + explainProp.startNumber,
+      parent,
+    });
+
+    elements[i] = child;
+  }
+  elements[0].showTitle = true;
+
+  return elements;
+};
+
+export { EXPLAIN_PROP, MODEL, getModel, getFullModel };

+ 142 - 0
src/modules/card/elements/fill-field/EditFillField.vue

@@ -0,0 +1,142 @@
+<template>
+  <div class="edit-fill-field">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="120px"
+    >
+      <el-form-item prop="fieldCountPerLine" label="每行变量数:">
+        <el-input-number
+          v-model="modalForm.fieldCountPerLine"
+          style="width: 125px"
+          :min="1"
+          :max="10"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+        <span class="el-input-tips">*指一行显示变量数量</span>
+      </el-form-item>
+      <el-form-item prop="lineSpacing" label="空位上下间距:">
+        <el-input-number
+          v-model.number="modalForm.lineSpacing"
+          style="width: 125px"
+          :min="20"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="fields" label="变量:">
+        <el-select
+          v-model="modalForm.fields"
+          placeholder="请选择"
+          multiple
+          clearable
+          style="width: 100%"
+        >
+          <el-option
+            v-for="item in fieldList"
+            :key="item.code"
+            :label="item.name"
+            :value="item.code"
+          >
+          </el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-checkbox v-model="modalForm.nameIsJustify"
+          >变量名是否两端对齐</el-checkbox
+        >
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { deepCopy, objAssign } from "../../plugins/utils";
+import { mapState } from "vuex";
+
+const initModalForm = {
+  id: "",
+  fieldCountPerLine: 1,
+  lineSpacing: 30,
+  nameIsJustify: false,
+  fields: [],
+};
+
+export default {
+  name: "EditFillField",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      modalForm: { ...initModalForm },
+      fieldList: [],
+      rules: {
+        fields: [
+          {
+            required: true,
+            message: "请选择变量",
+            trigger: "change",
+          },
+        ],
+        lineSpacing: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入空位上下间距",
+            trigger: "change",
+          },
+        ],
+        fieldCountPerLine: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入每行变量数",
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  computed: {
+    ...mapState("free", ["cardConfig"]),
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.fieldList = [
+        ...this.cardConfig.requiredFields,
+        ...this.cardConfig.extendFields,
+      ].filter((item) => item.enable);
+
+      this.modalForm = deepCopy(val);
+      this.modalForm.fields = val.fields.map((item) => item.code);
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      const data = deepCopy(this.modalForm);
+      data.fields = this.fieldList.filter((item) =>
+        this.modalForm.fields.includes(item.code)
+      );
+      const model = objAssign(this.instance, data);
+
+      this.$emit("modified", model);
+    },
+  },
+};
+</script>

+ 87 - 0
src/modules/card/elements/fill-field/ElemFillField.vue

@@ -0,0 +1,87 @@
+<template>
+  <div class="elem-fill-field">
+    <div
+      v-for="(info, index) in data.fields"
+      :key="index"
+      class="fill-field-item"
+      :style="itemStyles"
+    >
+      <div class="fill-field-content" :style="lineStyles">
+        <span :style="paramStyle">{{ info.name }}</span>
+        <span>:</span>
+        <span>{{ fieldInfos[info.code] }}</span>
+      </div>
+    </div>
+    <div v-if="!data.fields.length" class="fill-field-item" :style="itemStyles">
+      <div class="fill-field-content" :style="lineStyles">
+        <span :style="paramStyle">变量名</span>
+        <span>:</span>
+        <span>12345</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "ElemFillField",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      fieldInfos: this.data["fieldInfos"] || {},
+      questions: [],
+      paramStyle: {},
+      lenWidths: {
+        3: 44,
+        4: 62,
+        5: 72,
+        6: 86,
+        7: 100,
+        8: 114,
+      },
+    };
+  },
+  computed: {
+    itemStyles() {
+      return {
+        width: 100 / this.data.fieldCountPerLine + "%",
+      };
+    },
+    lineStyles() {
+      return {
+        height: this.data.lineSpacing + "px",
+        paddingTop: this.data.lineSpacing - 26 + "px",
+      };
+    },
+  },
+  watch: {
+    data: {
+      immediate: true,
+      handler() {
+        this.init();
+      },
+    },
+  },
+  methods: {
+    init() {
+      if (this.data.nameIsJustify) {
+        const nameNums = this.data.fields.map((item) => item.name.length);
+        const maxNameLen = Math.max.apply(null, nameNums);
+        const num = maxNameLen < 3 ? 3 : maxNameLen > 8 ? 8 : maxNameLen;
+        this.paramStyle = {
+          width: this.lenWidths[num] + "px",
+        };
+      } else {
+        this.paramStyle = {};
+      }
+    },
+  },
+};
+</script>

+ 25 - 0
src/modules/card/elements/fill-field/model.js

@@ -0,0 +1,25 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+
+const MODEL = {
+  type: "FILL_FIELD",
+  x: 0,
+  y: 0,
+  w: 200,
+  h: 100,
+  sign: "",
+  fieldCountPerLine: 1,
+  lineSpacing: 30,
+  nameIsJustify: false,
+  fields: [],
+  fieldInfos: {},
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...deepCopy(MODEL),
+  };
+};
+
+export { MODEL, getModel };

+ 277 - 0
src/modules/card/elements/fill-line/EditFillLine.vue

@@ -0,0 +1,277 @@
+<template>
+  <div class="edit-fill-line">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="120px"
+    >
+      <el-form-item prop="topicName" label="题目名称:">
+        <el-input
+          v-model="modalForm.topicName"
+          type="textarea"
+          :autosize="{ minRows: 2, maxRows: 5 }"
+          resize="none"
+          placeholder="请输入题目名称"
+          clearable
+        ></el-input>
+      </el-form-item>
+      <el-form-item prop="endNumber" label="起止题号:">
+        <el-input-number
+          v-model="modalForm.startNumber"
+          style="width: 40px"
+          :min="0"
+          :max="999"
+          :step="1"
+          step-strictly
+          :controls="false"
+          @change="lineTypeChange"
+        ></el-input-number>
+        <span class="el-input-split"></span>
+        <el-input-number
+          v-model="modalForm.endNumber"
+          style="width: 40px"
+          :min="0"
+          :max="999"
+          :step="1"
+          step-strictly
+          :controls="false"
+          @change="lineTypeChange"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="lineSpacing" label="空位上下间距:">
+        <el-input-number
+          v-model.number="modalForm.lineSpacing"
+          style="width: 125px"
+          :min="20"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="questionNumberPerLine" label="每行空数:">
+        <el-input-number
+          v-model="modalForm.questionNumberPerLine"
+          style="width: 125px"
+          :min="1"
+          :max="10"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+        <span class="el-input-tips">*指一行显示空位数量</span>
+      </el-form-item>
+      <el-form-item label="题号前缀:">
+        <el-input
+          v-model.trim="modalForm.numberPre"
+          style="width: 125px"
+          :maxlength="6"
+          clearable
+        ></el-input>
+      </el-form-item>
+      <el-form-item label="空位排列方向:" required>
+        <el-radio-group v-model="modalForm.questionDirection" size="small">
+          <el-radio-button
+            v-for="(val, key) in DIRECTION_TYPE"
+            :key="key"
+            :label="key"
+            >{{ val }}</el-radio-button
+          >
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="小题空数类型:">
+        <el-radio-group
+          v-model="modalForm.questionLineType"
+          size="small"
+          @change="lineTypeChange"
+        >
+          <el-radio-button label="norm">标准</el-radio-button>
+          <el-radio-button label="custom">自定义</el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item
+        v-if="modalForm.questionLineType === 'norm'"
+        prop="lineNumberPerQuestion"
+        label="每题空数:"
+      >
+        <el-input-number
+          v-model="modalForm.lineNumberPerQuestion"
+          style="width: 125px"
+          :min="1"
+          :max="15"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+        <span class="el-input-tips">*指每一小题的空位数量</span>
+      </el-form-item>
+      <el-form-item v-else prop="questionLineNums" label="各小题空数:">
+        <table class="table table-white table-narrow">
+          <tr>
+            <th>题号</th>
+            <th>空数</th>
+          </tr>
+          <tr v-for="option in questionLineNumOptions" :key="option.no">
+            <td>{{ option.no }}</td>
+            <td>
+              <el-input-number
+                v-model="option.count"
+                size="mini"
+                :min="1"
+                :max="50"
+                :step="1"
+                step-strictly
+                :controls="false"
+                style="width: 125px"
+              ></el-input-number>
+            </td>
+          </tr>
+        </table>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { DIRECTION_TYPE } from "../../enumerate";
+
+const initModalForm = {
+  id: "",
+  topicName: "",
+  startNumber: 1,
+  endNumber: 2,
+  questionsCount: 2,
+  questionNumberPerLine: 1,
+  lineNumberPerQuestion: 1,
+  lineSpacing: 40,
+  questionDirection: "horizontal",
+  questionLineType: "norm",
+  questionLineNums: [],
+  numberPre: "",
+};
+
+export default {
+  name: "EditFillLine",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    const numberRangeValidater = (rule, value, callback) => {
+      if (!this.modalForm.startNumber || !this.modalForm.endNumber) {
+        return callback(new Error("请输入起止题号"));
+      }
+      if (this.modalForm.startNumber > this.modalForm.endNumber) {
+        callback(new Error("开始题号不能大于结束题号"));
+      } else {
+        callback();
+      }
+    };
+
+    return {
+      modalForm: { ...initModalForm },
+      DIRECTION_TYPE,
+      questionLineNumOptions: [{ no: 1, count: 1 }],
+      rules: {
+        topicName: [
+          {
+            required: true,
+            message: "请输入题目名称",
+            trigger: "change",
+          },
+        ],
+        endNumber: [
+          {
+            required: true,
+            message: "请输入起止题号",
+            trigger: "change",
+          },
+          {
+            type: "number",
+            validator: numberRangeValidater,
+            trigger: "change",
+          },
+        ],
+        lineSpacing: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入空位上下间距",
+            trigger: "change",
+          },
+        ],
+        questionNumberPerLine: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入每行空数",
+            trigger: "change",
+          },
+        ],
+        lineNumberPerQuestion: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入每题空数",
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      const valInfo = val.parent || val;
+      this.modalForm = { ...valInfo };
+      this.modalForm.endNumber =
+        this.modalForm.startNumber + this.modalForm.questionsCount - 1;
+      this.questionLineNumOptions = this.modalForm.questionLineNums;
+    },
+    lineTypeChange() {
+      // check start end number
+      if (
+        !this.modalForm.startNumber ||
+        !this.modalForm.endNumber ||
+        this.modalForm.startNumber > this.modalForm.endNumber
+      )
+        return;
+
+      if (this.modalForm.questionLineType === "custom") {
+        let questionLineNumOptions = [];
+        for (
+          let i = this.modalForm.startNumber;
+          i <= this.modalForm.endNumber;
+          i++
+        ) {
+          questionLineNumOptions.push({
+            no: i,
+            count: this.modalForm.lineNumberPerQuestion,
+          });
+        }
+        this.questionLineNumOptions = questionLineNumOptions;
+      } else {
+        this.questionLineNumOptions = [];
+      }
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      this.modalForm.questionsCount =
+        this.modalForm.endNumber - this.modalForm.startNumber + 1;
+      this.modalForm.questionLineNums = this.questionLineNumOptions;
+      this.modalForm.topicName = this.modalForm.topicName.trim();
+      this.$emit("modified", this.modalForm);
+    },
+  },
+};
+</script>

+ 90 - 0
src/modules/card/elements/fill-line/ElemFillLine.vue

@@ -0,0 +1,90 @@
+<template>
+  <div class="elem-fill-line">
+    <div
+      v-if="data.parent && data.startNumber === data.parent.startNumber"
+      class="elem-title"
+    >
+      {{ data.parent.topicName }}
+    </div>
+    <div v-if="data.questionDirection === 'vertical'" class="elem-body">
+      <ul
+        v-for="question in data.questionLineNums"
+        :key="question.no"
+        class="elem-fill-quesiton"
+        :style="groupStyles"
+      >
+        <li class="elem-fill-no">
+          <span :style="lineNoStyles"
+            >{{ data.numberPre }}{{ question.no }}.</span
+          >
+        </li>
+        <li
+          v-for="line in question.count"
+          :key="line"
+          class="elem-fill-line"
+          :style="lineStyles"
+        ></li>
+      </ul>
+    </div>
+    <div v-else class="elem-body">
+      <ul
+        v-for="line in data.questionLineNums[0].count"
+        :key="line"
+        class="elem-fill-quesiton"
+        :style="groupStyles"
+      >
+        <li v-if="line === 1" class="elem-fill-no">
+          <span :style="lineNoStyles">
+            {{ data.numberPre }}{{ data.questionLineNums[0].no }}.
+          </span>
+        </li>
+        <li
+          v-if="line !== data.questionLineNums[0].count"
+          class="elem-fill-comma"
+          :style="lineNoStyles"
+        >
+          ,
+        </li>
+        <li class="elem-fill-line" :style="lineStyles"></li>
+      </ul>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "ElemFillLine",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      questions: [],
+    };
+  },
+  computed: {
+    lineStyles() {
+      return {
+        height: this.data.lineSpacing + "px",
+      };
+    },
+    lineNoStyles() {
+      return {
+        top: this.data.lineSpacing - 4 + "px",
+      };
+    },
+    groupStyles() {
+      return {
+        width: 100 / this.data.questionNumberPerLine + "%",
+      };
+    },
+  },
+  mounted() {},
+  methods: {},
+};
+</script>

+ 105 - 0
src/modules/card/elements/fill-line/model.js

@@ -0,0 +1,105 @@
+import { getElementId, randomCode } from "../../plugins/utils";
+
+const MODEL = {
+  type: "FILL_LINE",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 40,
+  minHeight: 40,
+  sign: "subjective",
+  topicName: "",
+  topicNo: null,
+  startNumber: 1,
+  questionsCount: 4,
+  questionNumberPerLine: 2,
+  lineNumberPerQuestion: 1,
+  lineSpacing: 40,
+  questionDirection: "vertical",
+  questionLineType: "norm",
+  questionLineNums: [],
+  numberPre: "",
+  isCovered: false,
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...MODEL,
+  };
+};
+
+const getFullModel = (model) => {
+  const parent = { ...model };
+  const numPerLine = model.questionNumberPerLine;
+  let elements = [];
+
+  let questionLineNums = model.questionLineNums;
+  if (model.questionLineType === "norm") {
+    questionLineNums = [];
+    for (
+      let j = model.startNumber;
+      j < model.startNumber + model.questionsCount;
+      j++
+    ) {
+      questionLineNums.push({
+        no: j,
+        count: model.lineNumberPerQuestion,
+      });
+    }
+  }
+
+  if (model.questionDirection === "vertical") {
+    const total = Math.ceil(model.questionsCount / numPerLine);
+    for (let i = 0; i < total; i++) {
+      const childQuestionLineNums = questionLineNums.slice(
+        i * numPerLine,
+        (i + 1) * numPerLine
+      );
+      const maxLineNumberPerQuestion = Math.max.apply(
+        null,
+        childQuestionLineNums.map((item) => item.count)
+      );
+      const questionHeight = model.lineSpacing * maxLineNumberPerQuestion;
+      let child = Object.assign({}, parent, {
+        id: getElementId(),
+        key: randomCode(),
+        h: i ? questionHeight : questionHeight + 34,
+        startNumber: model.startNumber + i * numPerLine,
+        questionsCount:
+          i === total - 1 ? model.questionsCount - numPerLine * i : numPerLine,
+        parent: parent,
+        isLast: i === total - 1,
+        questionLineNums: childQuestionLineNums,
+      });
+      child.minHeight = child.h;
+
+      elements[i] = child;
+    }
+  } else {
+    for (let i = 0; i < model.questionsCount; i++) {
+      const childQuestionLineNums = questionLineNums[i];
+      const maxLineNumberPerQuestion = Math.ceil(
+        childQuestionLineNums.count / numPerLine
+      );
+      const questionHeight = model.lineSpacing * maxLineNumberPerQuestion;
+      let child = Object.assign({}, parent, {
+        id: getElementId(),
+        h: i ? questionHeight : questionHeight + 34,
+        startNumber: model.startNumber + i,
+        questionsCount: 1,
+        parent: parent,
+        isLast: i === model.questionsCount - 1,
+        questionLineNums: [childQuestionLineNums],
+      });
+      child.minHeight = child.h;
+
+      elements[i] = child;
+    }
+  }
+
+  return elements;
+};
+
+export { MODEL, getModel, getFullModel };

+ 87 - 0
src/modules/card/elements/fill-number/EditFillNumber.vue

@@ -0,0 +1,87 @@
+<template>
+  <div class="edit-fill-number">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="100px"
+    >
+      <el-form-item prop="name" label="名称:">
+        <el-input
+          v-model="modalForm.name"
+          placeholder="请输入名称"
+          clearable
+        ></el-input>
+      </el-form-item>
+      <el-form-item prop="numberCount" label="号码数量:">
+        <el-input-number
+          v-model="modalForm.numberCount"
+          :max="20"
+          :min="1"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { objAssign } from "../../plugins/utils";
+
+const initModalForm = {
+  id: "",
+  name: "",
+  numberCount: 13,
+};
+
+export default {
+  name: "EditFillNumber",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    const numberValidater = (rule, value, callback) => {
+      if (!this.modalForm.numberCount) {
+        return callback(new Error("请输入号码数量"));
+      }
+
+      callback();
+    };
+    return {
+      modalForm: { ...initModalForm },
+      rules: {
+        name: [{ required: true, message: "请输入名称", trigger: "change" }],
+        numberCount: [
+          {
+            required: true,
+            validator: numberValidater,
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = objAssign(initModalForm, val);
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      this.$emit("modified", objAssign(this.instance, this.modalForm));
+    },
+  },
+};
+</script>

+ 52 - 0
src/modules/card/elements/fill-number/ElemFillNumber.vue

@@ -0,0 +1,52 @@
+<template>
+  <div class="elem-fill-number">
+    <div class="fill-number-head">
+      <h5>{{ data.name }}</h5>
+      <div class="fill-number-rect">
+        <div
+          v-for="n in data.numberCount"
+          :key="n"
+          class="fill-number-number"
+          :style="columnStyles"
+        ></div>
+      </div>
+    </div>
+    <div class="fill-number-body">
+      <div
+        v-for="n in data.numberCount"
+        :key="n"
+        class="fill-number-list"
+        :style="columnStyles"
+      >
+        <div v-for="m in 10" :key="m" class="fill-number-option">
+          <i>{{ m - 1 }}</i>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "ElemFillNumber",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    columnStyles() {
+      return {
+        width: (100 / this.data.numberCount).toFixed(2) + "%",
+      };
+    },
+  },
+  methods: {},
+};
+</script>

+ 23 - 0
src/modules/card/elements/fill-number/model.js

@@ -0,0 +1,23 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+
+const MODEL = {
+  type: "FILL_NUMBER",
+  x: 0,
+  y: 0,
+  w: 300,
+  h: 280,
+  sign: "",
+  name: "准考证号",
+  numberCount: 9,
+  content: "",
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...deepCopy(MODEL),
+  };
+};
+
+export { MODEL, getModel };

+ 139 - 0
src/modules/card/elements/fill-pane/EditFillPane.vue

@@ -0,0 +1,139 @@
+<template>
+  <div class="edit-fill-pane">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="120px"
+    >
+      <el-form-item prop="paneCount" label="方格数量:">
+        <el-input-number
+          v-model.number="modalForm.paneCount"
+          style="width: 125px"
+          :min="1"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="paneGap" label="方格间距:">
+        <el-input-number
+          v-model="modalForm.paneGap"
+          style="width: 125px"
+          :min="1"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="paneWidth" label="方格宽度:">
+        <el-input-number
+          v-model="modalForm.paneWidth"
+          style="width: 125px"
+          :min="1"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="paneHeight" label="方格高度:">
+        <el-input-number
+          v-model="modalForm.paneHeight"
+          style="width: 125px"
+          :min="1"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item label="方格边框形状:">
+        <line-style-select v-model="modalForm.borderStyle"></line-style-select>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { deepCopy } from "../../plugins/utils";
+import LineStyleSelect from "../../components/common/LineStyleSelect";
+
+const initModalForm = {
+  id: "",
+  paneGap: 6,
+  paneCount: 9,
+  paneWidth: 30,
+  paneHeight: 30,
+  borderStyle: "solid",
+};
+
+export default {
+  name: "EditFillPane",
+  components: { LineStyleSelect },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      modalForm: { ...initModalForm },
+      rules: {
+        paneGap: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入方格间距",
+            trigger: "change",
+          },
+        ],
+        paneCount: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入方格数量",
+            trigger: "change",
+          },
+        ],
+        paneWidth: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入方格宽度",
+            trigger: "change",
+          },
+        ],
+        paneHeight: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入方格高度",
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = deepCopy(val);
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      this.$emit("modified", this.modalForm);
+    },
+  },
+};
+</script>

+ 49 - 0
src/modules/card/elements/fill-pane/ElemFillPane.vue

@@ -0,0 +1,49 @@
+<template>
+  <div class="elem-fill-pane" :style="elemStyle">
+    <div
+      v-for="n in data.paneCount"
+      :key="n"
+      class="fill-pane-item"
+      :style="paneStyles"
+    >
+      <div class="fill-pane-cont" :style="contStyles"></div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "ElemFillPane",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    contStyles() {
+      return {
+        borderStyle: this.data.borderStyle,
+        width: this.data.paneWidth + "px",
+        height: this.data.paneHeight + "px",
+      };
+    },
+    paneStyles() {
+      return {
+        padding: this.data.paneGap / 2 + "px",
+      };
+    },
+    elemStyle() {
+      return {
+        padding: this.data.paneGap / 2 + "px",
+      };
+    },
+  },
+  methods: {},
+};
+</script>

+ 25 - 0
src/modules/card/elements/fill-pane/model.js

@@ -0,0 +1,25 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+
+const MODEL = {
+  type: "FILL_PANE",
+  x: 0,
+  y: 0,
+  w: 324,
+  h: 40,
+  sign: "",
+  paneGap: 5,
+  paneCount: 9,
+  paneWidth: 30,
+  paneHeight: 30,
+  borderStyle: "solid",
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...deepCopy(MODEL),
+  };
+};
+
+export { MODEL, getModel };

+ 257 - 0
src/modules/card/elements/fill-question/EditFillQuestion.vue

@@ -0,0 +1,257 @@
+<template>
+  <div class="edit-fill-question">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="120px"
+    >
+      <el-form-item prop="topicName" label="题目名称:">
+        <el-input
+          v-model="modalForm.topicName"
+          type="textarea"
+          :autosize="{ minRows: 2, maxRows: 5 }"
+          resize="none"
+          placeholder="请输入题目名称"
+          clearable
+        ></el-input>
+      </el-form-item>
+      <el-form-item prop="endNumber" label="起止题号:">
+        <el-input-number
+          v-model="modalForm.startNumber"
+          style="width: 40px"
+          :min="0"
+          :max="999"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+        <span class="el-input-split"></span>
+        <el-input-number
+          v-model="modalForm.endNumber"
+          style="width: 40px"
+          :min="modalForm.startNumber"
+          :max="999"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="optionCount" label="选项个数:">
+        <el-input-number
+          v-model="modalForm.optionCount"
+          style="width: 125px"
+          :min="2"
+          :max="22"
+          :step="1"
+          step-strictly
+          :controls="false"
+          :disabled="modalForm.isBoolean"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item label="小题排列方向:" required>
+        <el-radio-group v-model="modalForm.questionDirection" size="small">
+          <el-radio-button
+            v-for="(val, key) in DIRECTION_TYPE"
+            :key="key"
+            :label="key"
+            >{{ val }}</el-radio-button
+          >
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item>
+        <el-checkbox
+          v-model="modalForm.isMultiply"
+          :disabled="modalForm.isBoolean"
+          >多选</el-checkbox
+        >
+      </el-form-item>
+      <el-form-item>
+        <el-checkbox
+          v-model="modalForm.isBoolean"
+          :disabled="modalForm.isMultiply"
+          @change="selectTypeChange"
+          >判断题</el-checkbox
+        >
+        <el-select
+          v-if="modalForm.isBoolean"
+          v-model="modalForm.booleanType"
+          style="margin-left: 20px; width: 125px"
+          placeholder="请选择"
+          @change="booleanTypeChange"
+        >
+          <el-option
+            v-for="item in BOOLEAN_TYPE"
+            :key="item"
+            :label="item"
+            :value="item"
+          ></el-option>
+        </el-select>
+        <span v-if="modalForm.isBoolean">(备选是否配置)</span>
+      </el-form-item>
+      <el-form-item
+        v-if="modalForm.isBoolean"
+        prop="booleanType"
+        label="是否配置:"
+      >
+        <span>是:</span>
+        <el-input
+          v-model.trim="booleanTypes.yes"
+          :maxlength="1"
+          placeholder="是"
+          style="margin-right: 20px; width: 60px"
+        ></el-input>
+        <span>否:</span>
+        <el-input
+          v-model.trim="booleanTypes.no"
+          :maxlength="1"
+          placeholder="否"
+          style="margin-right: 20px; width: 60px"
+        ></el-input>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { BOOLEAN_TYPE, DIRECTION_TYPE } from "../../enumerate";
+
+const initModalForm = {
+  id: "",
+  topicName: "",
+  startNumber: 1,
+  endNumber: 5,
+  questionsCount: 10,
+  optionCount: 5,
+  questionDirection: "horizontal",
+  isBoolean: false,
+  booleanType: BOOLEAN_TYPE[0],
+  isMultiply: false,
+};
+
+export default {
+  name: "EditFillQuestion",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    const numberRangeValidater = (rule, value, callback) => {
+      if (!this.modalForm.startNumber || !this.modalForm.endNumber) {
+        return callback(new Error("请输入起止题号"));
+      }
+      if (this.modalForm.startNumber > this.modalForm.endNumber) {
+        callback(new Error("开始题号不能大于结束题号"));
+      } else {
+        callback();
+      }
+    };
+
+    const booleanTypeValidater = (rule, value, callback) => {
+      if (this.modalForm.isBoolean) {
+        if (
+          this.booleanTypes.yes &&
+          this.booleanTypes.no &&
+          this.booleanTypes.yes.length <= 2 &&
+          this.booleanTypes.no.length <= 2
+        ) {
+          callback();
+        } else {
+          callback(new Error("请设置是否配置,单个设置最多两个字符。"));
+        }
+      } else {
+        callback();
+      }
+    };
+
+    return {
+      modalForm: { ...initModalForm },
+      BOOLEAN_TYPE,
+      DIRECTION_TYPE,
+      booleanTypes: {
+        yes: "",
+        no: "",
+      },
+      rules: {
+        topicName: [
+          {
+            required: true,
+            message: "请输入题目名称",
+            trigger: "change",
+          },
+        ],
+        endNumber: [
+          {
+            required: true,
+            message: "请输入起止题号",
+            trigger: "change",
+          },
+          {
+            type: "number",
+            validator: numberRangeValidater,
+            trigger: "change",
+          },
+        ],
+        optionCount: [
+          {
+            required: true,
+            type: "number",
+            message: "请输入选项个数",
+            trigger: "change",
+          },
+        ],
+        booleanType: [
+          {
+            required: true,
+            validator: booleanTypeValidater,
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      const valInfo = val.parent || val;
+      this.modalForm = Object.assign({}, this.initModalForm, valInfo);
+      this.modalForm.endNumber =
+        this.modalForm.startNumber + this.modalForm.questionsCount - 1;
+      this.booleanTypeChange();
+    },
+    selectTypeChange(val) {
+      if (val) {
+        this.modalForm.optionCount = 2;
+        this.modalForm.isMultiply = false;
+        this.modalForm.booleanType = BOOLEAN_TYPE[0];
+        this.booleanTypeChange();
+      }
+    },
+    booleanTypeChange() {
+      const [yes, no] = this.modalForm.booleanType.split(",");
+      this.booleanTypes.yes = yes;
+      this.booleanTypes.no = no;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      this.modalForm.questionsCount =
+        this.modalForm.endNumber - this.modalForm.startNumber + 1;
+      this.modalForm.booleanType = [
+        this.booleanTypes.yes,
+        this.booleanTypes.no,
+      ].join();
+      this.modalForm.topicName = this.modalForm.topicName.trim();
+      this.$emit("modified", this.modalForm);
+    },
+  },
+};
+</script>

+ 142 - 0
src/modules/card/elements/fill-question/ElemFillQuestion.vue

@@ -0,0 +1,142 @@
+<template>
+  <div :class="classes">
+    <div v-if="isFirstSpin" class="elem-title">
+      {{ data.parent.topicName }}
+    </div>
+    <div class="elem-body">
+      <ul
+        v-for="(group, gindex) in questions"
+        :key="gindex"
+        class="group-item"
+        :style="gindex !== questions.length - 1 ? groupGapStyles : null"
+      >
+        <li
+          v-for="(question, qindex) in group"
+          :key="qindex"
+          class="question-item"
+          :style="getQuestionGapStyles(group, qindex)"
+        >
+          <span
+            v-for="(option, oindex) in question"
+            :key="oindex"
+            class="option-item"
+            :style="optionGapStyles"
+          >
+            <i>{{ option }}</i>
+          </span>
+        </li>
+      </ul>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "ElemFillQuestion",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      questions: [],
+    };
+  },
+  computed: {
+    isFirstSpin() {
+      return (
+        this.data.parent &&
+        this.data.startNumber === this.data.parent.startNumber
+      );
+    },
+    classes() {
+      return [
+        "elem-fill-question",
+        `elem-fill-question-${this.data.optionDirection}`,
+        {
+          "elem-fill-question-simple":
+            !this.data.isMultiply && !this.data.isBoolean,
+          "elem-fill-question-multiply": this.data.isMultiply,
+          "elem-fill-question-boolean": this.data.isBoolean,
+          "elem-fill-question-first": this.isFirstSpin,
+        },
+      ];
+    },
+    groupGapStyles() {
+      return {
+        marginRight: this.data.groupGap + "px",
+      };
+    },
+    // questionGapStyles() {
+    //   return this.data.optionDirection === "vertical"
+    //     ? { marginRight: this.data.questionGap + "px" }
+    //     : { marginBottom: this.data.questionGap + "px" };
+    // },
+    optionGapStyles() {
+      const styles =
+        this.data.optionDirection === "vertical"
+          ? { marginBottom: this.data.optionGap + "px" }
+          : { marginRight: this.data.optionGap + "px" };
+      // styles.fontSize = this.data.fontSize;
+      return styles;
+    },
+  },
+  watch: {
+    data: {
+      immediate: true,
+      handler(val) {
+        this.parseQuestion(val);
+      },
+    },
+  },
+  methods: {
+    parseQuestion(data) {
+      let questionNo = data.startNumber;
+      let questions = [];
+      const choiceList = this.getChoiceList(data);
+      if (data.questionDirection === "vertical") {
+        const groupNum = Math.ceil(
+          data.questionsCount / data.questionCountPerGroup
+        );
+        for (let i = 0; i < groupNum; i++) {
+          questions[i] = [];
+          const questionCountPerGroup =
+            i === groupNum - 1
+              ? data.questionsCount - data.questionCountPerGroup * i
+              : data.questionCountPerGroup;
+          for (let j = 0; j < questionCountPerGroup; j++) {
+            questions[i][j] = [questionNo++, ...choiceList];
+          }
+        }
+      } else {
+        for (let i = 0; i < data.questionsCount; i++) {
+          const groupIndex = i % data.groupPerLine;
+          if (!questions[groupIndex]) questions[groupIndex] = [];
+          questions[groupIndex].push([questionNo++, ...choiceList]);
+        }
+      }
+      this.questions = questions;
+    },
+    getChoiceList(data) {
+      if (data.isBoolean) {
+        return data.booleanType.split(",");
+      } else {
+        return "abcdefghijklmnopqrstuv"
+          .toUpperCase()
+          .slice(0, data.optionCount)
+          .split("");
+      }
+    },
+    getQuestionGapStyles(group, qindex) {
+      const size = group.length - 1 === qindex ? 0 : this.data.questionGap;
+      return this.data.optionDirection === "vertical"
+        ? { marginRight: size + "px" }
+        : { marginBottom: size + "px" };
+    },
+  },
+};
+</script>

+ 93 - 0
src/modules/card/elements/fill-question/model.js

@@ -0,0 +1,93 @@
+import { getElementId, randomCode } from "../../plugins/utils";
+import { BOOLEAN_TYPE } from "../../enumerate";
+
+const MODEL = {
+  type: "FILL_QUESTION",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 138,
+  minHeight: 138,
+  sign: "objective",
+  topicName: "",
+  topicNo: null,
+  startNumber: 1,
+  questionsCount: 10,
+  optionCount: 4,
+  questionCountPerGroup: 5,
+  groupPerLine: 4, // 小题纵向排列时,表示每行组数。小题横向排列时,表示每行小题数。
+  optionDirection: "horizontal",
+  questionDirection: "vertical",
+  questionGap: 8,
+  groupGap: 30,
+  optionGap: 12,
+  isBoolean: false, // 是否是判断题
+  booleanType: BOOLEAN_TYPE[0],
+  isMultiply: false, // 是否是多选题
+  isCovered: false,
+  fontSize: "14px",
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...MODEL,
+  };
+};
+
+const getFullModel = (model, { pageSize, columnNumber }) => {
+  const parent = { ...model };
+  // 不同栏数,不同选项个数,每一行对应的组数
+  // 以一行4题,每题4选项为标准展示效果
+  const numberPerChildren = {
+    A3: {
+      2: [0, 0, 6, 5, 4, 3, 3, 2, 2, 2, 2, 1, 1],
+      3: [0, 0, 4, 3, 2, 2, 2, 1],
+      4: [0, 0, 3, 2, 2, 1],
+    },
+    A4: {
+      1: [0, 0, 6, 5, 4, 3, 3, 2, 2, 2, 2, 1, 1],
+      2: [0, 0, 3, 2, 2, 1],
+    },
+  };
+  // 以一行4题,每题5选项为标准展示效果
+  // const numberPerChildren = {
+  //   2: [0, 0, 7, 5, 4, 4, 3, 3, 2, 2, 2, 2, 1],
+  //   3: [0, 0, 4, 3, 2, 2, 2, 2, 1],
+  //   4: [0, 0, 3, 2, 2, 2, 1]
+  // };
+  const numList = numberPerChildren[pageSize][columnNumber];
+  const groupPerLine =
+    model.optionCount > numList.length
+      ? numList.pop()
+      : numList[model.optionCount];
+  const numPerLine = groupPerLine * model.questionCountPerGroup;
+  const total = Math.ceil(model.questionsCount / numPerLine);
+  let elements = [];
+  for (let i = 0; i < total; i++) {
+    let child = Object.assign({}, parent, {
+      id: getElementId(),
+      key: randomCode(),
+      groupPerLine,
+      startNumber: model.startNumber + i * numPerLine,
+      questionsCount:
+        i === total - 1 ? model.questionsCount - numPerLine * i : numPerLine,
+      parent,
+      isLast: i === total - 1,
+    });
+    const optionCount =
+      model.questionDirection === "vertical"
+        ? Math.min(child.questionsCount, model.questionCountPerGroup)
+        : Math.ceil(child.questionsCount / groupPerLine);
+    const optionsHeight =
+      14 * optionCount + (optionCount - 1) * model.questionGap + 36;
+    child.h = i ? optionsHeight : optionsHeight + 34;
+    child.minHeight = child.h;
+
+    elements[i] = child;
+  }
+  return elements;
+};
+
+export { MODEL, getModel, getFullModel };

+ 205 - 0
src/modules/card/elements/fill-table/EditFillTable.vue

@@ -0,0 +1,205 @@
+<template>
+  <div class="edit-fill-table">
+    <el-form
+      ref="modalFormComp"
+      :key="modalForm.id"
+      :model="modalForm"
+      :rules="rules"
+      label-width="100px"
+    >
+      <el-form-item prop="tdCount" label="行列数:">
+        <el-row>
+          <el-col class="text-right" :span="3">
+            <span>行:</span>
+          </el-col>
+          <el-col :span="6">
+            <el-input-number
+              v-model="modalForm.rowCount"
+              style="width: 60px"
+              :min="2"
+              :max="16"
+              :step="1"
+              step-strictly
+              :controls="false"
+            ></el-input-number>
+          </el-col>
+          <el-col class="text-right" :span="3">
+            <span>列:</span>
+          </el-col>
+          <el-col :span="6">
+            <el-input-number
+              v-model="modalForm.colCount"
+              style="width: 60px"
+              :min="2"
+              :max="16"
+              :step="1"
+              step-strictly
+              :controls="false"
+            ></el-input-number>
+          </el-col>
+        </el-row>
+      </el-form-item>
+      <el-form-item prop="padding" label="网格内边距:">
+        <el-row>
+          <el-col class="text-right" :span="3">
+            <span>上下:</span>
+          </el-col>
+          <el-col :span="6">
+            <el-input-number
+              v-model="modalForm.paddingTop"
+              style="width: 60px"
+              :min="2"
+              :max="16"
+              :step="1"
+              step-strictly
+              :controls="false"
+            ></el-input-number>
+          </el-col>
+          <el-col class="text-right" :span="3">
+            <span>左右:</span>
+          </el-col>
+          <el-col :span="6">
+            <el-input-number
+              v-model="modalForm.paddingLeft"
+              style="width: 60px"
+              :min="2"
+              :max="16"
+              :step="1"
+              step-strictly
+              :controls="false"
+            ></el-input-number>
+          </el-col>
+        </el-row>
+      </el-form-item>
+      <el-form-item label="线条类型:">
+        <line-style-select v-model="modalForm.lineStyle"></line-style-select>
+      </el-form-item>
+      <el-form-item label="字号:">
+        <size-select v-model="modalForm.fontSize"></size-select>
+      </el-form-item>
+      <el-form-item prop="lineHeight" label="行高:">
+        <el-input-number
+          v-model="modalForm.lineHeight"
+          style="width: 125px"
+          :min="20"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item label="网格内容设置:"></el-form-item>
+      <table class="table table-nopad">
+        <tr v-for="rowNo in modalForm.rowCount" :key="rowNo">
+          <td v-for="colNo in modalForm.colCount" :key="colNo">
+            <el-input
+              v-model="modalForm.content[`${rowNo}_${colNo}`]"
+              clearable
+              style="width: 100%"
+            ></el-input>
+          </td>
+        </tr>
+      </table>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import LineStyleSelect from "../../components/common/LineStyleSelect";
+import SizeSelect from "../../components/common/SizeSelect";
+import { deepCopy, objAssign } from "../../plugins/utils";
+
+const initModalForm = {
+  id: "",
+  colCount: 3,
+  rowCount: 3,
+  paddingTop: 5,
+  paddingleft: 5,
+  fontSize: "14px",
+  lineHeight: 30,
+  lineStyle: "solid",
+  content: {},
+};
+
+export default {
+  name: "EditFillTable",
+  components: { LineStyleSelect, SizeSelect },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    const tdCountValidater = (rule, value, callback) => {
+      if (!this.modalForm.rowCount || !this.modalForm.colCount) {
+        return callback(new Error("请输入完整的行列数"));
+      }
+      callback();
+    };
+    const paddingValidater = (rule, value, callback) => {
+      if (!this.modalForm.paddingTop || !this.modalForm.paddingleft) {
+        return callback(new Error("请输入完整的网格内边距"));
+      }
+      callback();
+    };
+
+    return {
+      modalForm: { ...initModalForm },
+      rules: {
+        tdCount: [
+          {
+            required: true,
+            validator: tdCountValidater,
+            trigger: "change",
+          },
+        ],
+        padding: [
+          {
+            required: true,
+            validator: paddingValidater,
+            trigger: "change",
+          },
+        ],
+      },
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = objAssign(initModalForm, val);
+      this.modalForm.paddingTop = val.padding[0];
+      this.modalForm.paddingLeft = val.padding[1];
+      let content = {};
+      for (let rowNo = 0; rowNo < val.rowCount; rowNo++) {
+        for (let colNo = 0; colNo < val.colCount; colNo++) {
+          const field = `${rowNo}_${colNo}`;
+          content[field] = val[field] || "";
+        }
+      }
+      this.modalForm.content = content;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      const data = deepCopy(this.modalForm);
+      const model = objAssign(this.instance, data);
+      model.padding = [data.paddingTop, data.paddingLeft];
+
+      this.$emit("modified", model);
+    },
+  },
+};
+</script>
+
+<style scoped>
+.table-nopad td,
+.table-nopad th {
+  padding: 0;
+}
+</style>

+ 40 - 0
src/modules/card/elements/fill-table/ElemFillTable.vue

@@ -0,0 +1,40 @@
+<template>
+  <div class="elem-fill-table">
+    <table class="table">
+      <tr v-for="rowNo in data.rowCount" :key="rowNo">
+        <td v-for="colNo in data.colCount" :key="colNo" :style="tdStyles">
+          <span> {{ data.content[`${rowNo}_${colNo}`] }}</span>
+        </td>
+      </tr>
+    </table>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "ElemFillTable",
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    tdStyles() {
+      return {
+        padding: this.data.padding.map((item) => `${item}px`).join(" "),
+        border: `1px ${this.data.lineStyle} #000`,
+        fontSize: this.data.fontSize,
+        height: this.data.lineHeight + "px",
+      };
+    },
+  },
+  mounted() {},
+  methods: {},
+};
+</script>

+ 27 - 0
src/modules/card/elements/fill-table/model.js

@@ -0,0 +1,27 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+
+const MODEL = {
+  type: "FILL_TABLE",
+  x: 0,
+  y: 0,
+  w: 300,
+  h: 120,
+  sign: "",
+  colCount: 3,
+  rowCount: 3,
+  padding: [5, 5],
+  lineStyle: "solid",
+  fontSize: "14px",
+  lineHeight: 30,
+  content: {},
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...deepCopy(MODEL),
+  };
+};
+
+export { MODEL, getModel };

Some files were not shown because too many files changed in this diff