zhangjie 3 år sedan
förälder
incheckning
570a94cf88
95 ändrade filer med 6158 tillägg och 253 borttagningar
  1. 23 0
      card/INTRODUCTION.md
  2. 26 38
      card/api.js
  3. BIN
      card/assets/images/icon-column-four-act.png
  4. BIN
      card/assets/images/icon-column-four.png
  5. BIN
      card/assets/images/icon-column-one-act.png
  6. BIN
      card/assets/images/icon-column-one.png
  7. BIN
      card/assets/images/icon-column-three-act.png
  8. BIN
      card/assets/images/icon-column-three.png
  9. BIN
      card/assets/images/icon-column-two-act.png
  10. BIN
      card/assets/images/icon-column-two.png
  11. 47 8
      card/assets/styles/base.scss
  12. 190 28
      card/assets/styles/card-design.scss
  13. 243 41
      card/assets/styles/card-preview.scss
  14. 163 35
      card/assets/styles/element-ui-costom.scss
  15. 36 6
      card/assets/styles/home.scss
  16. 43 0
      card/assets/styles/icons.scss
  17. 1 0
      card/assets/styles/variables.scss
  18. 47 34
      card/components/PagePropEdit.vue
  19. 2 2
      card/components/TopicElementEdit.vue
  20. 3 2
      card/components/TopicElementPreview.vue
  21. 1 1
      card/components/common/ColorSelect.vue
  22. 6 1
      card/components/common/ElementResize.vue
  23. 33 0
      card/components/common/ShortcutKeySpin.vue
  24. 7 3
      card/elementModel.js
  25. 127 0
      card/elements/barcode/EditBarcode.vue
  26. 42 0
      card/elements/barcode/ElemBarcode.vue
  27. 27 0
      card/elements/barcode/model.js
  28. 10 4
      card/elements/card-head/CardHead.vue
  29. 6 1
      card/elements/card-head/cardHeadSpin/HeadDynamic.vue
  30. 6 4
      card/elements/card-head/model.js
  31. 0 1
      card/elements/composition/EditComposition.vue
  32. 3 1
      card/elements/composition/model.js
  33. 3 1
      card/elements/explain/model.js
  34. 142 0
      card/elements/fill-field/EditFillField.vue
  35. 84 0
      card/elements/fill-field/ElemFillField.vue
  36. 25 0
      card/elements/fill-field/model.js
  37. 4 1
      card/elements/fill-line/ElemFillLine.vue
  38. 3 1
      card/elements/fill-line/model.js
  39. 87 0
      card/elements/fill-number/EditFillNumber.vue
  40. 49 0
      card/elements/fill-number/ElemFillNumber.vue
  41. 23 0
      card/elements/fill-number/model.js
  42. 139 0
      card/elements/fill-pane/EditFillPane.vue
  43. 46 0
      card/elements/fill-pane/ElemFillPane.vue
  44. 25 0
      card/elements/fill-pane/model.js
  45. 16 7
      card/elements/fill-question/ElemFillQuestion.vue
  46. 14 6
      card/elements/fill-question/model.js
  47. 205 0
      card/elements/fill-table/EditFillTable.vue
  48. 37 0
      card/elements/fill-table/ElemFillTable.vue
  49. 27 0
      card/elements/fill-table/model.js
  50. 1 1
      card/elements/grids/EditGrids.vue
  51. 2 1
      card/elements/grids/model.js
  52. 2 1
      card/elements/image/model.js
  53. 2 1
      card/elements/line/model.js
  54. 2 1
      card/elements/lines/model.js
  55. 166 0
      card/elements/page/EditPage.vue
  56. 69 0
      card/elements/page/model.js
  57. 71 0
      card/elements/pane/EditPane.vue
  58. 28 0
      card/elements/pane/ElemPane.vue
  59. 24 0
      card/elements/pane/model.js
  60. 1 1
      card/elements/text/EditText.vue
  61. 2 1
      card/elements/text/model.js
  62. 2 1
      card/elements/topic-head/model.js
  63. 79 0
      card/enumerate.js
  64. 360 0
      card/modules/free/cardFormatTransform.js
  65. 98 0
      card/modules/free/components/ElementPropEdit.vue
  66. 176 0
      card/modules/free/components/ElementTierEdit.vue
  67. 49 0
      card/modules/free/components/HelpDialog.vue
  68. 131 0
      card/modules/free/components/PagePropEdit.vue
  69. 214 0
      card/modules/free/components/RightClickMenu.vue
  70. 188 0
      card/modules/free/components/ShortcutKey.vue
  71. 119 0
      card/modules/free/components/TopicColumnEdit.vue
  72. 127 0
      card/modules/free/components/TopicElementEdit.vue
  73. 81 0
      card/modules/free/components/TopicElementPreview.vue
  74. 256 0
      card/modules/free/elements/fill-line/EditFillLine.vue
  75. 83 0
      card/modules/free/elements/fill-line/ElemFillLine.vue
  76. 60 0
      card/modules/free/elements/fill-line/model.js
  77. 239 0
      card/modules/free/elements/fill-question/EditFillQuestion.vue
  78. 123 0
      card/modules/free/elements/fill-question/ElemFillQuestion.vue
  79. 39 0
      card/modules/free/elements/fill-question/model.js
  80. 166 0
      card/modules/free/elements/model.js
  81. 209 0
      card/modules/free/store.js
  82. 536 0
      card/modules/free/views/CardFreeDesign.vue
  83. 225 0
      card/modules/free/views/CardFreePreview.vue
  84. 18 4
      card/router/index.js
  85. 4 3
      card/store/card.js
  86. 5 1
      card/store/index.js
  87. 6 2
      card/views/CardDesign.vue
  88. 8 1
      card/views/CardPreview.vue
  89. 8 1
      card/views/CardRulePreview.vue
  90. 117 2
      card/views/Home.vue
  91. 7 0
      src/modules/base/components/ModifyCardInfo.vue
  92. 13 2
      src/modules/base/views/CardManage.vue
  93. 17 0
      src/modules/card/router.js
  94. 2 2
      src/modules/card/store.js
  95. 2 1
      src/store.js

+ 23 - 0
card/INTRODUCTION.md

@@ -1,5 +1,28 @@
 # 功能清单
 
+## v0.3.0
+
+- 新增A4尺寸题卡支持。
+- 新增自由编辑模式。
+- 修改页面样式。
+
+#### 自由编辑模式
+
+- 新增元素:方框,条形码,变量信息,准考证填涂,表格,方格组。
+- 层级关系拖动。
+- 卡格式导出。
+- 快捷键操作:
+  - 方向键,轻微移动(1px);shift+方向键,大幅移动(10px)。
+  - ctrl+alt+d:删除页面。
+  - ctrl+alt+n:新建页面。
+  - ctrl+c/v:复制/粘贴。
+  - ctrl+s:保存。
+  - ctrl+e:编辑选中元素。
+  - delete删除选中元素。
+  - 元素层级移动:ctrl+上下方向键:上/下移动一层 
+  - ctrl+p:预览
+  - ctrl+shift+s:提交
+
 ## v0.2.0
 
 #### 填空题

+ 26 - 38
card/api.js

@@ -2,53 +2,41 @@ import { $postParam, $post } from "@/plugins/axios";
 
 export const cardConfigInfos = id => {
   return $postParam("/api/admin/basic/card_rule/get_one", { id });
-
   // return Promise.resolve({
-  //   pageSize: "A3",
-  //   columnNumber: 2,
-  //   columnGap: 20,
-  //   showForbidArea: true,
-  //   cardName: "",
+  //   id: "173438690998091776",
+  //   createId: "173437828976345088",
+  //   createTime: 1632291806278,
+  //   updateId: null,
+  //   updateTime: 1632291806278,
+  //   schoolId: "2",
+  //   orgId: "173436480729907200",
+  //   name: "测试题卡规则1",
+  //   examNumberStyle: "PRINT",
+  //   paperType: "PRINT",
   //   examAbsent: true,
   //   writeSign: true,
-  //   examNumberStyle: "PRINT", // PRINT:印刷条码, PASTE:粘贴条码, FILL:考号填涂
-  //   aOrBSystem: true, // 后台附带的aOrB设置,如果有则使用这个值,如果没有则前台自动设置
-  //   paperType: "PRINT", // PRINT: "印刷",FILL: "填涂"
-  //   schoolName: "河南财经政法大学", // cartTitle
-  //   cardTitleDesc: "",
-  //   businessParams: [
-  //     {
-  //       name: "学号",
-  //       code: "studentNo"
-  //     },
-  //     {
-  //       name: "姓名",
-  //       code: "username"
-  //     },
-  //     {
-  //       name: "课程名称",
-  //       code: "courseName"
-  //     },
-  //     {
-  //       name: "课程名称",
-  //       code: "subjectName"
-  //     }
-  //   ],
-  //   attention: [
-  //     "答题前,请考生认真核对姓名、学号、教学班号、课程名称等信息,确认无误后签名。",
-  //     "客观题部分必须使用2B铅笔填涂,主观题部分请使用黑色签字笔写在指定的答题区内。",
-  //     "保持卡面清洁,不破损。"
-  //   ],
-  //   objectiveAttention:
-  //     "注意:必须使用2B铅笔填涂;在答题区内作答,超出涂填边框限定区域的答案无效。",
-  //   subjectiveAttention:
-  //     "注意:必须使用黑色字迹签字笔书写;在答题区内作答,超出以下黑色矩形边框限定区域的答案无效。"
+  //   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 = cardId => {
   return $postParam("/api/admin/exam/card/get_one", { cardId });
+  // const cardData = Vue.ls.get("cardData", {});
+  // return Promise.resolve(cardData);
 };
 
 export const saveCard = (datas, config = {}) => {
   return $post("/api/admin/exam/card/save", datas, config);
+  // Vue.ls.set("cardData", datas);
+  // return Promise.resolve(randomCode());
 };

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


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


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


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


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


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


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


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


+ 47 - 8
card/assets/styles/base.scss

@@ -141,6 +141,9 @@ body {
       display: none;
     }
   }
+  &-gray {
+    background-color: $--color-text-gray-7;
+  }
 
   &-flex {
     display: flex;
@@ -157,7 +160,7 @@ body {
   &-tips {
     font-size: 16px;
     line-height: 25px;
-    color: $--color-text-gray-2;
+    color: $--color-text-dark-1;
     margin-bottom: 15px;
   }
 
@@ -250,8 +253,12 @@ body {
   border-collapse: collapse;
   text-align: left;
 
+  &.table-white {
+    background-color: #fff;
+  }
+
   th {
-    padding: 14px;
+    padding: 12px;
     line-height: 1.2;
     letter-spacing: 1px;
     color: $--color-text-gray-2;
@@ -276,6 +283,20 @@ body {
     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 */
@@ -320,6 +341,9 @@ body {
 .color-gray {
   color: $--color-text-gray;
 }
+.color-gray-2 {
+  color: $--color-text-gray-2;
+}
 .color-white {
   color: #fff;
 }
@@ -369,11 +393,26 @@ body {
     color: $--color-text-gray;
   }
 }
-.btn-act {
-  background: rgba(28, 208, 161, 1) !important;
-  box-shadow: 5px 5px 4px 0px rgba(28, 208, 161, 0.3);
-  border-radius: 10px;
+
+.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;
@@ -409,8 +448,8 @@ body {
 // other
 .tips-info {
   font-size: 12px;
-  line-height: 25px;
-  color: $--color-text-gray-3;
+  line-height: 20px;
+  color: $--color-text-gray-2;
 }
 .tips-dark {
   color: $--color-text-gray;

+ 190 - 28
card/assets/styles/card-design.scss

@@ -19,6 +19,13 @@
     }
   }
   .page-column-main {
+    &.is-active {
+      &::before,
+      .page-column-forbid-area {
+        border-color: $--color-primary;
+      }
+    }
+
     &::before {
       content: "";
       display: block;
@@ -31,16 +38,13 @@
       border: 1px dashed #d0d0d0;
     }
   }
-  .page-column-element {
+  .topic-element-edit {
     .element-item-error {
       box-shadow: 0 0 10px $--color-danger;
     }
 
-    > .element-resize {
-      width: 100% !important;
-    }
-
     .element-resize {
+      background-color: #fff;
       > .resize-control {
         > .control-point,
         > .control-line {
@@ -140,6 +144,17 @@
         }
       }
     }
+
+    // elem-pane
+    .elem-pane {
+      width: 100%;
+      height: 100%;
+    }
+  }
+  .page-column-element {
+    > .element-resize {
+      width: 100% !important;
+    }
   }
 
   // page-main-outer
@@ -374,13 +389,13 @@
   right: 0;
   height: 70px;
   z-index: 99;
-  padding: 20px;
   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;
@@ -389,6 +404,7 @@
     }
   }
   .control-left {
+    padding: 20px 0 20px 20px;
     white-space: nowrap;
     overflow-y: hidden;
     overflow-x: auto;
@@ -425,32 +441,16 @@
 // page-prop-edit
 .page-prop-edit {
   .el-form-item {
-    margin-bottom: 10px;
+    margin-bottom: 5px;
   }
   .el-form-item__label,
   .el-checkbox {
     color: $--color-text-dark-1;
   }
   .column-btn {
-    width: 28px;
-    padding: 5px 0;
-    .icon {
-      margin-top: -2px;
-    }
-
-    &:hover {
-      border-color: $--color-primary;
-    }
-
-    &-act {
-      border-color: $--color-primary;
-      background-color: $--color-primary;
-      color: #fff;
-    }
-
-    &:not(:first-child) {
-      margin-left: 10px;
-    }
+    padding: 0;
+    border: none !important;
+    vertical-align: middle;
   }
   .topicno-list {
     font-size: 0;
@@ -486,6 +486,95 @@
     }
   }
 }
+// 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 {
@@ -530,8 +619,8 @@
 .custom-select {
   width: 100%;
   .select-preview {
-    height: 40px;
-    line-height: 36px;
+    height: 32px;
+    line-height: 30px;
     border: 1px solid #e0e0e0;
     border-radius: 5px;
     position: relative;
@@ -667,3 +756,76 @@
     }
   }
 }
+
+// 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%);
+    }
+  }
+}

+ 243 - 41
card/assets/styles/card-preview.scss

@@ -7,9 +7,6 @@
     margin: 10px auto;
     box-shadow: 0 0 4px #ddd;
   }
-  .element-item {
-    width: 100% !important;
-  }
 }
 .card-print {
   padding: 0;
@@ -30,11 +27,6 @@
 // page-box
 .page-box {
   position: relative;
-  // width: 420mm;
-  // height: 297mm;
-  // width: 1587px;
-  width: 1586px;
-  height: 1122px;
   background: #fff;
   margin: 0 auto;
   font-weight: normal;
@@ -45,36 +37,111 @@
     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;
+      }
 
-      .page-column-forbid-area {
-        &::before {
-          transform: rotate(64.6555deg);
+      &-1 {
+        .page-column-forbid-area {
+          &::before {
+            width: 2000px;
+            transform: rotate(34.326deg);
+          }
+          &::after {
+            width: 2000px;
+            transform: rotate(-34.326deg);
+          }
         }
-        &::after {
-          transform: rotate(-64.6555deg);
+      }
+      &-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);
+          }
         }
       }
     }
-    &-4 {
-      .page-column {
-        width: 25%;
+  }
+  &-A4 {
+    width: 793px;
+    height: 1122px;
+
+    .page-main {
+      &-inner {
+        padding: 60px 45px 86px;
       }
 
-      .page-column-forbid-area {
-        &::before {
-          transform: rotate(70.3109deg);
+      &-1 {
+        .page-column-forbid-area {
+          &::before {
+            transform: rotate(54.216deg);
+          }
+          &::after {
+            transform: rotate(-54.216deg);
+          }
         }
-        &::after {
-          transform: rotate(-70.3109deg);
+      }
+
+      &-2 {
+        .page-column-forbid-area {
+          &::before {
+            transform: rotate(70.5109deg);
+          }
+          &::after {
+            transform: rotate(-70.5109deg);
+          }
         }
       }
     }
   }
 }
+
 // 分栏间距,默认20px
 // page-main-inner
 .page-main-inner {
@@ -104,7 +171,7 @@
   vertical-align: middle;
   position: relative;
   height: 100%;
-  width: 50%;
+  width: 100%;
   font-size: 14px;
   padding: 0 10px;
 
@@ -141,22 +208,7 @@
       transform-origin: left;
       z-index: 1;
     }
-    // wkhtmltopdf 工具无法渲染如下样式:
-    // > p {
-    //   color: #333;
-    //   padding: 20px;
-    //   position: absolute;
-    //   top: 50%;
-    //   left: 50%;
-    //   z-index: 9;
-    //   transform: translate(-50%, -50%);
-    //   font-weight: bold;
-    //   font-size: 30px;
-    //   color: #999;
-    //   background-color: #fff;
-    // }
     > p {
-      color: #333;
       padding: 20px;
       position: absolute;
       width: 260px;
@@ -189,6 +241,10 @@
     .element-item {
       position: relative;
 
+      &-width {
+        width: 100% !important;
+      }
+
       &::before {
         content: "";
         position: absolute;
@@ -272,6 +328,7 @@
     margin-left: -12px;
   }
   &:last-child {
+    left: auto;
     right: 96px;
   }
   li {
@@ -1083,10 +1140,6 @@
   }
   .question-item {
     font-size: 0;
-
-    &:last-child {
-      margin-bottom: 0 !important;
-    }
   }
   .option-item {
     display: inline-block;
@@ -1285,3 +1338,152 @@
     }
   }
 }
+// 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 {
+  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;
+      }
+    }
+  }
+}

+ 163 - 35
card/assets/styles/element-ui-costom.scss

@@ -1,3 +1,7 @@
+/*
+* element-ui不管是自行构建的主题还是动态设置的主题,
+* 产生的css文件中存在近乎1/3的冗余样式,过于累赘,不如直接覆盖样式简洁。
+*/
 // dialog
 .el-dialog {
   border-radius: 8px;
@@ -7,12 +11,23 @@
 
   &.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-1;
+    color: $--color-text-dark;
     font-size: 16px;
     line-height: 19px;
   }
@@ -37,9 +52,9 @@
   padding: 30px 40px;
   position: relative;
   border-top: 1px solid $--color-border;
+  color: $--color-text-dark-1;
 
   .el-form-item__label {
-    color: #545454;
     padding-right: 2px;
   }
   .el-input-tips {
@@ -48,15 +63,13 @@
   }
 }
 .el-dialog__footer {
-  text-align: right;
+  overflow: hidden;
   .el-button {
     width: 100px;
     border-radius: 8px;
+    float: right;
+    margin-left: 10px;
   }
-  // .el-button--default {
-  //   background: rgba(245, 245, 245, 1);
-  //   color: #999;
-  // }
 }
 
 // .opacity-dialog
@@ -140,6 +153,37 @@
     }
   }
 }
+.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 {
@@ -180,7 +224,7 @@
   margin-left: 10px;
 }
 .el-button + .el-button {
-  margin-left: 18px;
+  margin-left: 10px;
 }
 .el-button--text + .el-button--text {
   margin-left: 5px;
@@ -278,26 +322,84 @@
     }
   }
   // action-column
-  .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 {
@@ -338,21 +440,21 @@
       @extend .el-pagination-li;
       &:not(.disabled).active {
         color: #fff;
+        background-color: $--color-primary;
       }
     }
   }
 }
 // el-message-box
 .el-message-box {
-  width: 350px;
+  width: 320px;
   background-color: #f6f6f6;
   border-radius: 10px;
   &__title {
     display: none;
   }
   &__headerbtn {
-    top: 10px;
-    right: 10px;
+    display: none;
   }
   &__content {
     text-align: center;
@@ -360,8 +462,8 @@
     .el-message-box__status {
       position: relative;
       top: 0;
-      height: 50px;
-      width: 50px;
+      height: 48px;
+      width: 48px;
       transform: none;
       margin-bottom: 10px;
 
@@ -385,22 +487,12 @@
     }
   }
   &__btns {
-    position: relative;
     height: 75px;
     padding: 30px 20px 10px;
+    text-align: center;
 
     > .el-button {
-      width: 85px;
-      position: absolute;
-      left: 50%;
-      top: 30px;
-
-      &:first-child {
-        margin-left: 5px;
-      }
-      &:last-child {
-        margin-left: -90px;
-      }
+      width: 100px;
     }
   }
 }
@@ -423,29 +515,52 @@
 }
 // el-date-editor
 .el-date-editor {
+  border-radius: 8px;
   .el-range-separator {
     width: auto;
-    line-height: 24px;
   }
   .el-range-input {
     background-color: transparent;
   }
-  .el-input__icon {
-    line-height: 24px;
-  }
 }
 
 // el-step
 .el-step {
   &__title.is-success,
-  &__description.is-success {
-    color: $--color-primary;
+  &__description.is-success,
+  &__title.is-process,
+  &__description.is-process {
+    color: $--color-success;
+  }
+  &__title.is-process {
+    font-weight: normal;
   }
   &__head.is-success {
-    color: $--color-primary;
-    border-color: $--color-primary;
     .el-step__line {
-      background-color: $--color-primary;
+      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;
     }
   }
 }
@@ -489,3 +604,16 @@
     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;
+    }
+  }
+}

+ 36 - 6
card/assets/styles/home.scss

@@ -31,7 +31,6 @@
   z-index: 100;
   overflow: auto;
   font-size: 14px;
-  padding: 30px 40px;
   background: $--color-white;
   border-top-right-radius: $--border-radius-huge;
   border-bottom-right-radius: $--border-radius-huge;
@@ -49,13 +48,15 @@
   }
 
   .head-logo {
+    padding: 0 40px;
     font-size: 20px;
     line-height: 40px;
-    border-radius: $--border-radius;
-    // background-color: $--color-background;
     text-align: center;
-    margin-bottom: 30px;
-
+    &-content {
+      display: block;
+      padding: 30px 0;
+      border-bottom: 1px solid #eff0f5;
+    }
     img {
       display: block;
       max-width: 160px;
@@ -130,7 +131,7 @@
       font-size: 12px;
       line-height: 16px;
       top: 12px;
-      right: 0;
+      right: 40px;
       background-color: $--color-warning;
       color: #fff;
       text-align: center;
@@ -138,6 +139,35 @@
     }
   }
 }
+.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 {

+ 43 - 0
card/assets/styles/icons.scss

@@ -56,4 +56,47 @@
     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;
+    }
+  }
 }

+ 1 - 0
card/assets/styles/variables.scss

@@ -8,6 +8,7 @@ $--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

+ 47 - 34
card/components/PagePropEdit.vue

@@ -5,8 +5,8 @@
         <el-select
           v-model="form.pageSize"
           placeholder="请选择"
-          @change="modifyPageSize"
           :disabled="pageSizeOptions.length < 2"
+          @change="modifyPageSize"
         >
           <el-option
             v-for="item in pageSizeOptions"
@@ -21,10 +21,7 @@
         <el-button
           v-for="(item, index) in columnOptions"
           :key="index"
-          :class="[
-            'column-btn',
-            { 'column-btn-act': form.columnNumber == item.value }
-          ]"
+          class="column-btn"
           :title="item.title"
           :disabled="item.disabled"
           @click="modifyColumnNum(item)"
@@ -33,8 +30,8 @@
             :class="[
               'icon',
               form.columnNumber == item.value
-                ? `icon-${item.label}-white`
-                : `icon-${item.label}-gray`
+                ? `icon-column-${item.label}-act`
+                : `icon-column-${item.label}`
             ]"
           ></i>
         </el-button>
@@ -62,34 +59,43 @@
 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: "page-prop-edit",
   data() {
     return {
-      columnOptions: [
-        {
-          value: 2,
-          icon: "el-icon-menu",
-          title: "二栏",
-          label: "two",
-          disabled: false
-        },
-        {
-          value: 3,
-          icon: "el-icon-s-grid",
-          title: "三栏",
-          label: "three",
-          disabled: false
-        },
-        {
-          value: 4,
-          icon: "el-icon-menu",
-          title: "四栏",
-          label: "four",
-          disabled: false
-        }
-      ],
-      pageSizeOptions: ["A3"],
+      columnOptions: [],
+      pageSizeOptions: ["A3", "A4"],
       form: {
         pageSize: "A3",
         columnNumber: 2,
@@ -115,11 +121,15 @@ export default {
       handler(val) {
         this.form = objAssign(this.form, val);
         this.prePageSize = this.form.pageSize;
-        this.columnOptions[2].disabled = val.examNumberStyle === "fill";
+        this.columnOptions = COLUMN_OPTIONS.filter(item =>
+          item.sizeValid.includes(this.form.pageSize)
+        );
+        if (this.form.pageSize === "A3") {
+          this.columnOptions[2].disabled = val.examNumberStyle === "fill";
+        }
       }
     }
   },
-  mounted() {},
   methods: {
     ...mapMutations("card", [
       "setPages",
@@ -166,7 +176,10 @@ export default {
         type: "warning"
       })
         .then(() => {
-          // TODO:A4
+          this.columnOptions = COLUMN_OPTIONS.filter(item =>
+            item.sizeValid.includes(this.form.pageSize)
+          );
+          this.form.columnNumber = this.columnOptions[0].value;
           this.configChange();
         })
         .catch(() => {

+ 2 - 2
card/components/TopicElementEdit.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="topic-element">
+  <div class="topic-element-edit">
     <div
       :class="classes"
       :id="data.id"
@@ -49,7 +49,7 @@ import ElementResize from "./common/ElementResize";
 import TopicNumber from "./common/TopicNumber";
 
 export default {
-  name: "topic-design",
+  name: "topic-element-edit",
   components: {
     EditCardHead,
     EditTopicHead,

+ 3 - 2
card/components/TopicElementPreview.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="topic-element">
+  <div class="topic-element-preview">
     <div
       :class="classes"
       :id="`preview-${data.id}`"
@@ -20,7 +20,7 @@ import PreviewFillLine from "../elements/fill-line/ElemFillLine";
 import PreviewTopicHead from "../elements/topic-head/TopicHead";
 
 export default {
-  name: "topic-preview",
+  name: "topic-element-preview",
   components: {
     PreviewCardHead,
     PreviewTopicHead,
@@ -48,6 +48,7 @@ export default {
       return [
         "topic-preview",
         "element-item",
+        "element-item-width",
         `element-item-${this.elementName}`,
         this.data["isLast"] ? `element-item-type-last` : "element-item-type-pre"
       ];

+ 1 - 1
card/components/common/ColorSelect.vue

@@ -31,7 +31,7 @@
 </template>
 
 <script>
-const PREDEFINE_OPTIONS = ["#000000", "#666666", "#999999"];
+const PREDEFINE_OPTIONS = ["#000000", "#666666", "#999999", "#ffffff"];
 
 export default {
   name: "color-select",

+ 6 - 1
card/components/common/ElementResize.vue

@@ -127,6 +127,7 @@ export default {
             top: this.sizePos.y + "px",
             width: this.sizePos.w + "px",
             height: this.sizePos.h + "px",
+            zIndex: this.sizePos.zindex,
             position: this.positionType
           }
         : {};
@@ -251,7 +252,11 @@ export default {
 
       this.lastSizePos = { ...sizePos };
       return this.transformFit
-        ? this.transformFit({ id: this.elementPk, ...sizePos }, actionType)
+        ? Object.assign(
+            {},
+            sizePos,
+            this.transformFit({ id: this.elementPk, ...sizePos }, actionType)
+          )
         : sizePos;
     },
     getLeftSize(left) {

+ 33 - 0
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: "shortcut-key-spin",
+  props: {
+    data: {
+      type: String,
+      default: ""
+    }
+  },
+  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];
+    }
+  },
+  data() {
+    return {};
+  },
+  methods: {}
+};
+</script>

+ 7 - 3
card/elementModel.js

@@ -132,12 +132,16 @@ const getElementName = type => {
 };
 
 // 创建新页面
-const getNewPage = (pageNo, columnNumber = 2) => {
+const getNewPage = (pageNo, { pageSize, columnNumber }) => {
   let npage = deepCopy(PAGE);
-  if (columnNumber === 4) {
+  if (
+    (pageSize === "A3" && columnNumber === 4) ||
+    (pageSize === "A4" && columnNumber === 2)
+  ) {
     npage.columnGap = 10;
   }
-  npage.locators = getNumList(3).map((item, index) => {
+  const num = pageSize === "A3" ? 3 : 2;
+  npage.locators = getNumList(num).map((item, index) => {
     return [
       {
         ...LOCATOR,

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

@@ -0,0 +1,127 @@
+<template>
+  <div class="edit-barcode">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :key="modalForm.id"
+      :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: "edit-barcode",
+  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>

+ 42 - 0
card/elements/barcode/ElemBarcode.vue

@@ -0,0 +1,42 @@
+<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: "elem-barcode",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  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 : "";
+    }
+  },
+  data() {
+    return {};
+  },
+  methods: {}
+};
+</script>

+ 27 - 0
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 };

+ 10 - 4
card/elements/card-head/CardHead.vue

@@ -34,7 +34,7 @@
       </div>
     </div>
 
-    <template v-if="data.columnNumber === 2">
+    <template v-if="!narrowCard">
       <div class="card-head-body" v-if="data.examNumberStyle !== 'FILL'">
         <div class="grid-container">
           <div class="grid-row">
@@ -69,7 +69,7 @@
       </div>
     </template>
 
-    <template v-if="data.columnNumber > 2">
+    <template v-if="narrowCard">
       <div class="card-head-body" v-if="data.examNumberStyle !== 'FILL'">
         <head-stdno class="card-head-part" :data="data"></head-stdno>
         <head-stdinfo class="card-head-part" :data="data"></head-stdinfo>
@@ -144,13 +144,19 @@ export default {
         "page-element",
         "card-head",
         {
-          "card-head-narrow": this.data.columnNumber > 2,
+          "card-head-narrow": this.narrowCard,
           "card-head-handle": this.data.examNumberStyle === "FILL",
           "card-head-normal":
-            this.data.examNumberStyle !== "FILL" && this.data.columnNumber <= 2
+            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"

+ 6 - 1
card/elements/card-head/cardHeadSpin/HeadDynamic.vue

@@ -128,7 +128,12 @@ export default {
   },
   methods: {
     initStyles() {
-      if (this.data.examNumberStyle === "FILL" || this.data.columnNumber !== 2)
+      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";

+ 6 - 4
card/elements/card-head/model.js

@@ -1,4 +1,4 @@
-import { getElementId, deepCopy } from "../../plugins/utils";
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
 
 const MODEL = {
   type: "CARD_HEAD",
@@ -23,9 +23,11 @@ const MODEL = {
 };
 
 const getModel = cardConfig => {
-  const infos = Object.assign({}, deepCopy(MODEL), cardConfig);
-  infos.id = getElementId();
-  return infos;
+  const model = Object.assign({}, deepCopy(MODEL), cardConfig);
+  model.id = getElementId();
+  model.key = randomCode();
+
+  return model;
 };
 
 export { MODEL, getModel };

+ 0 - 1
card/elements/composition/EditComposition.vue

@@ -62,7 +62,6 @@ export default {
     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);
     }

+ 3 - 1
card/elements/composition/model.js

@@ -1,4 +1,4 @@
-import { getElementId, deepCopy } from "../../plugins/utils";
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
 import { getModel as createLines } from "../lines/model";
 
 const COMPOSITION_PROP = {
@@ -36,6 +36,7 @@ const MODEL = {
 const getModel = () => {
   return {
     id: getElementId(),
+    key: randomCode(),
     ...COMPOSITION_PROP
   };
 };
@@ -45,6 +46,7 @@ const getFullModel = compositionProp => {
 
   let model = {
     id: getElementId(),
+    key: randomCode(),
     ...deepCopy(MODEL)
   };
   model.w = parent.w;

+ 3 - 1
card/elements/explain/model.js

@@ -1,4 +1,4 @@
-import { getElementId, deepCopy } from "../../plugins/utils";
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
 
 const EXPLAIN_PROP = {
   type: "EXPLAIN",
@@ -37,6 +37,7 @@ const MODEL = {
 const getModel = () => {
   return {
     id: getElementId(),
+    key: randomCode(),
     ...EXPLAIN_PROP
   };
 };
@@ -49,6 +50,7 @@ const getFullModel = explainProp => {
   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,

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

@@ -0,0 +1,142 @@
+<template>
+  <div class="edit-fill-field ">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      label-width="120px"
+    >
+      <el-form-item prop="fieldCountPerLine" label="每行变量数:">
+        <el-input-number
+          style="width:125px;"
+          v-model="modalForm.fieldCountPerLine"
+          :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
+          style="width:125px;"
+          v-model.number="modalForm.lineSpacing"
+          :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: "edit-fill-field",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  computed: {
+    ...mapState("free", ["cardConfig"])
+  },
+  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"
+          }
+        ]
+      }
+    };
+  },
+  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>

+ 84 - 0
card/elements/fill-field/ElemFillField.vue

@@ -0,0 +1,84 @@
+<template>
+  <div class="elem-fill-field">
+    <div
+      class="fill-field-item"
+      v-for="(info, index) in data.fields"
+      :key="index"
+      :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: "elem-fill-field",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  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"
+      };
+    }
+  },
+  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 = {};
+      }
+    }
+  },
+  watch: {
+    data: {
+      immediate: true,
+      handler() {
+        this.init();
+      }
+    }
+  }
+};
+</script>

+ 25 - 0
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 };

+ 4 - 1
card/elements/fill-line/ElemFillLine.vue

@@ -1,6 +1,9 @@
 <template>
   <div class="elem-fill-line">
-    <div class="elem-title" v-if="data.startNumber === data.parent.startNumber">
+    <div
+      class="elem-title"
+      v-if="data.parent && data.startNumber === data.parent.startNumber"
+    >
       {{ data.parent.topicName }}
     </div>
     <div class="elem-body" v-if="data.questionDirection === 'vertical'">

+ 3 - 1
card/elements/fill-line/model.js

@@ -1,4 +1,4 @@
-import { getElementId } from "../../plugins/utils";
+import { getElementId, randomCode } from "../../plugins/utils";
 
 const MODEL = {
   type: "FILL_LINE",
@@ -25,6 +25,7 @@ const MODEL = {
 const getModel = () => {
   return {
     id: getElementId(),
+    key: randomCode(),
     ...MODEL
   };
 };
@@ -63,6 +64,7 @@ const getFullModel = model => {
       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:

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

@@ -0,0 +1,87 @@
+<template>
+  <div class="edit-fill-number">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      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: "edit-fill-number",
+  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>

+ 49 - 0
card/elements/fill-number/ElemFillNumber.vue

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

+ 23 - 0
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
card/elements/fill-pane/EditFillPane.vue

@@ -0,0 +1,139 @@
+<template>
+  <div class="edit-fill-pane ">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      label-width="120px"
+    >
+      <el-form-item prop="paneCount" label="方格数量:">
+        <el-input-number
+          style="width:125px;"
+          v-model.number="modalForm.paneCount"
+          :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
+          style="width:125px;"
+          v-model="modalForm.paneGap"
+          :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
+          style="width:125px;"
+          v-model="modalForm.paneWidth"
+          :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
+          style="width:125px;"
+          v-model="modalForm.paneHeight"
+          :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: "edit-fill-pane",
+  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>

+ 46 - 0
card/elements/fill-pane/ElemFillPane.vue

@@ -0,0 +1,46 @@
+<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: "elem-fill-pane",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  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
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 };

+ 16 - 7
card/elements/fill-question/ElemFillQuestion.vue

@@ -1,6 +1,9 @@
 <template>
   <div :class="classes">
-    <div class="elem-title" v-if="data.startNumber === data.parent.startNumber">
+    <div
+      class="elem-title"
+      v-if="data.parent && data.startNumber === data.parent.startNumber"
+    >
       {{ data.parent.topicName }}
     </div>
     <div class="elem-body">
@@ -14,7 +17,7 @@
           class="question-item"
           v-for="(question, qindex) in group"
           :key="qindex"
-          :style="questionGapStyles"
+          :style="getQuestionGapStyles(group, qindex)"
         >
           <span
             class="option-item"
@@ -56,11 +59,11 @@ export default {
         marginRight: this.data.groupGap + "px"
       };
     },
-    questionGapStyles() {
-      return this.data.optionDirection === "vertical"
-        ? { marginRight: this.data.questionGap + "px" }
-        : { marginBottom: this.data.questionGap + "px" };
-    },
+    // questionGapStyles() {
+    //   return this.data.optionDirection === "vertical"
+    //     ? { marginRight: this.data.questionGap + "px" }
+    //     : { marginBottom: this.data.questionGap + "px" };
+    // },
     optionGapStyles() {
       const styles =
         this.data.optionDirection === "vertical"
@@ -112,6 +115,12 @@ export default {
           .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" };
     }
   },
   watch: {

+ 14 - 6
card/elements/fill-question/model.js

@@ -1,4 +1,4 @@
-import { getElementId } from "../../plugins/utils";
+import { getElementId, randomCode } from "../../plugins/utils";
 import { BOOLEAN_TYPE } from "../../enumerate";
 
 const MODEL = {
@@ -31,18 +31,25 @@ const MODEL = {
 const getModel = () => {
   return {
     id: getElementId(),
+    key: randomCode(),
     ...MODEL
   };
 };
 
-const getFullModel = (model, columnNumber) => {
+const getFullModel = (model, { pageSize, columnNumber }) => {
   const parent = { ...model };
   // 不同栏数,不同选项个数,每一行对应的组数
   // 以一行4题,每题4选项为标准展示效果
   const numberPerChildren = {
-    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]
+    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 = {
@@ -50,7 +57,7 @@ const getFullModel = (model, columnNumber) => {
   //   3: [0, 0, 4, 3, 2, 2, 2, 2, 1],
   //   4: [0, 0, 3, 2, 2, 2, 1]
   // };
-  const numList = numberPerChildren[columnNumber];
+  const numList = numberPerChildren[pageSize][columnNumber];
   const groupPerLine =
     model.optionCount > numList.length
       ? numList.pop()
@@ -61,6 +68,7 @@ const getFullModel = (model, columnNumber) => {
   for (let i = 0; i < total; i++) {
     let child = Object.assign({}, parent, {
       id: getElementId(),
+      key: randomCode(),
       groupPerLine,
       startNumber: model.startNumber + i * numPerLine,
       questionsCount:

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

@@ -0,0 +1,205 @@
+<template>
+  <div class="edit-fill-table ">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      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
+              style="width:60px;"
+              v-model="modalForm.rowCount"
+              :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
+              style="width:60px;"
+              v-model="modalForm.colCount"
+              :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
+              style="width:60px;"
+              v-model="modalForm.paddingTop"
+              :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
+              style="width:60px;"
+              v-model="modalForm.paddingLeft"
+              :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
+          style="width:125px;"
+          v-model="modalForm.lineHeight"
+          :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: "edit-fill-table",
+  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>

+ 37 - 0
card/elements/fill-table/ElemFillTable.vue

@@ -0,0 +1,37 @@
+<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: "elem-fill-table",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  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
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 };

+ 1 - 1
card/elements/grids/EditGrids.vue

@@ -21,7 +21,7 @@
         <el-input-number
           style="width:125px;"
           v-model.number="modalForm.rowCount"
-          :min="2"
+          :min="1"
           :max="100"
           :step="1"
           step-strictly

+ 2 - 1
card/elements/grids/model.js

@@ -1,4 +1,4 @@
-import { getElementId } from "../../plugins/utils";
+import { getElementId, randomCode } from "../../plugins/utils";
 
 const MODEL = {
   type: "GRIDS",
@@ -18,6 +18,7 @@ const MODEL = {
 const getModel = () => {
   return {
     id: getElementId(),
+    key: randomCode(),
     ...MODEL
   };
 };

+ 2 - 1
card/elements/image/model.js

@@ -1,4 +1,4 @@
-import { getElementId } from "../../plugins/utils";
+import { getElementId, randomCode } from "../../plugins/utils";
 
 const MODEL = {
   type: "IMAGE",
@@ -15,6 +15,7 @@ const MODEL = {
 const getModel = () => {
   return {
     id: getElementId(),
+    key: randomCode(),
     ...MODEL
   };
 };

+ 2 - 1
card/elements/line/model.js

@@ -1,4 +1,4 @@
-import { getElementId } from "../../plugins/utils";
+import { getElementId, randomCode } from "../../plugins/utils";
 
 const LINE_HORIZONTAL = {
   type: "LINE_HORIZONTAL",
@@ -28,6 +28,7 @@ const getModel = type => {
   const model = type === "HORIZONTAL" ? LINE_HORIZONTAL : LINE_VERTICAL;
   return {
     id: getElementId(),
+    key: randomCode(),
     ...model
   };
 };

+ 2 - 1
card/elements/lines/model.js

@@ -1,4 +1,4 @@
-import { getElementId } from "../../plugins/utils";
+import { getElementId, randomCode } from "../../plugins/utils";
 
 const MODEL = {
   type: "LINES",
@@ -18,6 +18,7 @@ const MODEL = {
 const getModel = () => {
   return {
     id: getElementId(),
+    key: randomCode(),
     ...MODEL
   };
 };

+ 166 - 0
card/elements/page/EditPage.vue

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

+ 69 - 0
card/elements/page/model.js

@@ -0,0 +1,69 @@
+import {
+  getElementId,
+  randomCode,
+  deepCopy,
+  getNumList,
+  objAssign
+} from "../../plugins/utils";
+
+const MODEL = {
+  type: "PAGE",
+  columnGap: 20,
+  locators: [],
+  globals: [],
+  columns: [],
+  pageSize: "A3",
+  columnNumber: 2,
+  showForbidArea: false
+};
+// 可编辑栏
+const COLUMN = {
+  type: "COLUMN",
+  x: "",
+  y: "",
+  w: "",
+  h: "",
+  elements: []
+};
+// 定位点
+const LOCATOR = {
+  type: "LOCATOR",
+  x: "",
+  y: "",
+  w: "",
+  h: ""
+};
+
+const getModel = (datas = {}) => {
+  let npage = deepCopy(MODEL);
+  npage = objAssign(npage, datas);
+  npage.id = getElementId();
+  const { pageSize, columnNumber } = npage;
+  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) => {
+    const id = getElementId();
+    return [
+      {
+        ...LOCATOR,
+        id: `locator-${id}-${index}0`
+      },
+      {
+        ...LOCATOR,
+        id: `locator-${id}-${index}1`
+      }
+    ];
+  });
+  npage.columns = getNumList(columnNumber).map(() => {
+    return { id: `column-${randomCode()}`, ...deepCopy(COLUMN) };
+  });
+  return npage;
+};
+
+export { MODEL, getModel };

+ 71 - 0
card/elements/pane/EditPane.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="edit-pane">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :key="modalForm.id"
+      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>
+  </div>
+</template>
+
+<script>
+import ColorSelect from "../../components/common/ColorSelect";
+import LineStyleSelect from "../../components/common/LineStyleSelect";
+import LineWidthSelect from "../../components/common/LineWidthSelect";
+
+const initModalForm = {
+  id: "",
+  bold: "1px",
+  color: "#000000",
+  bgColor: "#ffffff",
+  style: "solid"
+};
+
+export default {
+  name: "edit-pane",
+  components: { ColorSelect, LineStyleSelect, LineWidthSelect },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      modalForm: { ...initModalForm },
+      predefineColors: ["#000000", "#666666", "#999999", "#ffffff"]
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+    },
+    submit() {
+      this.$emit("modified", this.modalForm);
+    }
+  }
+};
+</script>

+ 28 - 0
card/elements/pane/ElemPane.vue

@@ -0,0 +1,28 @@
+<template>
+  <div class="elem-pane" :style="styles"></div>
+</template>
+
+<script>
+export default {
+  name: "elem-pane",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  computed: {
+    styles() {
+      return {
+        backgroundColor: this.data.bgColor,
+        borderStyle: this.data.style,
+        borderWidth: this.data.bold,
+        borderColor: this.data.color
+      };
+    }
+  },
+  data() {
+    return {};
+  },
+  methods: {}
+};
+</script>

+ 24 - 0
card/elements/pane/model.js

@@ -0,0 +1,24 @@
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
+
+const MODEL = {
+  type: "PANE",
+  x: 0,
+  y: 0,
+  w: 200,
+  h: 200,
+  sign: "",
+  bold: "1px",
+  color: "#000000",
+  bgColor: "#ffffff",
+  style: "solid"
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    key: randomCode(),
+    ...deepCopy(MODEL)
+  };
+};
+
+export { MODEL, getModel };

+ 1 - 1
card/elements/text/EditText.vue

@@ -49,7 +49,7 @@ import FontFamilySelect from "../../components/common/FontFamilySelect";
 
 const initModalForm = {
   id: "",
-  fontSize: "10.5pt",
+  fontSize: "14px",
   color: "",
   fontFamily: "",
   fontWeight: 400,

+ 2 - 1
card/elements/text/model.js

@@ -1,4 +1,4 @@
-import { getElementId, deepCopy } from "../../plugins/utils";
+import { getElementId, randomCode, deepCopy } from "../../plugins/utils";
 
 const MODEL = {
   type: "TEXT",
@@ -22,6 +22,7 @@ const MODEL = {
 const getModel = () => {
   return {
     id: getElementId(),
+    key: randomCode(),
     ...deepCopy(MODEL)
   };
 };

+ 2 - 1
card/elements/topic-head/model.js

@@ -1,4 +1,4 @@
-import { getElementId } from "../../plugins/utils";
+import { getElementId, randomCode } from "../../plugins/utils";
 
 const MODEL = {
   type: "TOPIC_HEAD",
@@ -21,6 +21,7 @@ const getModel = (content, type, isColumnFirst) => {
   element.isColumnFirst = isColumnFirst;
   element.h = isColumnFirst ? element.h : element.h + 10;
   element.id = getElementId();
+  element.key = randomCode();
   return element;
 };
 

+ 79 - 0
card/enumerate.js

@@ -17,3 +17,82 @@ export const DIRECTION_TYPE = {
   horizontal: "横向",
   vertical: "纵向"
 };
+
+export const SHORTCUT_KEYS = [
+  {
+    name: "向上轻微移动",
+    keys: ["ArrowUp"]
+  },
+  {
+    name: "向下轻微移动",
+    keys: ["ArrowDown"]
+  },
+  {
+    name: "向左轻微移动",
+    keys: ["ArrowLeft"]
+  },
+  {
+    name: "向右轻微移动",
+    keys: ["ArrowRight"]
+  },
+  {
+    name: "向上大幅移动",
+    keys: ["Shift", "ArrowUp"]
+  },
+  {
+    name: "向下大幅移动",
+    keys: ["Shift", "ArrowDown"]
+  },
+  {
+    name: "向左大幅移动",
+    keys: ["Shift", "ArrowLeft"]
+  },
+  {
+    name: "向右大幅移动",
+    keys: ["Shift", "ArrowRight"]
+  },
+  {
+    name: "向上移动一层",
+    keys: ["Ctrl", "ArrowUp"]
+  },
+  {
+    name: "向下移动一层",
+    keys: ["Ctrl", "ArrowDown"]
+  },
+  {
+    name: "复制选中元素",
+    keys: ["Ctrl", "C"]
+  },
+  {
+    name: "粘贴元素",
+    keys: ["Ctrl", "V"]
+  },
+  {
+    name: "编辑选中元素",
+    keys: ["Ctrl", "E"]
+  },
+  {
+    name: "删除选中元素",
+    keys: ["Delete"]
+  },
+  {
+    name: "新建页面",
+    keys: ["Ctrl", "Alt", "N"]
+  },
+  {
+    name: "删除当前页面",
+    keys: ["Ctrl", "Alt", "D"]
+  },
+  {
+    name: "预览题卡",
+    keys: ["Ctrl", "P"]
+  },
+  {
+    name: "保存题卡",
+    keys: ["Ctrl", "S"]
+  },
+  {
+    name: "提交题卡",
+    keys: ["Ctrl", "Shift", "S"]
+  }
+];

+ 360 - 0
card/modules/free/cardFormatTransform.js

@@ -0,0 +1,360 @@
+import { CARD_VERSION } from "../../enumerate";
+import { deepCopy } from "../../plugins/utils";
+
+const initIndex = {
+  question: 1,
+  absent: 1,
+  paperType: 1,
+  examNumber: 1,
+  selective: 1,
+  pageNumber: 1
+};
+let fillAreaIndex = { ...initIndex };
+
+const VALID_ELEMENTS_FOR_EXTERNAL = [
+  "LOCATOR",
+  "BARCODE",
+  "FILL_QUESTION",
+  "FILL_LINE",
+  "FILL_NUMBER",
+  "FILL_FIELD",
+  "FILL_TABLE",
+  "LINES",
+  "GRIDS"
+];
+
+function initFillAreaIndex() {
+  fillAreaIndex = { ...initIndex };
+}
+
+function getFillAreaIndex(type) {
+  return fillAreaIndex[type]++;
+}
+
+function getPreviewElementById(id) {
+  return document.getElementById(`preview-${id}`);
+}
+
+function getOffsetInfo(dom) {
+  let { offsetTop, offsetLeft } = dom;
+  let parentNode = dom.offsetParent;
+  while (parentNode.className.indexOf("page-box") === -1) {
+    offsetTop += parentNode.offsetTop;
+    offsetLeft += parentNode.offsetLeft;
+    parentNode = parentNode.offsetParent;
+  }
+  const pw = parentNode.offsetWidth;
+  const ph = parentNode.offsetHeight;
+
+  const infos = [
+    offsetLeft / pw,
+    offsetTop / ph,
+    dom.offsetWidth / pw,
+    dom.offsetHeight / ph
+  ];
+
+  return infos.map(num => num.toFixed(10) * 1);
+}
+
+// locator: this.getLocatorInfo(page.locators),
+// barcode: [],
+// info_area: [],
+// fill_area: [],
+// answer_area: []
+
+const elementInfoFunc = {
+  LOCATOR: locators => {
+    const result = locators.map(locatorGroup => {
+      const locatorInfos = locatorGroup.map(locator => {
+        return getOffsetInfo(document.getElementById(locator.id));
+      });
+      return {
+        top: locatorInfos[0],
+        bottom: locatorInfos[1]
+      };
+    });
+    return {
+      locator: result
+    };
+  },
+  BARCODE: element => {
+    return {
+      barcode: [
+        {
+          field: element.field,
+          area: getOffsetInfo(getPreviewElementById(element.id))
+        }
+      ]
+    };
+  },
+  FILL_QUESTION: element => {
+    const dom = getPreviewElementById(element.id);
+    const single = !element.isMultiply;
+    const horizontal = element.optionDirection === "horizontal";
+
+    let fillAreas = [];
+    dom.querySelectorAll(".group-item").forEach(groupItem => {
+      let listInfos = [];
+
+      groupItem
+        .querySelectorAll(".question-item")
+        .forEach((questionItem, questionIndex) => {
+          let options = [];
+          questionItem.childNodes.forEach((optionItem, optionIndex) => {
+            if (optionIndex)
+              options[optionIndex - 1] = this.getOffsetInfo(optionItem);
+          });
+          listInfos[questionIndex] = {
+            main_number: element.topicNo,
+            sub_number: questionItem.firstChild.textContent * 1,
+            options
+          };
+        });
+      fillAreas.push({
+        field: "question",
+        index: getFillAreaIndex("question"),
+        single,
+        horizontal,
+        items: listInfos
+      });
+    });
+
+    return {
+      fill_area: fillAreas
+    };
+  },
+  FILL_LINE: element => {
+    const dom = getPreviewElementById(element.id);
+    let sub_numbers = [];
+    for (
+      let i = element.startNumber,
+        len = element.startNumber + element.questionsCount;
+      i < len;
+      i++
+    ) {
+      sub_numbers.push(i);
+    }
+
+    return {
+      answer_area: [
+        {
+          main_number: element.topicNo,
+          sub_numbers,
+          area: getOffsetInfo(dom)
+        }
+      ]
+    };
+  },
+  FILL_NUMBER: element => {
+    let listInfos = [];
+    const dom = getPreviewElementById(element.id);
+
+    dom
+      .querySelectorAll(".fill-number-list")
+      .forEach((questionItem, questionIndex) => {
+        let options = [];
+        questionItem.childNodes.forEach((optionItem, optionIndex) => {
+          options[optionIndex] = getOffsetInfo(optionItem);
+        });
+        listInfos[questionIndex] = {
+          main_number: null,
+          sub_number: null,
+          options
+        };
+      });
+
+    return {
+      fill_area: [
+        {
+          field: "examNumber",
+          index: getFillAreaIndex("examNumber"),
+          single: true,
+          horizontal: false,
+          items: listInfos
+        }
+      ]
+    };
+  },
+  FILL_FIELD: element => {
+    const dom = getPreviewElementById(element.id);
+
+    return {
+      info_area: [getOffsetInfo(dom)]
+    };
+  },
+  FILL_TABLE: element => {
+    const dom = getPreviewElementById(element.id);
+
+    return {
+      info_area: [getOffsetInfo(dom)]
+    };
+  },
+  LINES: element => {
+    const dom = getPreviewElementById(element.id);
+
+    return {
+      answer_area: [
+        {
+          main_number: null,
+          sub_numbers: null,
+          area: getOffsetInfo(dom)
+        }
+      ]
+    };
+  },
+  GRIDS: element => {
+    const dom = getPreviewElementById(element.id);
+
+    return {
+      answer_area: [
+        {
+          main_number: null,
+          sub_numbers: null,
+          area: getOffsetInfo(dom)
+        }
+      ]
+    };
+  }
+};
+
+function getPageNumberInfo() {
+  const dom = document.querySelector(".page-box-0");
+  let options = [];
+  dom
+    .querySelector(".page-number-rect-list")
+    .childNodes.forEach((item, index) => {
+      options[index] = this.getOffsetInfo(item);
+    });
+  return [
+    {
+      field: "pageNumber",
+      index: 1,
+      single: true,
+      horizontal: true,
+      items: [
+        {
+          main_number: null,
+          sub_number: null,
+          options
+        }
+      ]
+    }
+  ];
+}
+
+function parsePageExchange(pages) {
+  initFillAreaIndex();
+
+  const npages = deepCopy(pages);
+  const pageNumberInfo = getPageNumberInfo();
+  npages.forEach((page, pindex) => {
+    let exchange = {
+      locator: elementInfoFunc.LOCATOR(page.locators),
+      barcode: [],
+      info_area: [],
+      fill_area: [],
+      answer_area: []
+    };
+    const elements = [
+      page.globals,
+      ...page.columns.map(column => column.elements)
+    ];
+
+    elements.forEach(elemGroup => {
+      elemGroup.forEach(element => {
+        if (!VALID_ELEMENTS_FOR_EXTERNAL.includes(element.type)) return;
+        const info = elementInfoFunc[element.typ](element);
+        Object.entries(info).forEach(([key, vals]) => {
+          exchange[key] = exchange[key].concat(vals);
+        });
+      });
+    });
+
+    if (!(pindex % 2)) {
+      let pnoInfo = deepCopy(pageNumberInfo);
+      pnoInfo[0].index = getFillAreaIndex("pageNumber");
+      exchange.fill_area = exchange.fill_area.concat(pnoInfo);
+    }
+
+    page.exchange = exchange;
+  });
+
+  return npages;
+}
+
+export function getPageModel({ cardConfig, paperParams, pages }) {
+  let npages = parsePageExchange(pages);
+  npages.forEach(page => {
+    page.exchange.page_size = cardConfig.pageSize;
+  });
+  return JSON.stringify(
+    {
+      version: CARD_VERSION,
+      cardConfig,
+      paperParams,
+      pages: npages
+    },
+    (k, v) => (k.startsWith("_") ? undefined : v)
+  );
+}
+
+// TODO:缺考涂填
+
+// export default {
+//   methods: {
+//     getCardHeadInfo(element) {
+//       const dom = this.getPreviewElementById(element.id);
+//       const headArea = this.getOffsetInfo(dom);
+//       let fill_area = [];
+//       let barcode = [];
+//       // 缺考涂填
+//       if (element.examAbsent && !element.isSimple) {
+//         fill_area.push({
+//           field: "absent",
+//           index: this.getFillAreaIndex("absent"),
+//           single: true,
+//           horizontal: true,
+//           items: [
+//             {
+//               main_number: null,
+//               sub_number: null,
+//               options: [
+//                 this.getOffsetInfo(document.getElementById("dynamic-miss-area"))
+//               ]
+//             }
+//           ]
+//         });
+//       }
+//       // A/B卷类型
+//       if (element.aOrB && !element.isSimple) {
+//         // fill_area
+//         let options = [];
+//         document
+//           .getElementById("head-dynamic-aorb")
+//           .querySelectorAll(".head-dynamic-rect")
+//           .forEach((optionItem, optionIndex) => {
+//             options[optionIndex] = this.getOffsetInfo(optionItem);
+//           });
+//         fill_area.push({
+//           field: "paperType",
+//           index: this.getFillAreaIndex("paperType"),
+//           single: true,
+//           horizontal: true,
+//           items: [
+//             {
+//               main_number: null,
+//               sub_number: null,
+//               options
+//             }
+//           ]
+//         });
+//       }
+
+//       return {
+//         info_area: [headArea],
+//         fill_area,
+//         barcode
+//       };
+//     }
+//   }
+// };

+ 98 - 0
card/modules/free/components/ElementPropEdit.vue

@@ -0,0 +1,98 @@
+<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"
+      :instance="curElement"
+      @modified="modified"
+      :key="curElement.id"
+      ref="ComponentForm"
+    ></component>
+
+    <div slot="footer">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button @click="cancel">取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { getElementName, getElementDesc } from "../elements/model";
+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";
+import EditPane from "../../../elements/pane/EditPane";
+import EditBarcode from "../../../elements/barcode/EditBarcode";
+import EditFillNumber from "../../../elements/fill-number/EditFillNumber";
+import EditFillField from "../../../elements/fill-field/EditFillField";
+import EditFillTable from "../../../elements/fill-table/EditFillTable";
+import EditFillPane from "../../../elements/fill-pane/EditFillPane";
+
+export default {
+  name: "element-prop-edit",
+  components: {
+    EditFillLine,
+    EditFillQuestion,
+    EditFillNumber,
+    EditFillField,
+    EditFillTable,
+    EditFillPane,
+    EditText,
+    EditImage,
+    EditLine,
+    EditLines,
+    EditGrids,
+    EditPane,
+    EditBarcode
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    ...mapState("free", ["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("free", ["setOpenElementEditDialog"]),
+    ...mapActions("free", ["modifyElement"]),
+    cancel() {
+      this.setOpenElementEditDialog(false);
+    },
+    open() {
+      this.setOpenElementEditDialog(true);
+    },
+    submit() {
+      this.$refs.ComponentForm.submit();
+    },
+    modified(element) {
+      element.desc = getElementDesc(element);
+      this.modifyElement(element);
+      this.cancel();
+    }
+  }
+};
+</script>

+ 176 - 0
card/modules/free/components/ElementTierEdit.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="element-tier-edit">
+    <div class="tier-menu">
+      <div
+        v-for="(column, cindex) in curPage.columns"
+        :key="column.id"
+        :class="['tier-menu-item', { 'is-active': column.id === curColumn.id }]"
+        @click="selectColumn(column)"
+      >
+        栏{{ cindex + 1 }}
+      </div>
+    </div>
+    <div
+      ref="TierList"
+      class="tier-list"
+      @drop.prevent="dropInnerElement"
+      @dragover.prevent="dragOver($event)"
+      @dragleave.prevent
+    >
+      <div
+        v-for="element in curColumn.elements"
+        :key="element.id"
+        :class="[
+          'tier-item',
+          {
+            'after-drop': element.id === curDropElementId && isDragDown,
+            'before-drop': element.id === curDropElementId && !isDragDown
+          }
+        ]"
+        :id="`tier-${element.id}`"
+        draggable="true"
+        @dragstart="$event => dragStart($event, element)"
+        @dragend.prevent="dragEnd"
+        @click="selectElement(element)"
+      >
+        <div
+          :class="[
+            'tier-item-cont',
+            {
+              'is-active': curElement.id === element.id
+            }
+          ]"
+        >
+          {{ element.desc }}
+        </div>
+        <!-- <div class="tier-item-cont">{{ element.zindex }}:{{ element.id }}</div> -->
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+
+export default {
+  name: "element-tier-edit",
+  data() {
+    return {
+      curColumn: { id: "1", elements: [] },
+      curDragElement: null,
+      curDropElementId: null,
+      dragStartScreenY: null,
+      isDragDown: false
+    };
+  },
+  computed: {
+    ...mapState("free", ["curPage", "curElement"])
+  },
+  watch: {
+    curPage: {
+      immediate: true,
+      handler(val) {
+        this.selectColumn(val.columns && val.columns[0]);
+      }
+    },
+    curElement(val) {
+      if (!val.id) return;
+      const columnIndex = this.curPage.columns.findIndex(column =>
+        column.elements.find(elem => elem.id === val.id)
+      );
+      if (columnIndex === -1) return;
+      this.selectColumn(this.curPage.columns[columnIndex]);
+    }
+  },
+  methods: {
+    ...mapMutations("free", ["setCurElement"]),
+    ...mapActions("free", ["moveElementToElement"]),
+    selectColumn(column) {
+      if (column) {
+        if (column.id === this.curColumn.id) return;
+        this.curColumn = column;
+      } else {
+        this.curColumn = { id: "1", elements: [] };
+      }
+    },
+    selectElement(element) {
+      this.setCurElement(element);
+    },
+    getRelateElement(dom) {
+      let element = null;
+      let parentNode = dom;
+      while (!element && !parentNode.className.includes("tier-list")) {
+        if (
+          !element &&
+          parentNode["id"] &&
+          parentNode["id"].includes("tier-element")
+        ) {
+          element = parentNode;
+        } else {
+          parentNode = parentNode.parentNode;
+        }
+      }
+
+      return element;
+    },
+    getElementId(tierId) {
+      return tierId.replace("tier-", "");
+    },
+    checkElementsIsSiblings(elementId1, elementId2) {
+      const pos1 = this.curColumn.elements.findIndex(
+        elem => elem.id === elementId1
+      );
+      const pos2 = this.curColumn.elements.findIndex(
+        elem => elem.id === elementId2
+      );
+      return Math.abs(pos1 - pos2) <= 1;
+    },
+    getSiblingElement(elementId, offset) {
+      const pos = this.curColumn.elements.findIndex(
+        elem => elem.id === elementId
+      );
+      return this.curColumn.elements[pos + offset] || null;
+    },
+    dragStart(e, element) {
+      this.dragStartScreenY = e.screenY;
+      this.curDragElement = element;
+    },
+    dragOver(e) {
+      // console.log(e.target);
+      this.isDragDown = e.screenY > this.dragStartScreenY;
+      if (e.target.className.includes("tier-list")) {
+        const curDropElement = this.isDragDown
+          ? this.curColumn.elements.slice(-1)[0]
+          : this.curColumn.elements[0];
+        this.curDropElementId = curDropElement.id;
+        return;
+      }
+
+      const elementDom = this.getRelateElement(e.target);
+      if (!elementDom) return;
+
+      const targetId = this.getElementId(elementDom.id);
+      const curDropElement = this.getSiblingElement(targetId, 0);
+      this.curDropElementId = curDropElement.id;
+    },
+    dropInnerElement() {
+      // console.log(this.curDragElement.id, this.curDropElementId);
+      if (this.curDragElement.id === this.curDropElementId) return;
+
+      // 往下:target上一个位置
+      // 往上:target下一个位置
+      this.moveElementToElement({
+        curElement: this.curDragElement,
+        toElementId: this.curDropElementId,
+        curColumnId: this.curColumn.id,
+        isDragDown: this.isDragDown
+      });
+    },
+    dragEnd() {
+      this.curDragElement = null;
+      this.curDropElementId = null;
+      this.dragStartScreenY = null;
+    }
+  }
+};
+</script>

+ 49 - 0
card/modules/free/components/HelpDialog.vue

@@ -0,0 +1,49 @@
+<template>
+  <el-dialog
+    class="help-dialog"
+    :visible.sync="dialogVisible"
+    title="帮助"
+    top="0"
+    width="640px"
+    :close-on-click-modal="false"
+    append-to-body
+  >
+    <el-table size="medium" :data="SHORTCUT_KEYS">
+      <el-table-column prop="name" label="命令"></el-table-column>
+      <el-table-column prop="keys" label="快捷键">
+        <template slot-scope="scope">
+          <shortcut-key-spin
+            v-for="sk in scope.row.keys"
+            :key="sk"
+            :data="sk"
+          ></shortcut-key-spin>
+        </template>
+      </el-table-column>
+    </el-table>
+    <div slot="footer"></div>
+  </el-dialog>
+</template>
+
+<script>
+import { SHORTCUT_KEYS } from "../../../enumerate";
+import ShortcutKeySpin from "../../../components/common/ShortcutKeySpin";
+
+export default {
+  name: "help-dialog",
+  components: { ShortcutKeySpin },
+  data() {
+    return {
+      dialogVisible: false,
+      SHORTCUT_KEYS
+    };
+  },
+  methods: {
+    cancel() {
+      this.dialogVisible = false;
+    },
+    open() {
+      this.dialogVisible = true;
+    }
+  }
+};
+</script>

+ 131 - 0
card/modules/free/components/PagePropEdit.vue

@@ -0,0 +1,131 @@
+<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-checkbox
+          v-model="form.showForbidArea"
+          @change="showForbidAreaChange"
+          >启用</el-checkbox
+        >
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { objAssign } from "../../../plugins/utils";
+import { getModel as getPageModel } from "../../../elements/page/model";
+
+const COLUMN_OPTIONS = [
+  {
+    value: 1,
+    title: "一栏",
+    label: "one",
+    sizeValid: ["A3", "A4"]
+  },
+  {
+    value: 2,
+    title: "二栏",
+    label: "two",
+    sizeValid: ["A3", "A4"]
+  },
+  {
+    value: 3,
+    title: "三栏",
+    label: "three",
+    sizeValid: ["A3"]
+  },
+  {
+    value: 4,
+    title: "四栏",
+    label: "four",
+    sizeValid: ["A3"]
+  }
+];
+
+export default {
+  name: "page-prop-edit",
+  data() {
+    return {
+      columnOptions: [],
+      pageSizeOptions: ["A3", "A4"],
+      form: {
+        pageSize: "A3",
+        columnNumber: 2,
+        showForbidArea: false
+      },
+      prePageSize: "A3"
+    };
+  },
+  computed: {
+    ...mapState("free", ["curPageNo", "pages", "cardConfig"]),
+    curPage() {
+      return this.pages[this.curPageNo];
+    }
+  },
+  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)
+        );
+      }
+    }
+  },
+  methods: {
+    ...mapMutations("free", [
+      "setPages",
+      "setCurElement",
+      "setCardConfig",
+      "setCurPageNo"
+    ]),
+    ...mapActions("free", ["modifyAllPageShowForbidArea"]),
+    showForbidAreaChange() {
+      this.setCardConfig(this.form);
+      this.modifyAllPageShowForbidArea(this.form.showForbidArea);
+    },
+    configChange() {
+      this.setCardConfig(this.form);
+      const page = getPageModel(this.form);
+      this.setPages([page]);
+      this.setCurPageNo(0);
+      this.setCurElement({});
+    },
+    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>

+ 214 - 0
card/modules/free/components/RightClickMenu.vue

@@ -0,0 +1,214 @@
+<template>
+  <div class="right-click-menu">
+    <div
+      ref="RightMenuBody"
+      class="right-menu-body"
+      :style="styles"
+      v-clickoutside="close"
+      v-if="visible"
+    >
+      <ul>
+        <li v-if="elementIsSelected" @click="toEdit">
+          <i class="el-icon-edit-outline"></i> 编辑
+        </li>
+        <li v-if="elementIsSelected" class="li-danger" @click="toDelete">
+          <i class="el-icon-delete"></i> 删除
+        </li>
+        <li v-if="elementIsSelected" @click="toCopy">
+          <i class="el-icon-copy-document"></i> 复制
+        </li>
+        <li v-if="curCopyElement" @click="toPaste">
+          <i class="el-icon-document-copy"></i> 粘贴
+        </li>
+      </ul>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import Clickoutside from "element-ui/src/utils/clickoutside";
+import { deepCopy, getElementId, randomCode } from "../../../plugins/utils";
+
+export default {
+  name: "right-click-menu",
+  directives: { Clickoutside },
+  data() {
+    return {
+      visible: false,
+      curColumnId: null,
+      styles: {
+        position: "fixed",
+        zIndex: 3000
+      },
+      rightClickPos: {}
+    };
+  },
+  computed: {
+    ...mapState("free", ["curElement", "curCopyElement"]),
+    elementIsSelected() {
+      return !!this.curElement.id;
+    }
+  },
+  mounted() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("free", [
+      "setOpenElementEditDialog",
+      "setCurElement",
+      "setCurCopyElement"
+    ]),
+    ...mapActions("free", ["actElementById", "removeElement", "pasteElement"]),
+    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 { elementId, columnId } = this.getRelateElementId(e.target);
+      // 栏外的点击,不做任何处理
+      if (!columnId) {
+        this.curColumnId = null;
+        this.setCurElement({});
+        this.rightClickPos = {};
+        return;
+      }
+      const { offsetLeft, offsetTop } = this.getOffsetInfo(
+        e.target || e.srcElement
+      );
+      this.rightClickPos = {
+        x: offsetLeft + e.offsetX,
+        y: offsetTop + e.offsetY
+      };
+
+      this.curColumnId = columnId;
+      if (elementId) {
+        this.actElementById(elementId);
+      } else {
+        this.setCurElement({});
+      }
+
+      // 既没有要黏贴的元素也没有选中的元素时,不显示右键菜单
+      if (!this.curCopyElement.id && !this.curElement.id) return;
+
+      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 elementId = null,
+        columnId = null;
+      let parentNode = dom;
+      while (!(columnId || parentNode.className.includes("page-box"))) {
+        if (
+          !elementId &&
+          parentNode["id"] &&
+          parentNode["id"].includes("element-")
+        ) {
+          elementId = parentNode["id"];
+        }
+
+        if (
+          !columnId &&
+          parentNode["id"] &&
+          parentNode["id"].includes("column-")
+        ) {
+          columnId = parentNode["id"];
+        }
+
+        parentNode = parentNode.parentNode;
+      }
+
+      return { elementId, columnId };
+    },
+    getOffsetInfo(dom, endParentClass = "page-column-body") {
+      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
+      };
+    },
+    toEdit() {
+      this.close();
+      this.setOpenElementEditDialog(true);
+    },
+    toDelete() {
+      this.removeSelectElement();
+      // this.close();
+      // this.$confirm("确定要删除当前元素吗?", "提示", {
+      //   type: "warning"
+      // })
+      //   .then(() => {
+      //     this.removeSelectElement();
+      //   })
+      //   .catch(() => {});
+    },
+    removeSelectElement() {
+      this.close();
+      this.removeElement(this.curElement);
+    },
+    toCopy() {
+      this.close();
+      this.setCurCopyElement(deepCopy(this.curElement));
+    },
+    toPaste() {
+      this.close();
+      this.pasteElement({
+        element: {
+          ...deepCopy(this.curCopyElement),
+          ...this.rightClickPos,
+          id: getElementId(),
+          key: randomCode()
+        },
+        toColumnId: this.curColumnId
+      });
+    }
+  },
+  beforeDestroy() {
+    document.oncontextmenu = null;
+    document.removeEventListener("mouseup", this.docMouseUp);
+  }
+};
+</script>

+ 188 - 0
card/modules/free/components/ShortcutKey.vue

@@ -0,0 +1,188 @@
+<template>
+  <div class="shortcut-key"></div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { deepCopy, randomCode, getElementId } from "../../../plugins/utils";
+import { getModel as getPageModel } from "../../../elements/page/model";
+
+export default {
+  name: "shortcut-key",
+  data() {
+    return {};
+  },
+  computed: {
+    ...mapState("free", [
+      "cardConfig",
+      "pages",
+      "curElement",
+      "curPage",
+      "curPageNo",
+      "curColumnId",
+      "curCopyElement"
+    ])
+  },
+  mounted() {
+    this.registShortcutKey();
+  },
+  methods: {
+    ...mapMutations("free", ["setCurCopyElement", "setOpenElementEditDialog"]),
+    ...mapActions("free", [
+      "modifyElement",
+      "addPage",
+      "removePage",
+      "pasteElement",
+      "removeElement",
+      "moveElementZindex"
+    ]),
+    keyEvent(e) {
+      // console.log(e);
+
+      // move
+      const moveAction = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
+      if (moveAction.includes(e.code)) {
+        e.preventDefault();
+        // move zindex
+        if (e.ctrlKey) {
+          this.toMoverElementZindex(e.code);
+          return;
+        }
+        const size = e.shiftKey ? 10 : 1;
+        this.toMoveElement(e.code, size);
+        return;
+      }
+
+      if (e.code === "KeyC" && e.ctrlKey && !e.repeat) {
+        e.preventDefault();
+        this.toCopeElement();
+        return;
+      }
+      if (e.code === "KeyV" && e.ctrlKey && !e.repeat) {
+        e.preventDefault();
+        this.toPasteElement();
+        return;
+      }
+      if (e.code === "KeyE" && e.ctrlKey && !e.repeat) {
+        e.preventDefault();
+        this.toEditElement();
+        return;
+      }
+      if (e.code === "KeyP" && e.ctrlKey && !e.repeat) {
+        e.preventDefault();
+        this.$emit("sk-preview");
+        return;
+      }
+      if (e.code === "KeyS" && e.ctrlKey && !e.shiftKey && !e.repeat) {
+        e.preventDefault();
+        this.$emit("sk-save");
+        return;
+      }
+      if (e.code === "KeyS" && e.ctrlKey && e.shiftKey && !e.repeat) {
+        e.preventDefault();
+        this.$emit("sk-submit");
+        return;
+      }
+
+      if (
+        e.code === "Delete" &&
+        !e.ctrlKey &&
+        !e.altKey &&
+        !e.shiftKey &&
+        !e.repeat
+      ) {
+        if (!this.curElement.id) return;
+        e.preventDefault();
+        this.removeElement(this.curElement);
+        return;
+      }
+
+      // create new page
+      // ctrl+n / ctrl+shift+n :无法重置浏览器默认操作
+      if (e.code === "KeyN" && e.ctrlKey && e.altKey && !e.repeat) {
+        e.preventDefault();
+        this.toAddPage();
+        return;
+      }
+      // ctrl+alt+delete :无法重置系统默认操作
+      if (e.code === "KeyD" && e.ctrlKey && e.altKey && !e.repeat) {
+        e.preventDefault();
+        this.toDeletePage();
+        return;
+      }
+    },
+    // actions
+    toMoveElement(direction, size) {
+      if (!this.curElement.id) return;
+      const actionSet = {
+        ArrowUp: ["y", -1],
+        ArrowDown: ["y", 1],
+        ArrowLeft: ["x", -1],
+        ArrowRight: ["x", 1]
+      };
+      const [moveParam, moveDirection] = actionSet[direction];
+      const moveData = {
+        [moveParam]: this.curElement[moveParam] + moveDirection * size,
+        key: randomCode()
+      };
+      this.modifyElement(Object.assign({}, this.curElement, moveData));
+    },
+    toMoverElementZindex(direction) {
+      if (!this.curElement.id) return;
+
+      const actionSet = {
+        ArrowUp: -1,
+        ArrowDown: 1
+      };
+      if (!actionSet[direction]) return;
+      this.moveElementZindex({
+        curElement: this.curElement,
+        pos: actionSet[direction]
+      });
+    },
+    toAddPage() {
+      const page = getPageModel(this.cardConfig);
+      this.addPage(page);
+    },
+    toDeletePage() {
+      if (this.pages.length === 1) {
+        this.$message.error("只剩最后一页,不能再删除了");
+        return;
+      }
+      this.removePage(this.curPage);
+    },
+    toCopeElement() {
+      this.setCurCopyElement(deepCopy(this.curElement));
+    },
+    toPasteElement() {
+      if (!this.curCopyElement.id) return;
+      if (!this.curColumnId) return;
+
+      this.pasteElement({
+        element: {
+          ...deepCopy(this.curCopyElement),
+          x: 50,
+          y: 50,
+          id: getElementId(),
+          key: randomCode()
+        },
+        toColumnId: this.curColumnId
+      });
+    },
+    toEditElement() {
+      if (!this.curElement.id) return;
+      this.setOpenElementEditDialog(true);
+    },
+    // event
+    registShortcutKey() {
+      document.addEventListener("keydown", this.keyEvent);
+    },
+    removeShortcutKey() {
+      document.removeEventListener("keydown", this.keyEvent);
+    }
+  },
+  beforeDestroy() {
+    this.removeShortcutKey();
+  }
+};
+</script>

+ 119 - 0
card/modules/free/components/TopicColumnEdit.vue

@@ -0,0 +1,119 @@
+<template>
+  <div
+    :class="['topic-column-edit', { 'is-active': curColumnId === data.id }]"
+    :id="data.id"
+    @drop.prevent="dropInnerElement($event)"
+    @dragover.prevent
+    @dragleave.prevent
+    @mousedown="columnBodyClick"
+  >
+    <div v-if="data.elements.length" class="page-column-body">
+      <topic-element-edit
+        v-for="element in data.elements"
+        :key="element.key"
+        :data="element"
+        :transform-fit="rebuildGuides"
+        @resize-over="elementResizeOver"
+      ></topic-element-edit>
+
+      <!-- guide-lines -->
+      <div class="element-guide-lines">
+        <div
+          class="guide-line guide-line-x"
+          v-for="line in xLines"
+          :key="`x-${line.top}`"
+          :style="line"
+        ></div>
+        <div
+          class="guide-line guide-line-y"
+          v-for="line in yLines"
+          :key="`y-${line.left}`"
+          :style="line"
+        ></div>
+      </div>
+    </div>
+    <div class="page-column-body" v-else>
+      <div class="page-column-forbid-area" v-if="curPage.showForbidArea">
+        <p>该区域严禁作答</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState, mapActions, mapMutations } from "vuex";
+import guideLinesMixins from "../../../mixins/guideLines";
+import TopicElementEdit from "./TopicElementEdit";
+import { getElementDesc } from "../elements/model";
+
+export default {
+  name: "topic-column-edit",
+  mixins: [guideLinesMixins],
+  components: { TopicElementEdit },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    ...mapState("free", ["curDragElement", "curPage", "curColumnId"])
+  },
+  methods: {
+    ...mapMutations("free", [
+      "setCurDragElement",
+      "setCurElement",
+      "setCurColumnId"
+    ]),
+    ...mapActions("free", ["addElement", "modifyElement"]),
+    dropInnerElement(e) {
+      let { offsetX: x, offsetY: y } = e;
+      const { offsetLeft, offsetTop } = this.getOffsetInfo(
+        e.target || e.srcElement
+      );
+
+      const curElement = {
+        ...this.curDragElement,
+        x: x + offsetLeft,
+        y: y + offsetTop,
+        desc: getElementDesc(this.curDragElement)
+      };
+      const columnNo = this.curPage.columns.findIndex(
+        col => col.id === this.data.id
+      );
+
+      this.clear();
+      this.setCurDragElement({});
+      this.addElement({ element: curElement, columnIndex: columnNo });
+    },
+    getOffsetInfo(dom, endParentClass = "page-column-body") {
+      let parentNode = dom;
+      let offsetTop = 0,
+        offsetLeft = 0;
+      while (!parentNode.className.includes(endParentClass)) {
+        offsetTop += parentNode.offsetTop;
+        offsetLeft += parentNode.offsetLeft;
+        parentNode = parentNode.offsetParent;
+      }
+      return {
+        offsetLeft,
+        offsetTop
+      };
+    },
+    elementResizeOver(element) {
+      this.clear();
+      this.modifyElement(element);
+    },
+    columnBodyClick() {
+      console.log(this.data.id);
+      this.setCurColumnId(this.data.id);
+      this.setCurElement({});
+    },
+    rebuildGuides(element, actionType) {
+      return this.rebuild(this.data.elements, element, actionType);
+    }
+  }
+};
+</script>

+ 127 - 0
card/modules/free/components/TopicElementEdit.vue

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

+ 81 - 0
card/modules/free/components/TopicElementPreview.vue

@@ -0,0 +1,81 @@
+<template>
+  <div class="topic-element-preview">
+    <div
+      :class="classes"
+      :id="`preview-${data.id}`"
+      :data-type="data.type"
+      :style="styles"
+    >
+      <component :is="compName" :data="data" preview></component>
+    </div>
+  </div>
+</template>
+
+<script>
+import PreviewFillQuestion from "../elements/fill-question/ElemFillQuestion";
+import PreviewFillLine from "../elements/fill-line/ElemFillLine";
+import PreviewText from "../../../elements/text/ElemText";
+import PreviewImage from "../../../elements/image/ElemImage";
+import PreviewLine from "../../../elements/line/ElemLine";
+import PreviewLines from "../../../elements/lines/ElemLines";
+import PreviewGrids from "../../../elements/grids/ElemGrids";
+import PreviewPane from "../../../elements/pane/ElemPane";
+import PreviewBarcode from "../../../elements/barcode/ElemBarcode";
+import PreviewFillNumber from "../../../elements/fill-number/ElemFillNumber";
+import PreviewFillField from "../../../elements/fill-field/ElemFillField";
+import PreviewFillTable from "../../../elements/fill-table/ElemFillTable";
+import PreviewFillPane from "../../../elements/fill-pane/ElemFillPane";
+
+export default {
+  name: "topic-element-preview",
+  components: {
+    PreviewFillQuestion,
+    PreviewFillLine,
+    PreviewFillNumber,
+    PreviewFillField,
+    PreviewFillTable,
+    PreviewFillPane,
+    PreviewText,
+    PreviewImage,
+    PreviewLine,
+    PreviewLines,
+    PreviewGrids,
+    PreviewPane,
+    PreviewBarcode
+  },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    elementName() {
+      const name = this.data.type.toLowerCase().replace("_", "-");
+      return name.includes("line-") ? "line" : name;
+    },
+    compName() {
+      return `preview-${this.elementName}`;
+    },
+    classes() {
+      return [
+        "topic-preview",
+        "element-item",
+        `element-item-${this.elementName}`
+      ];
+    },
+    styles() {
+      return {
+        left: this.data.x + "px",
+        top: this.data.y + "px",
+        width: this.data.w + "px",
+        height: this.data.h + "px",
+        zIndex: this.data.zindex
+      };
+    }
+  },
+  methods: {}
+};
+</script>

+ 256 - 0
card/modules/free/elements/fill-line/EditFillLine.vue

@@ -0,0 +1,256 @@
+<template>
+  <div class="edit-fill-line ">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      label-width="120px"
+    >
+      <el-form-item prop="endNumber" label="起止题号:">
+        <el-input-number
+          style="width:40px;"
+          v-model="modalForm.startNumber"
+          :min="0"
+          :max="999"
+          :step="1"
+          step-strictly
+          :controls="false"
+          @change="lineTypeChange"
+        ></el-input-number>
+        <span class="el-input-split"></span>
+        <el-input-number
+          style="width:40px;"
+          v-model="modalForm.endNumber"
+          :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
+          style="width:125px;"
+          v-model.number="modalForm.lineSpacing"
+          :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
+          style="width:125px;"
+          v-model="modalForm.questionNumberPerLine"
+          :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
+          style="width:125px;"
+          v-model.trim="modalForm.numberPre"
+          :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
+          style="width:125px;"
+          v-model="modalForm.lineNumberPerQuestion"
+          :min="1"
+          :max="15"
+          :step="1"
+          step-strictly
+          :controls="false"
+          @change="lineTypeChange"
+        ></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 modalForm.questionLineNums" :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";
+import { deepCopy } from "../../../../plugins/utils";
+import { getModel } from "./model";
+
+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: "edit-fill-line",
+  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 questionLineNumsValidater = (rule, value, callback) => {
+      if (value.some(item => !item.count)) {
+        return callback(new Error("请输入每题空数"));
+      }
+
+      callback();
+    };
+
+    return {
+      modalForm: { ...initModalForm },
+      DIRECTION_TYPE,
+      rules: {
+        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"
+          }
+        ],
+        questionLineNums: [
+          {
+            required: true,
+            validator: questionLineNumsValidater,
+            trigger: "change"
+          }
+        ]
+      }
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = deepCopy(val);
+      this.modalForm.endNumber =
+        this.modalForm.startNumber + this.modalForm.questionsCount - 1;
+    },
+    lineTypeChange() {
+      if (
+        !this.modalForm.startNumber ||
+        !this.modalForm.endNumber ||
+        this.modalForm.startNumber > this.modalForm.endNumber
+      )
+        return;
+
+      this.modalForm.questionsCount =
+        this.modalForm.endNumber - this.modalForm.startNumber + 1;
+      this.modalForm = getModel(this.modalForm);
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate().catch(() => {});
+      if (!valid) return;
+
+      this.$emit("modified", deepCopy(this.modalForm));
+    }
+  }
+};
+</script>

+ 83 - 0
card/modules/free/elements/fill-line/ElemFillLine.vue

@@ -0,0 +1,83 @@
+<template>
+  <div class="elem-fill-line">
+    <div class="elem-body" v-if="data.questionDirection === 'vertical'">
+      <ul
+        class="elem-fill-quesiton"
+        v-for="question in data.questionLineNums"
+        :key="question.no"
+        :style="groupStyles"
+      >
+        <li class="elem-fill-no">
+          <span :style="lineNoStyles"
+            >{{ data.numberPre }}{{ question.no }}.</span
+          >
+        </li>
+        <li
+          class="elem-fill-line"
+          v-for="line in question.count"
+          :key="line"
+          :style="lineStyles"
+        ></li>
+      </ul>
+    </div>
+    <div v-else class="elem-body">
+      <div v-for="question in data.questionLineNums" :key="question.no">
+        <ul
+          class="elem-fill-quesiton"
+          v-for="line in question.count"
+          :key="line"
+          :style="groupStyles"
+        >
+          <li v-if="line === 1" class="elem-fill-no">
+            <span :style="lineNoStyles">
+              {{ data.numberPre }}{{ question.no }}.
+            </span>
+          </li>
+          <li
+            v-if="line !== question.count"
+            class="elem-fill-comma"
+            :style="lineNoStyles"
+          >
+            ,
+          </li>
+          <li class="elem-fill-line" :style="lineStyles"></li>
+        </ul>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "elem-fill-line",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  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>

+ 60 - 0
card/modules/free/elements/fill-line/model.js

@@ -0,0 +1,60 @@
+import { deepCopy, getElementId, randomCode } from "../../../../plugins/utils";
+
+const MODEL = {
+  type: "FILL_LINE",
+  x: 0,
+  y: 0,
+  w: 500,
+  h: 100,
+  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 = (options = {}) => {
+  let model = Object.assign({}, deepCopy(MODEL), options);
+  model.key = randomCode();
+  if (!model.id) model.id = getElementId();
+
+  if (model.questionLineType === "norm") {
+    let questionLineNums = [];
+    for (
+      let j = model.startNumber;
+      j < model.startNumber + model.questionsCount;
+      j++
+    ) {
+      questionLineNums.push({
+        no: j,
+        count: model.lineNumberPerQuestion
+      });
+    }
+    model.questionLineNums = questionLineNums;
+  } else {
+    const orgQuestionLineCounts = model.questionLineNums.map(
+      item => item.count
+    );
+    let questionLineNums = [];
+    for (let j = 0; j < model.questionsCount; j++) {
+      questionLineNums.push({
+        no: j + model.startNumber,
+        count: orgQuestionLineCounts[j] || model.lineNumberPerQuestion
+      });
+    }
+    model.questionLineNums = questionLineNums;
+  }
+
+  return model;
+};
+
+export { MODEL, getModel };

+ 239 - 0
card/modules/free/elements/fill-question/EditFillQuestion.vue

@@ -0,0 +1,239 @@
+<template>
+  <div class="edit-fill-question">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      label-width="120px"
+    >
+      <el-form-item prop="endNumber" label="起止题号:">
+        <el-input-number
+          style="width:40px;"
+          v-model="modalForm.startNumber"
+          :min="0"
+          :max="999"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+        <span class="el-input-split"></span>
+        <el-input-number
+          style="width:40px;"
+          v-model="modalForm.endNumber"
+          :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
+          style="width:125px;"
+          v-model="modalForm.optionCount"
+          :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"
+          @change="selectTypeChange"
+          :disabled="modalForm.isMultiply"
+          >判断题</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: "edit-fill-question",
+  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: {
+        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.$emit("modified", this.modalForm);
+    }
+  }
+};
+</script>

+ 123 - 0
card/modules/free/elements/fill-question/ElemFillQuestion.vue

@@ -0,0 +1,123 @@
+<template>
+  <div :class="classes">
+    <div class="elem-body">
+      <ul
+        class="group-item"
+        v-for="(group, gindex) in questions"
+        :key="gindex"
+        :style="gindex !== questions.length - 1 ? groupGapStyles : null"
+      >
+        <li
+          class="question-item"
+          v-for="(question, qindex) in group"
+          :key="qindex"
+          :style="questionGapStyles"
+        >
+          <span
+            class="option-item"
+            v-for="(option, oindex) in question"
+            :key="oindex"
+            :style="optionGapStyles"
+          >
+            <i>{{ option }}</i>
+          </span>
+        </li>
+      </ul>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "elem-fill-question",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  computed: {
+    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
+        }
+      ];
+    },
+    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;
+    }
+  },
+  data() {
+    return {
+      questions: []
+    };
+  },
+  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("");
+      }
+    }
+  },
+  watch: {
+    data: {
+      immediate: true,
+      handler(val) {
+        this.parseQuestion(val);
+      }
+    }
+  }
+};
+</script>

+ 39 - 0
card/modules/free/elements/fill-question/model.js

@@ -0,0 +1,39 @@
+import { getElementId, randomCode } from "../../../../plugins/utils";
+import { BOOLEAN_TYPE } from "../../../../enumerate";
+
+const MODEL = {
+  type: "FILL_QUESTION",
+  x: 0,
+  y: 0,
+  w: 500,
+  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
+  };
+};
+
+export { MODEL, getModel };

+ 166 - 0
card/modules/free/elements/model.js

@@ -0,0 +1,166 @@
+// 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 createPane } from "../../../elements/pane/model";
+import { getModel as createBarcode } from "../../../elements/barcode/model";
+import { getModel as createFillNumber } from "../../../elements/fill-number/model";
+import { getModel as createFillField } from "../../../elements/fill-field/model";
+import { getModel as createFillTable } from "../../../elements/fill-table/model";
+import { getModel as createFillPane } from "../../../elements/fill-pane/model";
+import { getModel as createFillQuestion } from "./fill-question/model";
+import { getModel as createFillLine } from "./fill-line/model";
+
+// available infos
+export const EDITABLE_ELEMENT = [
+  "LINE_HORIZONTAL",
+  "LINE_VERTICAL",
+  "TEXT",
+  "IMAGE",
+  "PANE",
+  "BARCODE"
+];
+
+export const EDITABLE_TOPIC = [
+  "FILL_QUESTION",
+  "FILL_LINE",
+  "FILL_NUMBER",
+  "FILL_FIELD",
+  "FILL_TABLE",
+  "FILL_PANE",
+  "LINES",
+  "GRIDS"
+];
+
+export const ELEMENT_INFOS = {
+  LINES: {
+    name: "多横线",
+    getModel: createLines,
+    getDesc: element => {
+      return `多横线-行数:${element.lineCount}`;
+    }
+  },
+  LINE_HORIZONTAL: {
+    name: "横线",
+    getModel: () => createLine("HORIZONTAL"),
+    getDesc: () => {
+      return `横线`;
+    }
+  },
+  LINE_VERTICAL: {
+    name: "竖线",
+    getModel: () => createLine("VERTICAL"),
+    getDesc: () => {
+      return `竖线`;
+    }
+  },
+  TEXT: {
+    name: "文本",
+    getModel: createText,
+    getDesc: element => {
+      return `文本-${element.content[0].content.substr(0, 20)}`;
+    }
+  },
+  IMAGE: {
+    name: "图片",
+    getModel: createImage,
+    getDesc: () => {
+      return `图片`;
+    }
+  },
+  GRIDS: {
+    name: "网格",
+    getModel: createGrids,
+    getDesc: element => {
+      return `网格-${element.columnCount}×${element.rowCount}`;
+    }
+  },
+  PANE: {
+    name: "方框",
+    getModel: createPane,
+    getDesc: () => {
+      return `方框`;
+    }
+  },
+  BARCODE: {
+    name: "条形码",
+    getModel: createBarcode,
+    getDesc: element => {
+      const name = (element.fields[0] && element.fields[0].name) || "";
+      return `条形码-${name}`;
+    }
+  },
+  FILL_QUESTION: {
+    name: "选项填涂",
+    getModel: createFillQuestion,
+    getDesc: element => {
+      const endNumber = element.startNumber + element.questionsCount - 1;
+      return `选项填涂-${element.startNumber}~${endNumber}`;
+    }
+  },
+  FILL_LINE: {
+    name: "填空",
+    getModel: createFillLine,
+    getDesc: element => {
+      const endNumber = element.startNumber + element.questionsCount - 1;
+      return `填空-${element.startNumber}~${endNumber}`;
+    }
+  },
+  FILL_NUMBER: {
+    name: "号码填涂",
+    getModel: createFillNumber,
+    getDesc: element => {
+      return `号码填涂-${element.name}`;
+    }
+  },
+  FILL_FIELD: {
+    name: "变量",
+    getModel: createFillField,
+    getDesc: element => {
+      const fieldNames = element.fields.map(field => field.name).join(",");
+      return `变量-${fieldNames}`;
+    }
+  },
+  FILL_TABLE: {
+    name: "表格",
+    getModel: createFillTable,
+    getDesc: element => {
+      return `表格-${element.colCount}×${element.rowCount}`;
+    }
+  },
+  FILL_PANE: {
+    name: "方格组",
+    getModel: createFillPane,
+    getDesc: element => {
+      return `方格组-${element.paneCount}`;
+    }
+  }
+};
+
+export const ELEMENT_LIST = EDITABLE_ELEMENT.map(type => {
+  return {
+    ...ELEMENT_INFOS[type],
+    type
+  };
+});
+
+export const TOPIC_LIST = EDITABLE_TOPIC.map(type => {
+  return {
+    ...ELEMENT_INFOS[type],
+    type
+  };
+});
+
+// 获取元件默认数据结构
+export const getElementModel = type => {
+  return { ...ELEMENT_INFOS[type].getModel(), zindex: 9, desc: "" };
+};
+
+export const getElementName = type => {
+  return ELEMENT_INFOS[type].name;
+};
+export const getElementDesc = element => {
+  return ELEMENT_INFOS[element.type].getDesc(element);
+};

+ 209 - 0
card/modules/free/store.js

@@ -0,0 +1,209 @@
+import { randomCode } from "../../plugins/utils";
+
+const state = {
+  cardConfig: {},
+  curElement: {},
+  curDragElement: {},
+  curCopyElement: {},
+  curPage: {},
+  curPageNo: 0,
+  curColumnId: 0,
+  pages: [],
+  openElementEditDialog: false
+};
+
+const mutations = {
+  setCardConfig(state, cardConfig) {
+    state.cardConfig = Object.assign({}, state.cardConfig, cardConfig);
+  },
+  setCurElement(state, curElement) {
+    state.curElement = curElement;
+
+    if (!curElement.id) return;
+    const curColumn = state.curPage.columns.find(
+      column => !!column.elements.find(elem => elem.id === curElement.id)
+    );
+    state.curColumnId = curColumn.id;
+  },
+  setCurDragElement(state, curDragElement) {
+    state.curDragElement = curDragElement;
+  },
+  setCurCopyElement(state, curCopyElement) {
+    state.curCopyElement = curCopyElement;
+  },
+  setCurPageNo(state, curPageNo) {
+    const pageNo = state.pages[curPageNo] ? curPageNo : 0;
+    state.curPage = state.pages[pageNo];
+    state.curPageNo = pageNo;
+  },
+  setCurPage(state, curPage) {
+    state.curPage = curPage;
+  },
+  setCurColumnId(state, curColumnId) {
+    state.curColumnId = curColumnId;
+  },
+  setPages(state, pages) {
+    state.pages = pages;
+  },
+  setOpenElementEditDialog(state, openElementEditDialog) {
+    state.openElementEditDialog = openElementEditDialog;
+  },
+  initState(state) {
+    state.curElement = {};
+    state.curDragElement = {};
+    state.curCopyElement = {};
+    state.curPage = {};
+    state.curPageNo = 0;
+    state.pages = [];
+    state.cardConfig = {};
+    state.openElementEditDialog = false;
+  }
+};
+
+const findElementById = (id, curPage) => {
+  let curElement = null;
+  let columnIndex = 0;
+  let elementIndex = 0;
+  curPage.columns.forEach((column, cindex) => {
+    column.elements.forEach((element, eindex) => {
+      if (curElement) return;
+      if (element.id === id) {
+        curElement = element;
+        columnIndex = cindex;
+        elementIndex = eindex;
+        return;
+      }
+    });
+  });
+  return { curElement, columnIndex, elementIndex };
+};
+
+const updateElementsZindex = elements => {
+  const maxZindex = elements.length + 9 - 1;
+  elements.forEach((element, index) => {
+    element.zindex = maxZindex - index;
+    element.key = randomCode();
+  });
+};
+
+const actions = {
+  addPage({ state }, page) {
+    state.pages.push(page);
+  },
+  modifyPage({ state, commit }, page) {
+    const pos = state.pages.findIndex(p => p.id === page.id);
+    state.pages.splice(pos, 1, page);
+    commit("setCurPageNo", pos);
+  },
+  modifyAllPageShowForbidArea({ state, commit }, showForbidArea) {
+    state.pages.forEach(page => {
+      page.showForbidArea = showForbidArea;
+    });
+    commit("setCurPageNo", state.curPageNo);
+  },
+  removePage({ state, commit }, page) {
+    const pos = state.pages.findIndex(p => p.id === page.id);
+    state.pages.splice(pos, 1);
+    if (pos === state.curPageNo) commit("setCurPageNo", state.curPageNo);
+  },
+  actElementById({ state, commit }, id) {
+    const { curElement } = findElementById(id, state.curPage);
+    if (!curElement) return;
+
+    commit("setCurElement", curElement);
+  },
+  addElement({ state, commit }, { columnIndex, element }) {
+    // 层级大的放最前面,层级自增,即后创建的元素放前面
+    const elements = state.curPage.columns[columnIndex].elements;
+    elements.unshift(element);
+    updateElementsZindex(elements);
+    commit(
+      "setCurElement",
+      elements.find(elem => elem.id === element.id)
+    );
+  },
+  modifyElement({ state, commit }, element) {
+    const { columnIndex, elementIndex } = findElementById(
+      element.id,
+      state.curPage
+    );
+    const elements = state.curPage.columns[columnIndex].elements;
+    elements.splice(elementIndex, 1, element);
+    commit("setCurElement", element);
+  },
+  removeElement({ state }, element) {
+    const { columnIndex, elementIndex } = findElementById(
+      element.id,
+      state.curPage
+    );
+    const elements = state.curPage.columns[columnIndex].elements;
+    elements.splice(elementIndex, 1);
+    updateElementsZindex(elements);
+  },
+  pasteElement({ state, dispatch }, { element, toColumnId }) {
+    const columnIndex = state.curPage.columns.findIndex(
+      col => col.id === toColumnId
+    );
+    dispatch("addElement", { columnIndex, element });
+  },
+  moveElementToElement(
+    { state, commit },
+    { curElement, toElementId, curColumnId, isDragDown }
+  ) {
+    const columnIndex = state.curPage.columns.findIndex(
+      col => col.id === curColumnId
+    );
+    const elements = state.curPage.columns[columnIndex].elements;
+    const curPos = elements.findIndex(elem => elem.id === curElement.id);
+    elements.splice(curPos, 1);
+
+    const toPos = elements.findIndex(elem => elem.id === toElementId);
+    const offset = isDragDown ? 1 : 0;
+    elements.splice(toPos + offset, 0, curElement);
+    // console.log(toPos);
+
+    updateElementsZindex(elements);
+    commit(
+      "setCurElement",
+      elements.find(elem => elem.id === curElement.id)
+    );
+  },
+  moveElementZindex({ state, commit }, { curElement, pos }) {
+    let curColumn = null,
+      curElementPos = null;
+
+    state.curPage.columns.forEach(column =>
+      column.elements.forEach((elem, eindex) => {
+        if (elem.id === curElement.id) {
+          curElementPos = eindex;
+          curColumn = column;
+        }
+      })
+    );
+
+    const elements = curColumn.elements;
+
+    if (
+      (curElementPos === elements.length - 1 && pos > 0) ||
+      (curElementPos === 0 && pos < 0)
+    )
+      return;
+
+    const toPos = curElementPos + pos;
+    elements.splice(curElementPos, 1);
+    elements.splice(toPos, 0, curElement);
+
+    updateElementsZindex(elements);
+    commit(
+      "setCurElement",
+      elements.find(elem => elem.id === curElement.id)
+    );
+  }
+};
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+};

+ 536 - 0
card/modules/free/views/CardFreeDesign.vue

@@ -0,0 +1,536 @@
+<template>
+  <div class="card-design card-free-design">
+    <div class="design-header">
+      <div class="design-header-cont box-justify">
+        <div></div>
+        <el-button
+          class="btn-help"
+          icon="el-icon-question"
+          type="text"
+          @click="showHelp"
+        ></el-button>
+      </div>
+    </div>
+
+    <!-- actions -->
+    <div class="design-action">
+      <div class="design-logo">
+        <h1>
+          <i class="el-icon-d-arrow-left" @click="toExit" title="退出"></i>
+          答题卡制作
+        </h1>
+      </div>
+
+      <div class="action-part">
+        <div class="action-part-title"><h2>基本设置</h2></div>
+        <div class="action-part-body">
+          <page-prop-edit></page-prop-edit>
+        </div>
+      </div>
+      <div class="action-part">
+        <div class="action-part-title"><h2>当前页面设置</h2></div>
+        <div class="action-part-body">
+          <edit-page></edit-page>
+        </div>
+      </div>
+      <div class="action-part">
+        <div class="action-part-title"><h2>复杂元素</h2></div>
+        <div class="action-part-body">
+          <div class="type-list">
+            <div
+              class="type-item"
+              v-for="(item, index) in TOPIC_LIST"
+              :key="index"
+              draggable="true"
+              @dragstart="dragstart(item)"
+            >
+              <el-button><i class="el-icon-plus"></i>{{ item.name }}</el-button>
+            </div>
+          </div>
+          <p class="tips-info">提示:拖动插入元素</p>
+        </div>
+      </div>
+      <div class="action-part">
+        <div class="action-part-title"><h2>简单元素</h2></div>
+        <div class="action-part-body">
+          <div class="type-list">
+            <div
+              class="type-item"
+              v-for="(item, index) in ELEMENT_LIST"
+              :key="index"
+              draggable="true"
+              @dragstart="dragstart(item)"
+            >
+              <el-button><i class="el-icon-plus"></i>{{ item.name }}</el-button>
+            </div>
+            <p class="tips-info">提示:拖动插入元素</p>
+          </div>
+        </div>
+      </div>
+      <div class="action-part">
+        <div class="action-part-title"><h2>元素面板</h2></div>
+        <div class="action-part-body">
+          <!-- element-tier-edit -->
+          <element-tier-edit ref="ElementTierEdit"></element-tier-edit>
+        </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)"
+          >
+            <span>第{{ pageNo + 1 }}页</span>
+            <span class="page-delete" @click.stop="toDeletePage(page)"
+              ><i class="el-icon-error"></i
+            ></span>
+          </el-button>
+          <el-button icon="el-icon-plus" @click="toAddPage"></el-button>
+        </div>
+        <div class="control-right">
+          <el-button
+            type="success"
+            :loading="isSubmit"
+            :disabled="!pages.length"
+            @click="toPreview"
+            >预览</el-button
+          >
+          <el-button
+            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
+          :class="[
+            'page-box',
+            `page-box-${curPage.pageSize}`,
+            `page-box-${curPageNo % 2}`
+          ]"
+          v-if="curPage.id"
+        >
+          <div
+            :class="[
+              'page-locators',
+              `page-locators-${curPage.locators.length}`
+            ]"
+          >
+            <ul
+              class="page-locator-group"
+              v-for="(locator, iind) in curPage.locators"
+              :key="iind"
+            >
+              <li
+                v-for="(elem, eindex) in locator"
+                :key="eindex"
+                :id="elem.id"
+              ></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
+                class="page-column"
+                v-for="(column, columnNo) in curPage.columns"
+                :key="columnNo"
+                :style="{ padding: `0 ${curPage.columnGap / 2}px` }"
+              >
+                <topic-column-edit
+                  class="page-column-main"
+                  :data="column"
+                ></topic-column-edit>
+              </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>
+
+    <!-- element-prop-edit -->
+    <element-prop-edit ref="ElementPropEdit"></element-prop-edit>
+    <!-- right-click-menu -->
+    <right-click-menu></right-click-menu>
+    <!-- shortcut-key -->
+    <shortcut-key
+      @sk-save="skSave"
+      @sk-submit="skSubmit"
+      @sk-preview="skPreview"
+    ></shortcut-key>
+    <!-- help-dialog -->
+    <help-dialog ref="HelpDialog"></help-dialog>
+    <!-- card-view-frame -->
+    <div class="design-preview-frame" v-if="cardPreviewUrl">
+      <iframe :src="cardPreviewUrl" frameborder="0"></iframe>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { cardConfigInfos, cardDetail, saveCard } from "../../../api";
+import { getElementModel, ELEMENT_LIST, TOPIC_LIST } from "../elements/model";
+import { getModel as getPageModel } from "../../../elements/page/model";
+import { CARD_VERSION } from "../../../enumerate";
+
+import TopicColumnEdit from "../components/TopicColumnEdit";
+import ElementPropEdit from "../components/ElementPropEdit";
+import ElementTierEdit from "../components/ElementTierEdit";
+import PagePropEdit from "../components/PagePropEdit";
+import RightClickMenu from "../components/RightClickMenu";
+import ShortcutKey from "../components/ShortcutKey";
+import HelpDialog from "../components/HelpDialog";
+import EditPage from "../../../elements/page/EditPage";
+
+import PageNumber from "../../../components/PageNumber";
+
+export default {
+  name: "card-free-design",
+  components: {
+    TopicColumnEdit,
+    PagePropEdit,
+    EditPage,
+    ElementPropEdit,
+    ElementTierEdit,
+    RightClickMenu,
+    ShortcutKey,
+    HelpDialog,
+    PageNumber
+  },
+  data() {
+    return {
+      cardId: this.$route.params.cardId || this.$ls.get("cardId"),
+      prepareTcPCard: this.$ls.get("prepareTcPCard", {
+        examTaskId: "",
+        courseCode: "",
+        courseName: "",
+        makeMethod: "SELF",
+        cardRuleId: ""
+      }),
+      ELEMENT_LIST,
+      TOPIC_LIST,
+      topicList: [],
+      columnWidth: 0,
+      cardPreviewUrl: "",
+      isSubmit: false,
+      canSave: false
+    };
+  },
+  computed: {
+    ...mapState("free", [
+      "cardConfig",
+      "pages",
+      "curElement",
+      "curPage",
+      "curPageNo",
+      "curDragElement",
+      "curColumnId"
+    ]),
+    isEdit() {
+      return !!this.cardId;
+    }
+  },
+  mounted() {
+    // if (!this.prepareTcPCard.examTaskId && !this.isEdit) {
+    //   this.$message.error("找不到命题任务,请退出题卡制作!");
+    //   return;
+    // }
+    this.initCard();
+    this.registWindowSubmit();
+  },
+  methods: {
+    ...mapMutations("free", [
+      "setCurPageNo",
+      "setCurElement",
+      "setCardConfig",
+      "setOpenElementEditDialog",
+      "setCurDragElement",
+      "setPages",
+      "initState"
+    ]),
+    ...mapActions("free", [
+      "addPage",
+      "removePage",
+      "addElement",
+      "modifyElement"
+    ]),
+    async initCard() {
+      if (this.isEdit) {
+        await this.getCardTempDetail();
+      } else {
+        await this.getCardConfig();
+        this.initPageData();
+      }
+    },
+    getCardTitle(titleRule) {
+      const fieldMap = {
+        courseCode: this.prepareTcPCard.courseCode,
+        courseName: this.prepareTcPCard.courseName,
+        schoolName: this.prepareTcPCard.schoolName
+      };
+      Object.entries(fieldMap).forEach(([key, val]) => {
+        titleRule = titleRule.replace("${" + key + "}", val);
+      });
+      return titleRule;
+    },
+    async getCardTempDetail() {
+      const detData = await cardDetail(this.cardId);
+      // this.canSave = !detData.operateStatus;
+
+      // 可能存在题卡内容没有记录的情况
+      if (detData.content) {
+        const cont = JSON.parse(detData.content);
+        this.setPages(cont.pages);
+        this.setCardConfig(cont.cardConfig);
+        this.setCurPageNo(0);
+      } else {
+        await this.getCardConfig();
+        // 没有题卡内容时,直接创建新的内容
+        if (detData.makeMethod === "CUST") {
+          this.setCardConfig({ cardTitle: detData.title });
+        }
+
+        this.initPageData();
+      }
+    },
+    initPageData() {
+      const page = getPageModel(this.cardConfig);
+      this.addPage(page);
+      this.setCurPageNo(0);
+    },
+    toAddPage() {
+      const page = getPageModel(this.cardConfig);
+      this.addPage(page);
+    },
+    async getCardConfig() {
+      const data = await cardConfigInfos(this.prepareTcPCard.cardRuleId);
+      if (!data) {
+        this.$message.error("找不到题卡规则!");
+        return;
+      }
+      let config = {
+        ...data,
+        ...{
+          pageSize: "A3",
+          columnNumber: 2,
+          columnGap: 20,
+          showForbidArea: true,
+          cardDesc: "",
+          makeMethod: this.prepareTcPCard.makeMethod
+        }
+      };
+      config.aOrB = true; // 默认开启A/B卷型
+      config.requiredFields = JSON.parse(config.requiredFields);
+      config.extendFields = JSON.parse(config.extendFields);
+      config.cardTitle = this.getCardTitle(config.titleRule);
+      this.setCardConfig(config);
+    },
+    addNewTopic(item) {
+      let element = getElementModel(item.type);
+
+      this.setCurElement(element);
+      this.$refs.ElementPropEdit.open();
+      // to elementPropEdit/ElementPropEdit open topic edit dialog
+    },
+    // 元件编辑
+    dragstart(element) {
+      this.setCurDragElement(getElementModel(element.type));
+    },
+    // 操作
+    skSave() {
+      this.toSave();
+    },
+    skSubmit() {
+      this.toSubmit();
+    },
+    skPreview() {
+      this.toPreview();
+    },
+    async toPreview() {
+      if (this.isSubmit) return;
+      this.isSubmit = true;
+      const result = await this.save().catch(() => {});
+      this.isSubmit = false;
+      if (!result) return;
+      const { href } = this.$router.resolve({
+        name: "CardFreePreview",
+        params: {
+          cardId: this.cardId,
+          viewType: "view"
+        }
+      });
+      window.open(href);
+    },
+    swithPage(pindex) {
+      if (this.curPageNo === pindex) return;
+      this.setCurPageNo(pindex);
+      this.setCurElement({});
+    },
+    toDeletePage(page) {
+      if (this.pages.length === 1) {
+        this.$message.error("只剩最后一页,不能再删除了");
+        return;
+      }
+      this.removePage(page);
+    },
+    // save
+    getCardData(htmlContent = "", model = "") {
+      let data = {
+        title: this.cardConfig.cardTitle,
+        content: model,
+        htmlContent,
+        type: "CUSTOM",
+        ...this.prepareTcPCard
+      };
+      if (this.cardId) data.id = this.cardId;
+      return data;
+    },
+    getRequestConfig() {
+      return this.prepareTcPCard.makeMethod === "CUST"
+        ? {
+            headers: {
+              schoolId: this.prepareTcPCard.schoolId
+            }
+          }
+        : {};
+    },
+    getCardJson() {
+      // 防止页面未渲染完成,各试题高度未及时更新,保存数据有误的问题
+      return new Promise(resolve => {
+        setTimeout(() => {
+          const data = JSON.stringify(
+            {
+              version: CARD_VERSION,
+              cardConfig: this.cardConfig,
+              paperParams: this.paperParams,
+              pages: this.pages
+            },
+            (k, v) => (k.startsWith("_") ? undefined : v)
+          );
+          resolve(data);
+        }, 100);
+      });
+    },
+    async save() {
+      const cardJson = await this.getCardJson();
+      let datas = this.getCardData("", cardJson);
+      datas.status = "STAGE";
+      const result = await saveCard(datas, this.getRequestConfig());
+      this.cardId = result;
+      this.$ls.set("cardId", this.cardId);
+      return true;
+    },
+    async toSave() {
+      if (this.isSubmit) return;
+      this.isSubmit = true;
+      const result = await this.save().catch(() => {});
+      this.isSubmit = false;
+      if (result) this.$message.success("保存成功!");
+    },
+    toSubmit() {
+      if (this.isSubmit) return;
+
+      if (this.pages.length % 2) {
+        this.$message.error("请确保题卡页数是偶数");
+        return;
+      }
+
+      this.$confirm("确定要提交当前题卡吗?", "提示", {
+        type: "warning"
+      })
+        .then(() => {
+          window.cardData = {
+            cardConfig: this.cardConfig,
+            pages: this.pages,
+            paperParams: this.paperParams
+          };
+          this.isSubmit = true;
+          const { href } = this.$router.resolve({
+            name: "CardPreview",
+            params: {
+              cardId: 1,
+              viewType: "frame"
+            }
+          });
+          this.cardPreviewUrl = href;
+        })
+        .catch(() => {});
+    },
+    registWindowSubmit() {
+      window.submitCardTemp = async (htmlContent, model) => {
+        const datas = this.getCardData(htmlContent, model);
+        datas.status = "SUBMIT";
+        const result = await saveCard(
+          datas,
+          this.getRequestConfig()
+        ).catch(() => {});
+        this.cardPreviewUrl = "";
+        this.isSubmit = false;
+        window.cardData = null;
+        if (result) {
+          this.cardId = result;
+          this.$ls.set("cardId", this.cardId);
+          this.canSave = false;
+          this.$message.success("提交成功!");
+          this.goback();
+        } else {
+          this.$message.error("提交失败,请重新尝试!");
+        }
+      };
+    },
+    toExit() {
+      this.$confirm(
+        "请确保当前题卡已经正常保存,确定要退出当前题卡编辑吗?",
+        "提示",
+        {
+          type: "warning"
+        }
+      )
+        .then(() => {
+          this.goback();
+        })
+        .catch(() => {});
+    },
+    showHelp() {
+      this.$refs.HelpDialog.open();
+    }
+  },
+  beforeDestroy() {
+    this.$ls.remove("cardId");
+    this.$ls.remove("prepareTcPCard");
+    this.initState();
+    delete window.submitCardTemp;
+  }
+};
+</script>

+ 225 - 0
card/modules/free/views/CardFreePreview.vue

@@ -0,0 +1,225 @@
+<template>
+  <div :class="classes">
+    <div class="preview-frame" id="preview-frame" v-if="IS_COMMON_CARD"></div>
+    <div class="preview-body" v-else>
+      <template v-for="(page, pageNo) in pages">
+        <div
+          :class="[
+            'page-box',
+            `page-box-${page.pageSize}`,
+            `page-box-${pageNo % 2}`
+          ]"
+          :key="pageNo"
+        >
+          <div
+            :class="['page-locators', `page-locators-${page.locators.length}`]"
+          >
+            <ul
+              class="page-locator-group"
+              v-for="(locator, iind) in page.locators"
+              :key="iind"
+            >
+              <li
+                v-for="(elem, eindex) in locator"
+                :key="eindex"
+                :id="elem.id"
+              ></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
+                class="page-column"
+                v-for="(column, columnNo) in page.columns"
+                :key="columnNo"
+                :style="{ padding: `0 ${page.columnGap / 2}px` }"
+              >
+                <div
+                  class="page-column-main"
+                  :id="[`column-${pageNo}-${columnNo}`]"
+                >
+                  <div class="page-column-body" v-if="column.elements.length">
+                    <topic-element-preview
+                      class="page-column-element"
+                      v-for="element in column.elements"
+                      :key="element.id"
+                      :data="element"
+                    ></topic-element-preview>
+                  </div>
+                  <div class="page-column-body" v-else>
+                    <div
+                      class="page-column-forbid-area"
+                      v-if="cardConfig.showForbidArea"
+                    >
+                      <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>
+  </div>
+</template>
+
+<script>
+import TopicElementPreview from "../components/TopicElementPreview";
+import PageNumber from "../../../components/PageNumber";
+import { cardDetail } from "../../../api";
+import previewTemp from "../../../previewTemp";
+import { getPageModel } from "../cardFormatTransform";
+import { deepCopy } from "../../../plugins/utils";
+const JsBarcode = require("jsbarcode");
+
+export default {
+  name: "card-free-preview",
+  components: { TopicElementPreview, PageNumber },
+  data() {
+    return {
+      isPrint: this.$route.params.viewType !== "view",
+      isFrame: this.$route.params.viewType === "frame",
+      cardId: this.$route.params.cardId,
+      cardConfig: {},
+      pages: [],
+      IS_COMMON_CARD: false
+    };
+  },
+  computed: {
+    classes() {
+      return [
+        "card-free-preview",
+        {
+          "card-print": this.isPrint
+        }
+      ];
+    }
+  },
+  mounted() {
+    if (this.isFrame) {
+      this.initFrame();
+    } else {
+      this.init();
+    }
+  },
+  methods: {
+    initFrame() {
+      const cardData = window.parent.cardData;
+      if (!cardData) return;
+
+      const { cardConfig, pages } = deepCopy(cardData);
+      let fieldInfos = {};
+      [...cardConfig.requiredFields, ...cardConfig.extendFields]
+        .filter(item => item.enable)
+        .map(item => {
+          fieldInfos[item.code] = "${" + item.code + "}";
+        });
+      this.cardConfig = cardConfig;
+      this.pages = this.appendFieldInfo(pages, fieldInfos);
+
+      this.$nextTick(() => {
+        const cardContentTemp = previewTemp(this.$el.outerHTML);
+        const model = getPageModel(cardData);
+        window.parent &&
+          window.parent.submitCardTemp &&
+          window.parent.submitCardTemp(cardContentTemp, model);
+      });
+    },
+    async init() {
+      const detData = await cardDetail(this.cardId);
+
+      this.IS_COMMON_CARD = detData.type === "GENERIC";
+      // 通卡展示
+      if (this.IS_COMMON_CARD) {
+        this.$nextTick(() => {
+          this.initHtmlTemp(detData.htmlContent);
+        });
+        return;
+      }
+      // 常规卡展示
+      if (!detData.content) {
+        this.$message.error("很抱歉,当前题卡还没开始制作!");
+        return;
+      }
+      const { cardConfig, pages } = JSON.parse(detData.content);
+      const fieldInfos = this.fetchFieldInfos(cardConfig, {});
+
+      this.cardConfig = cardConfig;
+      this.pages = this.appendFieldInfo(pages, fieldInfos);
+    },
+    initHtmlTemp(htmlTemp) {
+      const iframeDom = document.createElement("iframe");
+      document.getElementById("preview-frame").appendChild(iframeDom);
+      const wwidth = window.innerWidth - 10;
+      const wheight = window.innerHeight - 10;
+      iframeDom.style.cssText = `width: ${wwidth}px;height: ${wheight}px;border:none;outline:none;`;
+      const iframeDoc = iframeDom.contentDocument;
+      iframeDoc.open();
+      iframeDoc.write(htmlTemp);
+      iframeDoc.close();
+    },
+    fetchFieldInfos(cardConfig, stdInfo) {
+      let fieldInfos = {};
+      const defContent = "相关信息";
+      [...cardConfig.requiredFields, ...cardConfig.extendFields]
+        .filter(item => item.enable)
+        .map(item => {
+          fieldInfos[item.code] = stdInfo[item.code] || defContent;
+        });
+
+      return fieldInfos;
+    },
+    getBase64Barcode(str) {
+      const canvas = document.createElement("CANVAS");
+      JsBarcode(canvas, str, {
+        width: 2,
+        height: 30,
+        displayValue: false,
+        marginLeft: 20,
+        marginRight: 20,
+        marginTop: 0,
+        marginBottom: 0
+      });
+
+      return canvas.toDataURL();
+    },
+    appendFieldInfo(pages, fieldInfos) {
+      const VALID_ELEMENTS_FOR_EXTERNAL = ["BARCODE", "FILL_FIELD"];
+      pages.forEach(page => {
+        page.columns.forEach(column => {
+          column.elements.forEach(element => {
+            if (!VALID_ELEMENTS_FOR_EXTERNAL.includes(element.type)) return;
+
+            if (element.type === "BARCODE") {
+              const field = element.fields[0] && element.fields[0].code;
+              element.content = `data:image/png;base64,${fieldInfos[field]}`;
+              return;
+            }
+
+            element.fieldInfos = fieldInfos;
+          });
+        });
+      });
+      return pages;
+    }
+  }
+};
+</script>

+ 18 - 4
card/router/index.js

@@ -8,10 +8,7 @@ const routes = [
   {
     path: "/",
     name: "Home",
-    component: Home,
-    redirect: {
-      name: "CardDesign"
-    }
+    component: Home
   },
   {
     path: "/card/design/:cardId?",
@@ -19,6 +16,14 @@ const routes = [
     component: () =>
       import(/* webpackChunkName: "CardDesign" */ "../views/CardDesign.vue")
   },
+  {
+    path: "/card/free-design/:cardId?",
+    name: "CardFreeDesign",
+    component: () =>
+      import(
+        /* webpackChunkName: "CardDesign" */ "../modules/free/views/CardFreeDesign.vue"
+      )
+  },
   {
     // viewType::: view:预览,print:打印,frame:iframe嵌套
     path: "/card/preview/:cardId/:viewType",
@@ -26,6 +31,15 @@ const routes = [
     component: () =>
       import(/* webpackChunkName: "CardPreview" */ "../views/CardPreview.vue")
   },
+  {
+    // viewType::: view:预览,print:打印,frame:iframe嵌套
+    path: "/card/free-preview/:cardId/:viewType",
+    name: "CardFreePreview",
+    component: () =>
+      import(
+        /* webpackChunkName: "CardPreview" */ "../modules/free/views/CardFreePreview.vue"
+      )
+  },
   {
     path: "/card/card-rule/preview/:cardRuleId",
     name: "CardRulePreview",

+ 4 - 3
card/store/card.js

@@ -202,7 +202,7 @@ const createFunc = {
     return getExplainElements(element);
   },
   FILL_QUESTION(element) {
-    return getFillQuesitonElements(element, state.cardConfig.columnNumber);
+    return getFillQuesitonElements(element, state.cardConfig);
   },
   FILL_LINE(element) {
     return getFillLineElements(element);
@@ -552,6 +552,7 @@ const actions = {
   },
   rebuildPages({ state, commit }) {
     const columnNumber = state.cardConfig.columnNumber;
+    const pageSize = state.cardConfig.pageSize;
     // 更新元件最新的高度信息
     // 整理所有元件
     const cardHeadElement = state.topics[0];
@@ -656,7 +657,7 @@ const actions = {
     columns.forEach((column, cindex) => {
       const columnNo = cindex % columnNumber;
       if (!columnNo) {
-        page = getNewPage(pages.length, columnNumber);
+        page = getNewPage(pages.length, { pageSize, columnNumber });
       }
       page.columns[columnNo].elements = column;
 
@@ -666,7 +667,7 @@ const actions = {
     });
     // 保证页面总是偶数页
     if (pages.length % 2) {
-      pages.push(getNewPage(pages.length, columnNumber));
+      pages.push(getNewPage(pages.length, { pageSize, columnNumber }));
     }
 
     commit("setPages", pages);

+ 5 - 1
card/store/index.js

@@ -1,6 +1,7 @@
 import Vue from "vue";
 import Vuex from "vuex";
 import card from "./card";
+import free from "../modules/free/store";
 
 Vue.use(Vuex);
 
@@ -9,6 +10,9 @@ export default new Vuex.Store({
   mutations: {},
   actions: {},
   modules: {
-    card
+    card,
+    free
   }
 });
+
+export { card, free };

+ 6 - 2
card/views/CardDesign.vue

@@ -110,7 +110,11 @@
       <!-- edit body -->
       <div class="design-body">
         <div
-          :class="['page-box', `page-box-${curPageNo % 2}`]"
+          :class="[
+            'page-box',
+            `page-box-${cardConfig.pageSize}`,
+            `page-box-${curPageNo % 2}`
+          ]"
           v-if="curPage.locators"
         >
           <div
@@ -187,7 +191,7 @@
 
     <!-- all topics -->
     <div class="topic-list">
-      <div class="page-box">
+      <div :class="['page-box', `page-box-${cardConfig.pageSize}`]">
         <div class="page-main-inner">
           <div
             :class="['page-main', `page-main-${cardConfig.columnNumber}`]"

+ 8 - 1
card/views/CardPreview.vue

@@ -3,7 +3,14 @@
     <div class="preview-frame" id="preview-frame" v-if="IS_COMMON_CARD"></div>
     <div class="preview-body" v-else>
       <template v-for="(page, pageNo) in pages">
-        <div :class="['page-box', `page-box-${pageNo % 2}`]" :key="pageNo">
+        <div
+          :class="[
+            'page-box',
+            `page-box-${cardConfig.pageSize}`,
+            `page-box-${pageNo % 2}`
+          ]"
+          :key="pageNo"
+        >
           <div
             :class="['page-locators', `page-locators-${page.locators.length}`]"
           >

+ 8 - 1
card/views/CardRulePreview.vue

@@ -2,7 +2,14 @@
   <div :class="classes">
     <div class="preview-body">
       <template v-for="(page, pageNo) in pages">
-        <div :class="['page-box', `page-box-${pageNo % 2}`]" :key="pageNo">
+        <div
+          :class="[
+            'page-box',
+            `page-box-${cardConfig.pageSize}`,
+            `page-box-${pageNo % 2}`
+          ]"
+          :key="pageNo"
+        >
           <div
             :class="['page-locators', `page-locators-${page.locators.length}`]"
           >

+ 117 - 2
card/views/Home.vue

@@ -1,9 +1,124 @@
 <template>
-  <div class="home"></div>
+  <div class="home">
+    <div class="part-box part-box-pad part-box-flex">
+      <div></div>
+      <div>
+        <el-button type="primary" icon="el-icon-refresh" @click="toAdd"
+          >新增标准题卡</el-button
+        >
+        <el-button type="success" icon="el-icon-refresh" @click="toFreeAdd"
+          >新增自由题卡</el-button
+        >
+      </div>
+    </div>
+    <div class="part-box part-box-pad">
+      <el-table ref="TableList" :data="cards">
+        <el-table-column type="index" label="序号" width="70"></el-table-column>
+        <el-table-column prop="name" label="名称"></el-table-column>
+        <el-table-column class-name="action-column" label="操作" width="200px">
+          <template slot-scope="scope">
+            <el-button
+              class="btn-primary"
+              type="text"
+              @click="toEdit(scope.row)"
+              >编辑</el-button
+            >
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="part-page">
+        <el-pagination
+          background
+          layout="total,prev, pager, next"
+          :current-page="current"
+          :total="total"
+          :page-size="size"
+          @current-change="toPage"
+        >
+        </el-pagination>
+      </div>
+    </div>
+  </div>
 </template>
 
 <script>
 export default {
-  name: "Home"
+  name: "home",
+  data() {
+    return {
+      current: 1,
+      size: 10,
+      total: 10,
+      cards: []
+    };
+  },
+  mounted() {
+    this.getList();
+  },
+  methods: {
+    getList() {
+      this.cards = [
+        {
+          id: "1111",
+          name: "题卡001"
+        },
+        {
+          id: "2222",
+          name: "题卡002"
+        }
+      ];
+      this.total = this.cards.length;
+    },
+    toPage(page) {
+      this.current = page;
+      this.getList();
+    },
+    toEdit(row) {
+      this.$ls.set("prepareTcPCard", {
+        examTaskId: "1111",
+        courseCode: "数学",
+        courseName: "sx001",
+        makeMethod: "SELF",
+        cardRuleId: "1"
+      });
+      this.$router.push({
+        name: "CardFreeDesign",
+        params: {
+          cardId: row.id
+        }
+      });
+    },
+    toAdd() {
+      this.$ls.set("prepareTcPCard", {
+        examTaskId: "1111",
+        courseCode: "数学",
+        courseName: "sx001",
+        makeMethod: "SELF",
+        cardRuleId: "1"
+      });
+      this.$router.push({
+        name: "CardDesign"
+      });
+    },
+    toFreeAdd() {
+      this.$ls.set("prepareTcPCard", {
+        examTaskId: "1111",
+        courseCode: "数学",
+        courseName: "sx001",
+        makeMethod: "SELF",
+        cardRuleId: "1"
+      });
+      this.$router.push({
+        name: "CardFreeDesign"
+      });
+    }
+  }
 };
 </script>
+
+<style scoped>
+.home {
+  background: #eff0f5;
+  padding: 20px;
+}
+</style>

+ 7 - 0
src/modules/base/components/ModifyCardInfo.vue

@@ -232,10 +232,17 @@ export default {
       const data = await updateTemplate(datas).catch(() => {});
       this.isSubmit = false;
       if (!data) return;
+      console.log(datas);
 
       this.$message.success("保存成功!");
       this.$emit("modified");
       this.cancel();
+
+      if (!this.IS_UPLOAD && !this.isEdit) {
+        this.$router.push({
+          name: this.IS_STANDARD ? "CardDesign" : "CardFreeDesign"
+        });
+      }
     },
     uplaodError(errorData) {
       this.$notify.error({ title: "错误提示", message: errorData.message });

+ 13 - 2
src/modules/base/views/CardManage.vue

@@ -185,11 +185,22 @@ export default {
     },
     toPreview(row) {
       this.curCard = row;
-      this.$refs.ModifyRole.open();
+      this.$router.push({
+        name: row.createType === "STANDARD" ? "CardPreview" : "CardFreePreview",
+        params: {
+          cardId: row.id,
+          viewType: "view"
+        }
+      });
     },
     toEditCard(row) {
       this.curCard = row;
-      this.$refs.ModifyRole.open();
+      this.$router.push({
+        name: row.createType === "STANDARD" ? "CardDesign" : "CardFreeDesign",
+        params: {
+          cardId: row.id
+        }
+      });
     },
     toEditInfo(row) {
       this.curCard = row;

+ 17 - 0
src/modules/card/router.js

@@ -23,5 +23,22 @@ export default [
       import(
         /* webpackChunkName: "CardRulePreview" */ "../../../card/views/CardRulePreview.vue"
       )
+  },
+  {
+    path: "/card/free-design/:cardId?",
+    name: "CardFreeDesign",
+    component: () =>
+      import(
+        /* webpackChunkName: "CardDesign" */ "../../../card/modules/free/views/CardFreeDesign.vue"
+      )
+  },
+  {
+    // viewType::: view:预览,print:打印,frame:iframe嵌套
+    path: "/card/free-preview/:cardId/:viewType",
+    name: "CardFreePreview",
+    component: () =>
+      import(
+        /* webpackChunkName: "CardPreview" */ "../../../card/modules/free/views/CardFreePreview.vue"
+      )
   }
 ];

+ 2 - 2
src/modules/card/store.js

@@ -1,3 +1,3 @@
-import card from "../../../card/store/card";
+import { card, free } from "../../../card/store";
 
-export default card;
+export { card, free };

+ 2 - 1
src/store.js

@@ -8,7 +8,7 @@ const privilegeMapData = window.sessionStorage.getItem("privilegeMap");
 const privilegeMap = privilegeMapData ? JSON.parse(privilegeMapData).value : {};
 
 // modules
-import card from "./modules/card/store";
+import { card, free } from "./modules/card/store";
 import exam from "./modules/exam/store";
 
 export default new Vuex.Store({
@@ -27,6 +27,7 @@ export default new Vuex.Store({
   actions: {},
   modules: {
     card,
+    free,
     exam
   }
 });