zhangjie 5 жил өмнө
parent
commit
2f938b5562
76 өөрчлөгдсөн 7598 нэмэгдсэн , 10 устгасан
  1. 1 0
      package.json
  2. BIN
      src/assets/images/barcode-sample.png
  3. BIN
      src/assets/images/icon-back.png
  4. BIN
      src/assets/images/icon-four-gray.png
  5. BIN
      src/assets/images/icon-four-white.png
  6. BIN
      src/assets/images/icon-help.png
  7. BIN
      src/assets/images/icon-three-gray.png
  8. BIN
      src/assets/images/icon-three-white.png
  9. BIN
      src/assets/images/icon-two-gray.png
  10. BIN
      src/assets/images/icon-two-white.png
  11. 426 0
      src/assets/styles/card-design.scss
  12. 1028 0
      src/assets/styles/card-preview.scss
  13. 9 1
      src/assets/styles/element-ui-costom.scss
  14. 42 0
      src/assets/styles/icons.scss
  15. 3 0
      src/assets/styles/index.scss
  16. 54 0
      src/modules/card/api.js
  17. 181 0
      src/modules/card/card.temp.json
  18. 138 0
      src/modules/card/components/CardConfigPropEdit.vue
  19. 164 0
      src/modules/card/components/PagePropEdit.vue
  20. 133 0
      src/modules/card/components/RightClickMenu.vue
  21. 339 0
      src/modules/card/components/SavePage.vue
  22. 106 0
      src/modules/card/components/TopicElementEdit.vue
  23. 60 0
      src/modules/card/components/TopicElementPreview.vue
  24. 74 0
      src/modules/card/components/common/ColorSelect.vue
  25. 63 0
      src/modules/card/components/common/DirectionSelect.vue
  26. 508 0
      src/modules/card/components/common/ElementResize.vue
  27. 55 0
      src/modules/card/components/common/FontFamilySelect.vue
  28. 74 0
      src/modules/card/components/common/LineStyleSelect.vue
  29. 71 0
      src/modules/card/components/common/LineWidthSelect.vue
  30. 49 0
      src/modules/card/components/common/PopoverButton.vue
  31. 54 0
      src/modules/card/components/common/RotationSelect.vue
  32. 76 0
      src/modules/card/components/common/SizeSelect.vue
  33. 156 0
      src/modules/card/components/elementEdit/CardHead.vue
  34. 98 0
      src/modules/card/components/elementEdit/CardHeadBodyAutoResize.vue
  35. 48 0
      src/modules/card/components/elementEdit/CardHeadSample.vue
  36. 89 0
      src/modules/card/components/elementEdit/Composition.vue
  37. 96 0
      src/modules/card/components/elementEdit/CompositionElement.vue
  38. 99 0
      src/modules/card/components/elementEdit/ExplainChildren.vue
  39. 101 0
      src/modules/card/components/elementEdit/ExplainChildrenElement.vue
  40. 98 0
      src/modules/card/components/elementEdit/cardHeadSpin/HeadDynamic.vue
  41. 40 0
      src/modules/card/components/elementEdit/cardHeadSpin/HeadNotice.vue
  42. 31 0
      src/modules/card/components/elementEdit/cardHeadSpin/HeadStdinfo.vue
  43. 60 0
      src/modules/card/components/elementEdit/cardHeadSpin/HeadStdno.vue
  44. 38 0
      src/modules/card/components/elementPreview/Composition.vue
  45. 42 0
      src/modules/card/components/elementPreview/CompositionElement.vue
  46. 37 0
      src/modules/card/components/elementPreview/ElemImage.vue
  47. 30 0
      src/modules/card/components/elementPreview/ElemLineHorizontal.vue
  48. 30 0
      src/modules/card/components/elementPreview/ElemLineVertical.vue
  49. 37 0
      src/modules/card/components/elementPreview/ElemText.vue
  50. 51 0
      src/modules/card/components/elementPreview/ExplainChildren.vue
  51. 46 0
      src/modules/card/components/elementPreview/ExplainChildrenElement.vue
  52. 39 0
      src/modules/card/components/elementPreview/FillArea.vue
  53. 41 0
      src/modules/card/components/elementPreview/FillLine.vue
  54. 107 0
      src/modules/card/components/elementPreview/FillQuestion.vue
  55. 22 0
      src/modules/card/components/elementPreview/TopicHead.vue
  56. 106 0
      src/modules/card/components/elementPropEdit/EditComposition.vue
  57. 136 0
      src/modules/card/components/elementPropEdit/EditExplain.vue
  58. 163 0
      src/modules/card/components/elementPropEdit/EditFillLine.vue
  59. 162 0
      src/modules/card/components/elementPropEdit/EditFillQuestion.vue
  60. 120 0
      src/modules/card/components/elementPropEdit/EditImage.vue
  61. 89 0
      src/modules/card/components/elementPropEdit/EditLine.vue
  62. 166 0
      src/modules/card/components/elementPropEdit/EditText.vue
  63. 114 0
      src/modules/card/components/elementPropEdit/ElementPropEdit.vue
  64. 48 0
      src/modules/card/directives/move-ele.js
  65. 415 0
      src/modules/card/elementModel.js
  66. 1 0
      src/modules/card/enumerate.js
  67. 14 0
      src/modules/card/router.js
  68. 346 0
      src/modules/card/store/index.js
  69. 385 0
      src/modules/card/views/CardDesign.vue
  70. 161 0
      src/modules/card/views/CardPreview.vue
  71. 6 1
      src/modules/exam-center/views/CardManage.vue
  72. 1 1
      src/plugins/mixins.js
  73. 11 4
      src/plugins/utils.js
  74. 3 1
      src/router.js
  75. 2 2
      src/store.js
  76. 5 0
      yarn.lock

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
     "cropperjs": "^1.5.1",
     "deepmerge": "^3.2.0",
     "element-ui": "^2.13.1",
+    "jsbarcode": "^3.11.0",
     "vue": "^2.6.10",
     "vue-ls": "^3.2.1",
     "vue-router": "^3.0.3",

BIN
src/assets/images/barcode-sample.png


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


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


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


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


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


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


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


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


+ 426 - 0
src/assets/styles/card-design.scss

@@ -0,0 +1,426 @@
+// card-design
+.card-design {
+  color: #000;
+  .design-top {
+    position: fixed;
+    width: 100%;
+    padding: 10px 0;
+    height: 60px;
+    top: 0;
+    left: 0;
+    z-index: 99;
+    color: #fff;
+    line-height: 40px;
+    background: $--color-blue;
+    &-logo {
+      width: 240px;
+      float: left;
+      color: #fff;
+      height: 60px;
+      margin-top: -10px;
+      padding: 10px 20px;
+      font-size: 20px;
+      text-align: left;
+      transition: width 0.2s ease;
+
+      .icon {
+        margin-right: 12px;
+        margin-top: -2px;
+        cursor: pointer;
+      }
+    }
+    &-info {
+      padding-right: 20px;
+      float: right;
+      height: 40px;
+      line-height: 40px;
+      position: relative;
+      color: $--color-text-regular;
+      cursor: pointer;
+
+      .info-help {
+        display: inline-block;
+        vertical-align: top;
+        color: #fff;
+        font-size: 20px;
+        .icon {
+          margin-right: 12px;
+          margin-top: -2px;
+        }
+      }
+    }
+  }
+
+  .design-main {
+    padding-top: 60px;
+  }
+  // page-box
+  .page-box {
+    box-shadow: $--shadow-light;
+    &::before {
+      content: "";
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      top: 0;
+      left: 0;
+      z-index: 7;
+      background-color: rgba($color: #ffffff, $alpha: 0.7);
+    }
+  }
+
+  .page-column-main {
+    overflow: visible;
+    &::before {
+      content: "";
+      display: block;
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      z-index: 8;
+      border: 1px dashed #d0d0d0;
+    }
+  }
+  .page-column-element {
+    > .element-resize {
+      width: 100% !important;
+    }
+  }
+
+  // page-main-outer
+  .page-main-outer {
+    z-index: 1;
+    background-color: rgba($color: #ffffff, $alpha: 0.7);
+
+    &-act {
+      z-index: 19;
+    }
+  }
+
+  // design-other-pages
+  .design-other-pages {
+    position: absolute;
+    width: 2000px;
+    left: -9999px;
+    top: 0;
+    z-index: 1000;
+    visibility: hidden;
+  }
+  // .topic-design
+  .topic-design {
+    + .resize-control {
+      display: none;
+    }
+
+    &:hover,
+    &-act {
+      + .resize-control {
+        display: block;
+      }
+    }
+  }
+  // card-head-sample
+  .card-head-sample {
+    position: absolute;
+    width: 2000px;
+    left: -9999px;
+    top: 0;
+    z-index: 1001;
+    visibility: hidden;
+  }
+}
+.design-head {
+  position: fixed;
+  top: 60px;
+  left: 270px;
+  right: 0;
+  height: 150px;
+  z-index: 99;
+  padding: 20px 40px;
+  background: $--color-background;
+
+  &::after {
+    content: "";
+    display: block;
+    position: absolute;
+    width: 100%;
+    height: 36px;
+    left: 0;
+    bottom: 0;
+    z-index: 2;
+    background: linear-gradient(
+      180deg,
+      rgba(245, 245, 245, 0) 0%,
+      rgba(238, 238, 238, 1) 100%
+    );
+  }
+  &::before {
+    content: "";
+    display: block;
+    position: absolute;
+    width: 100%;
+    height: 1px;
+    left: 0;
+    bottom: 0;
+    z-index: 3;
+    background: rgba(229, 229, 229, 1);
+  }
+  .design-control {
+    overflow: hidden;
+    position: relative;
+    z-index: 9;
+  }
+
+  .control-right {
+    float: right;
+    width: 300px;
+    line-height: 40px;
+    text-align: right;
+
+    .el-button {
+      width: 100px;
+      border-radius: 10px;
+    }
+  }
+  .control-left {
+    margin-right: 310px;
+    white-space: nowrap;
+    overflow-y: hidden;
+    overflow-x: auto;
+
+    .el-button {
+      width: 80px;
+      color: #bbb;
+      font-weight: bold;
+      background: rgba(245, 245, 245, 1);
+      border-radius: 10px;
+    }
+  }
+}
+.design-steps {
+  padding: 10px 0 30px;
+  text-align: center;
+  white-space: nowrap;
+
+  .step-item {
+    display: inline-block;
+    vertical-align: top;
+    > i {
+      display: inline-block;
+      vertical-align: middle;
+      height: 28px;
+      width: 28px;
+      border-radius: 50%;
+      border: 2px solid $--color-primary;
+      color: $--color-primary;
+      line-height: 24px;
+      font-weight: bold;
+    }
+    > span {
+      display: inline-block;
+      vertical-align: middle;
+      margin-left: 14px;
+      font-weight: bold;
+      color: #999;
+    }
+    &:not(:last-child) {
+      &::after {
+        content: "";
+        display: inline-block;
+        vertical-align: middle;
+        width: 200px;
+        margin: 0 20px;
+        border-bottom: 1px dashed #999;
+      }
+    }
+  }
+}
+
+.design-action {
+  position: fixed;
+  padding: 28px;
+  top: 60px;
+  left: 0;
+  bottom: 0;
+  width: 270px;
+  z-index: 99;
+  overflow-x: hidden;
+  overflow-y: auto;
+  background: linear-gradient(
+    90deg,
+    rgba(247, 244, 248, 1) 0%,
+    rgba(238, 238, 238, 1) 100%
+  );
+
+  &::before {
+    content: "";
+    display: block;
+    position: absolute;
+    height: 100%;
+    width: 1px;
+    right: 0;
+    bottom: 0;
+    z-index: 9;
+    background: rgba(229, 229, 229, 1);
+  }
+
+  .action-part {
+    margin-bottom: 5px;
+    color: #999;
+
+    &-title {
+      padding-bottom: 10px;
+      border-bottom: 1px solid #e5e5e5;
+    }
+    &-body {
+      padding: 20px 0;
+    }
+  }
+
+  // design-action
+  .type-list {
+    font-size: 0;
+  }
+  .type-item {
+    font-size: 14px;
+    display: inline-block;
+    vertical-align: top;
+    width: 50%;
+    text-align: center;
+    margin-bottom: 10px;
+    border-radius: 10px;
+    cursor: pointer;
+
+    &:nth-of-type(even) {
+      padding-left: 5px;
+    }
+    &:nth-of-type(odd) {
+      padding-right: 5px;
+    }
+    .el-button {
+      height: 40px;
+      width: 100px;
+      font-weight: bold;
+      border-radius: 10px;
+      background: rgba(245, 245, 245, 1);
+      color: #999;
+
+      &:hover {
+        color: #fff;
+        background: rgba(28, 208, 161, 1);
+        box-shadow: 5px 5px 4px 0px rgba(28, 208, 161, 0.3);
+      }
+    }
+
+    i {
+      margin-right: 2px;
+      font-size: 12px;
+    }
+  }
+}
+.design-body {
+  position: relative;
+  min-height: 1332px;
+  background: #fff;
+  padding: 180px 30px 30px 300px;
+}
+
+// tool-tips
+.tool-tips {
+  color: #999;
+}
+// page-prop-edit
+.page-prop-edit {
+  .el-form-item {
+    margin-bottom: 10px;
+  }
+  .el-form-item__label,
+  .el-checkbox {
+    color: #999;
+    font-weight: bold;
+  }
+  .column-btn {
+    width: 32px;
+    height: 32px;
+    padding: 2px 0;
+    border-radius: 10px;
+    .icon {
+      margin-top: -2px;
+    }
+
+    &:hover {
+      border-color: $--color-primary;
+    }
+  }
+}
+// element-prop-edit
+.edit-dialog {
+  .el-input-split {
+    position: relative;
+    display: inline-block;
+    margin: 3px 10px;
+    width: 10px;
+    border-bottom: 2px solid #dddddd;
+  }
+  .el-input-number.is-without-controls {
+    .el-input__inner {
+      padding-left: 5px;
+      padding-right: 5px;
+    }
+  }
+}
+//
+// explain-children-element
+.explain-children-element,
+.composition-element {
+  .resize-control {
+    display: none;
+  }
+
+  &:hover,
+  &-act {
+    .resize-control {
+      display: block;
+    }
+  }
+}
+.composition-element {
+  > .element-resize {
+    width: 100% !important;
+  }
+}
+
+// right-menu-body
+.right-menu-body {
+  width: 62px;
+  border: 1px solid #cccccc;
+  border-radius: 10px;
+  overflow: hidden;
+  background-color: #fff;
+  font-size: 14px;
+  color: #666666;
+  text-align: center;
+  box-shadow: 5px 5px 4px 0px rgba(0, 0, 0, 0.1);
+  li {
+    padding: 13px 5px;
+    font-weight: 400;
+    cursor: pointer;
+
+    &:hover {
+      background-color: #f6f6f6;
+    }
+
+    &:not(:last-child) {
+      border-bottom: 1px solid #dddddd;
+    }
+  }
+}
+.right-click-popper {
+  box-shadow: none;
+  border: none;
+  background-color: transparent;
+  padding: 0;
+  min-width: 0;
+}

+ 1028 - 0
src/assets/styles/card-preview.scss

@@ -0,0 +1,1028 @@
+// card-preview
+.card-preview {
+  padding-top: 70px;
+  background-color: #f0f0f0;
+  .page-box {
+    margin: 10px auto;
+    box-shadow: 0 0 4px #ddd;
+  }
+  .element-item {
+    width: 100% !important;
+  }
+}
+.card-print {
+  padding: 0;
+
+  .page-box {
+    // height: 296.5mm;
+    margin: 0 auto;
+    box-shadow: none;
+    page-break-after: always;
+  }
+}
+
+// page-box
+.page-box {
+  position: relative;
+  // width: 420mm;
+  // height: 297mm;
+  width: 1587px;
+  height: 1122px;
+  background: #fff;
+  margin: 0 auto;
+  font-weight: normal;
+
+  .page-main {
+    height: 100%;
+    position: relative;
+    white-space: nowrap;
+    margin: 0 -10px;
+
+    &-3 {
+      .page-column {
+        &:first-child {
+          width: 430px;
+        }
+        &:not(:first-child) {
+          width: calc((100% - 430px) / 2);
+        }
+      }
+    }
+    &-4 {
+      .page-column {
+        &:first-child {
+          width: 430px;
+        }
+        &:not(:first-child) {
+          width: calc((100% - 430px) / 3);
+        }
+      }
+    }
+  }
+
+  &-1 {
+    .page-main {
+      &-3 {
+        .page-column {
+          width: 33.33% !important;
+        }
+      }
+      &-4 {
+        .page-column {
+          width: 25% !important;
+        }
+      }
+    }
+  }
+}
+// 分栏间距,默认20px
+// page-main-inner
+.page-main-inner {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  padding: 50px 30px;
+  z-index: 9;
+  font-size: 0;
+}
+// page-main-outer
+.page-main-outer {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 8;
+  background-color: transparent;
+  overflow: hidden;
+}
+
+.page-column {
+  display: inline-block;
+  vertical-align: middle;
+  position: relative;
+  height: 100%;
+  width: 50%;
+  font-size: 14px;
+  padding: 0 10px;
+
+  &-forbid-area {
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+    z-index: 1;
+    border: 1px solid #333;
+    overflow: hidden;
+    background: linear-gradient(
+        to top right,
+        rgba(172, 172, 172, 0) 0%,
+        rgba(172, 172, 172, 0) calc(50% - 1px),
+        rgba(172, 172, 172, 1) 50%,
+        rgba(172, 172, 172, 0) calc(50% + 1px),
+        rgba(172, 172, 172, 0) 100%
+      ),
+      linear-gradient(
+        to bottom right,
+        rgba(172, 172, 172, 0) 0%,
+        rgba(172, 172, 172, 0) calc(50% - 1px),
+        rgba(172, 172, 172, 1) 50%,
+        rgba(172, 172, 172, 0) calc(50% + 1px),
+        rgba(172, 172, 172, 0) 100%
+      );
+
+    > p {
+      color: #333;
+      padding: 20px;
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      font-weight: bold;
+      font-size: 30px;
+      color: #999;
+      background-color: #fff;
+    }
+  }
+  &-main {
+    position: relative;
+    height: 100%;
+    overflow: hidden;
+    .page-column-element:nth-of-type(1) .element-item-topic-head {
+      margin-top: 0;
+    }
+  }
+  &-body {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    z-index: 9;
+  }
+  &-element {
+    .element-item {
+      position: relative;
+      border: 1px solid #333;
+      border-top: 0;
+      &-card-head {
+        border: 0;
+      }
+
+      &-topic-head {
+        margin-top: 10px;
+        border-top: 1px solid #333;
+      }
+    }
+  }
+}
+// locator
+.page-locators {
+  position: absolute;
+  top: 50px;
+  left: 30px;
+  right: 30px;
+  bottom: 50px;
+  z-index: 8;
+
+  &-4 {
+    .page-locator-group {
+      &:nth-of-type(2) {
+        left: 33.3%;
+        margin-left: -4.5mm;
+      }
+      &:nth-of-type(3) {
+        left: 66.6%;
+        margin-left: -2.3mm;
+      }
+    }
+  }
+  &-5 {
+    .page-locator-group {
+      &:nth-of-type(2) {
+        left: 25%;
+        margin-left: -5.1mm;
+      }
+      &:nth-of-type(3) {
+        left: 50%;
+        margin-left: -3.4mm;
+      }
+      &:nth-of-type(4) {
+        left: 75%;
+        margin-left: -1.8mm;
+      }
+    }
+  }
+}
+.page-locator-group {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 30px;
+  &:first-child {
+    left: 0;
+  }
+  &:nth-of-type(2) {
+    left: 50%;
+    margin-left: -15px;
+  }
+  &:last-child {
+    right: 0;
+  }
+  li {
+    position: absolute;
+    width: 30px;
+    border-bottom: 15px solid #000;
+    z-index: 99;
+    &:first-child {
+      top: -20px;
+    }
+    &:last-child {
+      bottom: -20px;
+    }
+  }
+}
+
+// elem
+.elem {
+  &-title {
+    padding: 10px 13px 20px;
+    font-size: 14px;
+    font-weight: bold;
+    color: rgba(0, 0, 0, 1);
+    line-height: 1;
+  }
+  &-body {
+    padding: 10px;
+  }
+}
+// card-head
+.card-head {
+  &-top {
+    text-align: center;
+  }
+  &-title {
+    font-size: 36px;
+    font-family: "楷体";
+    font-weight: bold;
+    line-height: 48px;
+  }
+  &-subtitle {
+    textarea,
+    > p {
+      height: 50px;
+      font-size: 14px;
+      font-family: $--font-family;
+      font-weight: bold;
+      line-height: 20px;
+      text-align: center;
+      border-color: transparent;
+      background-color: transparent;
+      color: #000;
+    }
+  }
+  &-body {
+    font-weight: normal;
+    .el-col {
+      padding-top: 5px;
+      padding-bottom: 5px;
+    }
+    &-spin {
+      height: 100%;
+      padding: 5px 12px;
+      white-space: normal;
+      word-break: break-all;
+      border: 1px solid #000;
+
+      &-dash {
+        border-style: dotted;
+      }
+    }
+    .stdinfo-item {
+      height: 34px;
+      line-height: 34px;
+      position: relative;
+      overflow: hidden;
+
+      &::after {
+        content: "";
+        display: block;
+        position: absolute;
+        width: 100%;
+        border-bottom: 1px solid #333;
+        bottom: 6px;
+        left: 0;
+        z-index: 1;
+      }
+
+      > span {
+        z-index: 2;
+        display: block;
+        position: relative;
+        font-weight: bold;
+        font-size: 14px;
+
+        &:first-child {
+          float: left;
+          background-color: #fff;
+          width: 60px;
+          text-align: justify;
+
+          &::after {
+            content: "";
+            display: inline-block;
+            width: 100%;
+            height: 0;
+            line-height: 0;
+          }
+        }
+        &:nth-of-type(2) {
+          float: left;
+          width: 20px;
+          background-color: #fff;
+        }
+        &:last-child {
+          margin-left: 80px;
+          height: 100%;
+          overflow: hidden;
+        }
+      }
+    }
+    .head-stdno {
+      position: relative;
+      padding: 0;
+      .stdno-empty {
+        position: absolute;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 100%;
+        left: 0;
+        z-index: auto;
+        font-weight: bold;
+        text-align: center;
+        letter-spacing: 3px;
+      }
+      .stdno-fill {
+        min-height: 286px;
+        height: 100%;
+        &-head {
+          position: absolute;
+          width: 100%;
+          height: 51px;
+          top: 0;
+          left: 0;
+          z-index: 9;
+
+          > h5 {
+            border-bottom: 1px solid #333;
+            line-height: 24px;
+            font-size: 16px;
+            font-weight: bold;
+            text-align: center;
+          }
+        }
+
+        &-body {
+          display: flex;
+          height: 100%;
+          padding-top: 51px;
+        }
+        &-list {
+          width: 7.692%;
+          display: flex;
+          padding: 3px 0;
+          flex-direction: column;
+          justify-content: space-around;
+        }
+        &-rect {
+          display: flex;
+          height: 27px;
+          border-bottom: 1px solid #333;
+        }
+        &-number {
+          width: 7.692%;
+          &:not(:last-child) {
+            border-right: 1px solid #333;
+          }
+        }
+        &-option {
+          margin: auto 6px;
+          height: 14px;
+          font-size: 12px;
+          line-height: 1;
+          text-align: center;
+          color: #666;
+          border: 1px solid #333;
+        }
+      }
+      .stdno-auto {
+        position: absolute;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 100%;
+        left: 0;
+        z-index: auto;
+        text-align: center;
+
+        &-barcode {
+          height: 80px;
+          padding: 10px 0;
+
+          > img {
+            height: 100%;
+            width: auto;
+            display: inline-block;
+            vertical-align: top;
+          }
+        }
+      }
+    }
+
+    .head-notice {
+      > h4 {
+        font-weight: bold;
+        margin-bottom: 8px;
+      }
+      &-cont {
+        line-height: 1.5;
+        font-size: 12px;
+        margin-bottom: 5px;
+
+        > span {
+          display: block;
+
+          &:first-child {
+            width: 20px;
+            float: left;
+          }
+          &:last-child {
+            margin-left: 20px;
+          }
+        }
+      }
+
+      &-exam-number-fill {
+        span {
+          display: inline;
+
+          &:first-child {
+            float: none;
+          }
+          &:last-child {
+            margin: 0;
+          }
+        }
+      }
+    }
+
+    .head-dynamic {
+      padding: 0;
+      font-size: 12px;
+
+      &-part:not(:last-child) {
+        border-bottom: 1px solid #000;
+      }
+      &-write {
+        padding: 5px 12px;
+        .stdinfo-item {
+          margin-bottom: 0;
+        }
+        > p {
+          line-height: 18px;
+        }
+      }
+      &-missfill {
+        display: flex;
+      }
+      &-miss {
+        width: 140px;
+        padding: 10px;
+        border-right: 1px solid #000;
+        display: flex;
+        align-items: center;
+        .head-dynamic-content {
+          height: 32px;
+          width: 100%;
+        }
+
+        span {
+          display: block;
+        }
+        .dynamic-miss-title {
+          width: 32px;
+          float: left;
+        }
+        .dynamic-miss-body {
+          margin-left: 32px;
+          height: 100%;
+          padding-top: 8px;
+          .head-dynamic-rect {
+            margin: 0 auto;
+          }
+        }
+      }
+      &-fill {
+        width: 228px;
+        padding: 5px 10px;
+        display: flex;
+        align-items: center;
+
+        p {
+          line-height: 22px;
+          word-wrap: normal;
+
+          > span,
+          > i {
+            display: inline-block;
+            vertical-align: middle;
+            box-sizing: border-box;
+          }
+          &:first-child {
+            i {
+              width: 24px;
+              height: 14px;
+              background-color: #000;
+            }
+          }
+          &:last-child {
+            > i {
+              width: 28px;
+              height: 14px;
+              border: 1px solid #000;
+              font-size: 14px;
+              font-weight: bold;
+              margin-right: 6px;
+              line-height: 12px;
+              text-align: center;
+
+              &:last-child {
+                margin-right: 0;
+              }
+
+              &:nth-of-type(3) {
+                &::before {
+                  content: "";
+                  display: inline-block;
+                  vertical-align: top;
+                  margin-left: -5px;
+                  height: 100%;
+                  width: 5px;
+                  background-color: #000;
+                }
+              }
+              &:nth-of-type(4) {
+                &::before {
+                  content: "";
+                  display: inline-block;
+                  margin-top: 1px;
+                  width: 10px;
+                  height: 10px;
+                  border-radius: 50%;
+                  background-color: #000;
+                }
+              }
+            }
+          }
+        }
+      }
+      &-rect {
+        display: block;
+        width: 40px;
+        height: 16px;
+        border: 1px solid #000;
+        box-sizing: border-box;
+        font-size: 14px;
+        text-align: center;
+        line-height: 18px;
+        color: #666;
+      }
+      &-aorb {
+        height: 50px;
+        &-auto {
+          .dynamic-aorb-title {
+            border-right: 1px solid #000;
+          }
+        }
+
+        .dynamic-aorb-title {
+          width: 100px;
+          height: 100%;
+          padding: 10px;
+          float: left;
+        }
+        .dynamic-aorb-body {
+          margin-left: 100px;
+          height: 100%;
+          overflow: hidden;
+
+          > span {
+            display: block;
+            float: left;
+            height: 100%;
+          }
+          span.dynamic-aorb-rect {
+            width: 50%;
+            position: relative;
+            .head-dynamic-rect {
+              position: absolute;
+              top: 50%;
+              left: 50%;
+              transform: translate(-50%, -50%);
+            }
+          }
+
+          span.dynamic-aorb-type {
+            width: 40px;
+            border-right: 1px solid #000;
+            font-size: 16px;
+          }
+          span.dynamic-aorb-barcode {
+            float: none;
+            margin-left: 40px;
+            > img {
+              padding: 5px 0;
+              position: relative;
+              margin: 0 auto;
+              top: 50%;
+              left: 50%;
+              transform: translate(-50%, -50%);
+              height: 32px;
+            }
+          }
+        }
+      }
+      .center-cont {
+        display: block;
+        text-align: center;
+        position: relative;
+        top: 50%;
+        transform: translateY(-50%);
+      }
+    }
+  }
+  &-normal {
+    .head-dynamic {
+      &-1 {
+        .head-dynamic-part {
+          height: 100%;
+        }
+      }
+      &-2 {
+        .head-dynamic-part {
+          height: 50%;
+        }
+        .head-dynamic-write {
+          height: 97px;
+
+          + .head-dynamic-part {
+            height: calc(100% - 97px);
+          }
+        }
+      }
+    }
+  }
+
+  &-narrow {
+    .head-stdno {
+      height: 138px;
+    }
+  }
+
+  &-handle {
+    &.card-head-narrow {
+      .head-stdno {
+        height: 286px;
+      }
+    }
+  }
+}
+// card-head-body-auto-resize
+.card-head-body-auto-resize {
+  margin-left: -5px;
+  margin-right: -5px;
+  display: flex;
+
+  &.col-item-auto-height {
+    .card-head-body-spin {
+      height: auto;
+    }
+  }
+
+  .head-dynamic-2 {
+    .head-dynamic-part {
+      height: auto;
+    }
+    .head-dynamic-aorb {
+      height: 60px;
+    }
+  }
+
+  &::before {
+    display: table;
+    content: "";
+  }
+  .rect-col {
+    float: left;
+    height: 100%;
+    padding: 5px;
+    &:first-child {
+      width: 324px;
+    }
+    &:last-child {
+      width: 440px;
+    }
+
+    &-item {
+      &:nth-of-type(2) {
+        margin-top: 10px;
+      }
+    }
+  }
+}
+// elem-topic-head
+.elem-topic-head {
+  text-align: center;
+  > h3 {
+    font-size: 16px;
+    line-height: 28px;
+    border-bottom: 1px dotted #333;
+  }
+  > p {
+    font-size: 12px;
+    line-height: 29px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+// elem-line
+.elem-line-horizontal {
+  height: 100%;
+  line-height: 10px;
+  .line-body {
+    display: inline-block;
+    vertical-align: middle;
+    width: 100%;
+    border-bottom: 1px solid #000;
+  }
+}
+.elem-line-vertical {
+  height: 100%;
+  text-align: center;
+  .line-body {
+    display: inline-block;
+    vertical-align: top;
+    height: 100%;
+    border-left: 1px solid #000;
+  }
+}
+// elem-rect
+.elem-rect {
+  .rect-body {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+  }
+}
+// elem-text
+.elem-text {
+  .text-body {
+    padding: 5px;
+    line-height: 1.4;
+
+    span {
+      white-space: pre-wrap;
+      word-wrap: normal;
+      word-break: break-all;
+      &.cont-variate {
+        color: #a0a0a0;
+        margin: 0 2px;
+      }
+    }
+  }
+}
+// elem-barcode
+.elem-barcode {
+  height: 100%;
+  border-color: transparent;
+  border-width: 1pt;
+  position: relative;
+  > img {
+    max-height: 100%;
+    max-width: 100%;
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    margin: auto;
+  }
+}
+// elem-image
+.elem-image {
+  height: 100%;
+  border-color: transparent;
+  border-width: 1pt;
+  position: relative;
+  > p {
+    position: absolute;
+    color: #b0b0b0;
+    font-size: 30pt;
+    text-align: center;
+    left: 0;
+    width: 100%;
+    top: 50%;
+    transform: translateY(-50%);
+  }
+  > img {
+    max-height: 100%;
+    max-width: 100%;
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    margin: auto;
+  }
+}
+// elem-fill-question
+.elem-fill-question {
+  white-space: normal;
+
+  .group-item {
+    display: inline-block;
+    vertical-align: top;
+    font-size: 0;
+    padding: 0 20px 0 0;
+  }
+  .question-item {
+    font-size: 0;
+
+    &:last-child {
+      margin-bottom: 0 !important;
+    }
+  }
+  .option-item {
+    display: inline-block;
+    vertical-align: middle;
+    padding: 0;
+    width: 32px;
+    height: 14px;
+    text-align: center;
+    font-size: 12px;
+    line-height: 1;
+    border: 1px solid #000;
+    color: #666;
+    box-sizing: border-box;
+
+    &:first-child {
+      padding-right: 5px;
+      padding-left: 5px;
+      text-align: right;
+      border-color: transparent;
+      font-size: 12px;
+      color: #000;
+    }
+    &:last-child {
+      margin-right: 0 !important;
+    }
+  }
+
+  &-vertical {
+    .question-item {
+      display: inline-block;
+      vertical-align: top;
+      &:last-child {
+        margin-right: 0 !important;
+      }
+    }
+    .option-item {
+      display: block;
+
+      &:first-child {
+        padding: 0;
+        text-align: center;
+      }
+      &:last-child {
+        margin-bottom: 0 !important;
+      }
+    }
+  }
+}
+// elem-fill-area
+.elem-fill-area {
+  .option-item {
+    display: inline-block;
+    vertical-align: middle;
+    width: 30px;
+    height: 16px;
+    border: 1px solid #000;
+
+    &:last-child {
+      margin-right: 0 !important;
+    }
+  }
+
+  &-vertical {
+    .option-item {
+      display: block;
+      &:last-child {
+        margin-bottom: 0 !important;
+      }
+    }
+  }
+}
+// elem-fill-line
+.elem-fill-line {
+  white-space: normal;
+
+  .elem-title {
+    padding-bottom: 0;
+  }
+  .elem-body {
+    padding-bottom: 30px;
+  }
+  .elem-fill-quesiton {
+    display: inline-block;
+    vertical-align: top;
+    position: relative;
+    padding: 0 5px;
+    font-size: 12px;
+
+    li {
+      height: 50px;
+      border-bottom: 1px solid #000;
+      position: relative;
+      z-index: 8;
+    }
+
+    > li:first-child {
+      position: absolute;
+      height: 100%;
+      background-color: #fff;
+      top: 0;
+      left: 5px;
+      z-index: 9;
+      padding-top: 30px;
+      border: none;
+    }
+  }
+}
+
+// elem-explain-children
+.elem-explain-children {
+  .elem-title {
+    padding-bottom: 0;
+  }
+  .elem-body {
+    min-height: 60px;
+    position: relative;
+  }
+  .elem-explain-no {
+    position: absolute;
+    left: 30px;
+    top: 10px;
+    font-size: 12px;
+    z-index: 9;
+  }
+  .elem-explain-elements {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    z-index: 8;
+  }
+  .explain-children-element {
+    .explain-element {
+      position: absolute;
+    }
+  }
+}
+// .elem-composition
+.elem-composition {
+  .elem-body {
+    padding: 0;
+  }
+  &-lines {
+    padding: 10px;
+    li {
+      height: 50px;
+      border-bottom: 1px solid #000;
+    }
+  }
+  &-elements {
+    padding: 5px 0;
+  }
+
+  .composition-element {
+    &-item {
+      position: relative;
+    }
+  }
+}

+ 9 - 1
src/assets/styles/element-ui-costom.scss

@@ -105,7 +105,6 @@
 // button
 .el-button {
   border-radius: $--border-radius;
-  min-width: 88px;
   > .icon {
     margin-right: 5px;
   }
@@ -113,6 +112,11 @@
     display: inline-block;
     vertical-align: middle;
   }
+  &.is-disabled {
+    color: $--color-text-secondary !important;
+    background: $--color-background !important;
+    border: 1px solid $--color-border !important;
+  }
 }
 .el-button--primary {
   border-color: $--color-primary;
@@ -177,6 +181,7 @@
 
   &.btn-table-icon {
     padding: 0;
+    min-width: 10px;
 
     &:hover {
       transform: scale(1.2);
@@ -218,6 +223,9 @@
       width: 18px;
       height: 18px;
       border-color: $--color-border;
+      &::after {
+        display: none;
+      }
     }
     &.is-checked {
       .el-checkbox__inner {

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

@@ -156,4 +156,46 @@
     width: 18px;
     height: 14px;
   }
+
+  // card
+  &-help {
+    background-image: url(../images/icon-help.png);
+    width: 32px;
+    height: 32px;
+  }
+  &-back {
+    background-image: url(../images/icon-back.png);
+    width: 32px;
+    height: 32px;
+  }
+  &-two-gray {
+    background-image: url(../images/icon-two-gray.png);
+    width: 12px;
+    height: 10px;
+  }
+  &-two-white {
+    background-image: url(../images/icon-two-white.png);
+    width: 12px;
+    height: 10px;
+  }
+  &-three-gray {
+    background-image: url(../images/icon-three-gray.png);
+    width: 16px;
+    height: 10px;
+  }
+  &-three-white {
+    background-image: url(../images/icon-three-white.png);
+    width: 16px;
+    height: 10px;
+  }
+  &-four-gray {
+    background-image: url(../images/icon-four-gray.png);
+    width: 18px;
+    height: 10px;
+  }
+  &-four-white {
+    background-image: url(../images/icon-four-white.png);
+    width: 18px;
+    height: 10px;
+  }
 }

+ 3 - 0
src/assets/styles/index.scss

@@ -6,6 +6,9 @@
 @import "./login.scss";
 @import "./account.scss";
 @import "./pages.scss";
+// card
+@import "./card-preview.scss";
+@import "./card-design.scss";
 
 @import "./element-ui-costom.scss";
 // @import "./common-comp.scss";

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

@@ -0,0 +1,54 @@
+import { $get, $post } from "@/plugins/axios";
+
+export const cardInfosById = id => {
+  return $post("/backend/login", id);
+};
+export const cardConfigInfos = datas => {
+  // return $get("/backend/sysuser/resetPwd", datas);
+  return Promise.resolve({
+    missAndFill: true,
+    writeSign: true,
+    examNumberType: "auto", // auto:自动条码, empty:手动条码, fill:手动涂填
+    aOrBSystem: true, // 后台附带的aOrB设置,如果有则使用这个值,如果没有则前台自动设置
+    aOrBType: "auto", // fill:手动涂填,auto:自动条码
+    schoolName: "河南财经政法大学",
+    businessParams: [
+      {
+        name: "学号",
+        field: "studentNo"
+      },
+      {
+        name: "姓名",
+        field: "username"
+      },
+      {
+        name: "科目名称",
+        field: "courseName"
+      },
+      {
+        name: "课程名称",
+        field: "subjectName"
+      }
+    ],
+    noticeHead: [
+      "答题前,请考生认真核对姓名、学号、教学班号、课程名称等信息,确认无误后签名。",
+      "客观题部分必须使用2B铅笔填涂,主观题部分请使用黑色签字笔写在指定的答题区内。",
+      "保持卡面清洁,不破损。"
+    ],
+    objectiveNotice:
+      "注意:必须使用2B铅笔填涂;在答题区内作答,超出涂填边框限定区域的答案无效。",
+    subjectiveNotice:
+      "注意:必须使用黑色字迹签字笔书写;在答题区内作答,超出以下黑色矩形边框限定区域的答案无效。"
+  });
+};
+
+export const saveCard = datas => {
+  return $post("/backend/sysuser/resetPwd", datas);
+};
+
+export const cardDetail = cardId => {
+  return $get("/backend/sysuser/resetPwd", cardId);
+};
+export const cardStudentInfo = ({ cardId, studentNo }) => {
+  return $get("/backend/sysuser/resetPwd", { cardId, studentNo });
+};

+ 181 - 0
src/modules/card/card.temp.json

@@ -0,0 +1,181 @@
+{
+  "pages": [
+    {
+      "type": "PAGE",
+      "columnGap": 20,
+      "locators": [
+        [
+          {
+            "type": "LOCATOR",
+            "x": "",
+            "y": "",
+            "w": "",
+            "h": "",
+            "id": "locator-0-00"
+          },
+          {
+            "type": "LOCATOR",
+            "x": "",
+            "y": "",
+            "w": "",
+            "h": "",
+            "id": "locator-0-01"
+          }
+        ]
+      ],
+      "globals": [],
+      "columns": [
+        {
+          "type": "COLUMN",
+          "x": "",
+          "y": "",
+          "w": "",
+          "h": "",
+          "isFull": false,
+          "elements": [
+            {
+              "type": "CARD_HEAD",
+              "x": 0,
+              "y": 0,
+              "w": 0,
+              "h": 0,
+              "schoolName": "河南财经政法大学",
+              "cardName": "",
+              "aOrB": true,
+              "aOrBType": "auto",
+              "missAndFill": true,
+              "writeSign": true,
+              "examNumberType": "auto",
+              "businessParams": [
+                {
+                  "name": "学号",
+                  "field": "studentNo"
+                },
+                {
+                  "name": "姓名",
+                  "field": "username"
+                },
+                {
+                  "name": "科目名称",
+                  "field": "courseName"
+                },
+                {
+                  "name": "课程名称",
+                  "field": "subjectName"
+                }
+              ],
+              "noticeHead": [
+                "答题前,请考生认真核对姓名、学号、教学班号、课程名称等信息,确认无误后签名。",
+                "客观题部分必须使用2B铅笔填涂,主观题部分请使用黑色签字笔写在指定的答题区内。",
+                "保持卡面清洁,不破损。"
+              ],
+              "columnNumber": 2,
+              "isSimple": false,
+              "sign": "head",
+              "pageSize": "A3",
+              "columnGap": 20,
+              "showForbidArea": true,
+              "aOrBSystem": true,
+              "objectiveNotice": "注意:必须使用2B铅笔填涂;在答题区内作答,超出涂填边框限定区域的答案无效。",
+              "subjectiveNotice": "注意:必须使用黑色字迹签字笔书写;在答题区内作答,超出以下黑色矩形边框限定区域的答案无效。",
+              "id": "element-iimjmonofft311c8"
+            }
+          ]
+        },
+        {
+          "type": "COLUMN",
+          "x": "",
+          "y": "",
+          "w": "",
+          "h": "",
+          "isFull": false,
+          "elements": []
+        }
+      ]
+    },
+    {
+      "type": "PAGE",
+      "columnGap": 20,
+      "locators": [
+        [
+          {
+            "type": "LOCATOR",
+            "x": "",
+            "y": "",
+            "w": "",
+            "h": "",
+            "id": "locator-1-00"
+          },
+          {
+            "type": "LOCATOR",
+            "x": "",
+            "y": "",
+            "w": "",
+            "h": "",
+            "id": "locator-1-01"
+          }
+        ]
+      ],
+      "globals": [],
+      "columns": [
+        {
+          "type": "COLUMN",
+          "x": "",
+          "y": "",
+          "w": "",
+          "h": "",
+          "isFull": false,
+          "elements": []
+        },
+        {
+          "type": "COLUMN",
+          "x": "",
+          "y": "",
+          "w": "",
+          "h": "",
+          "isFull": false,
+          "elements": []
+        }
+      ]
+    }
+  ],
+  "cardConfig": {
+    "pageSize": "A3",
+    "columnNumber": 2,
+    "columnGap": 20,
+    "aOrB": true,
+    "showForbidArea": true,
+    "missAndFill": true,
+    "writeSign": true,
+    "examNumberType": "auto",
+    "aOrBSystem": true,
+    "aOrBType": "auto",
+    "schoolName": "河南财经政法大学",
+    "businessParams": [
+      {
+        "name": "学号",
+        "field": "studentNo"
+      },
+      {
+        "name": "姓名",
+        "field": "username"
+      },
+      {
+        "name": "科目名称",
+        "field": "courseName"
+      },
+      {
+        "name": "课程名称",
+        "field": "subjectName"
+      }
+    ],
+    "noticeHead": [
+      "答题前,请考生认真核对姓名、学号、教学班号、课程名称等信息,确认无误后签名。",
+      "客观题部分必须使用2B铅笔填涂,主观题部分请使用黑色签字笔写在指定的答题区内。",
+      "保持卡面清洁,不破损。"
+    ],
+    "objectiveNotice": "注意:必须使用2B铅笔填涂;在答题区内作答,超出涂填边框限定区域的答案无效。",
+    "subjectiveNotice": "注意:必须使用黑色字迹签字笔书写;在答题区内作答,超出以下黑色矩形边框限定区域的答案无效。",
+    "cardName": ""
+  }
+}

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

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

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

@@ -0,0 +1,164 @@
+<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="请选择"
+          @change="modifyPageSize"
+          :disabled="pageSizeOptions.length < 2"
+        >
+          <el-option
+            v-for="item in pageSizeOptions"
+            :key="item"
+            :label="item"
+            :value="item"
+          >
+          </el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="栏位布局">
+        <el-button
+          v-for="(item, index) in columnOptions"
+          :key="index"
+          :class="[
+            'column-btn',
+            { 'btn-act': form.columnNumber == item.value }
+          ]"
+          :title="item.title"
+          :disabled="item.disabled"
+          @click="modifyColumnNum(item)"
+        >
+          <i
+            :class="[
+              'icon',
+              form.columnNumber == item.value
+                ? `icon-${item.label}-white`
+                : `icon-${item.label}-gray`
+            ]"
+          ></i>
+        </el-button>
+      </el-form-item>
+      <el-form-item label-width="0px">
+        <el-checkbox
+          v-model="form.aOrB"
+          @change="configChange"
+          :disabled="aOrBDisabled"
+          >启用A/B卷</el-checkbox
+        >
+      </el-form-item>
+      <el-form-item label-width="0px">
+        <el-checkbox v-model="form.showForbidArea" @change="configChange"
+          >启用禁答区</el-checkbox
+        >
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+
+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"],
+      form: {
+        pageSize: "A3",
+        columnNumber: 2,
+        columnGap: 4,
+        aOrB: false,
+        showForbidArea: false
+      },
+      prePageSize: "A3"
+    };
+  },
+  computed: {
+    ...mapState("card", ["curPageNo", "pages", "cardConfig"]),
+    curPage() {
+      return this.pages[this.curPageNo];
+    },
+    aOrBDisabled() {
+      return this.cardConfig.hasOwnProperty("aOrBSystem");
+    }
+  },
+  watch: {
+    cardConfig: {
+      immediate: true,
+      handler(val) {
+        this.form = this.$objAssign(this.form, val);
+        this.prePageSize = this.form.pageSize;
+        if (val.hasOwnProperty("aOrBSystem")) {
+          this.form.aOrB = val.aOrBSystem;
+        }
+      }
+    }
+  },
+  mounted() {},
+  methods: {
+    ...mapMutations("card", ["setCurElement", "setCardConfig"]),
+    ...mapActions("card", ["rebuildPages"]),
+    modifyColumnNum(item) {
+      this.$confirm(
+        "此操作可能会导致当前题卡所有元素位置变动, 是否继续?",
+        "提示",
+        {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          type: "warning"
+        }
+      )
+        .then(() => {
+          this.form.columnNumber = item.value;
+          this.configChange();
+        })
+        .catch(() => {});
+    },
+    configChange() {
+      this.setCardConfig(this.form);
+      this.$nextTick(() => {
+        this.rebuildPages();
+        this.setCurElement({});
+      });
+    },
+    modifyPageSize(pageSize) {
+      this.$confirm("此操作将会重置当前页面所有元素信息, 是否继续?", "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      })
+        .then(() => {
+          // TODO:A4
+          this.configChange();
+        })
+        .catch(() => {
+          this.form.pageSize = this.prePageSize;
+        });
+    }
+  }
+};
+</script>

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

@@ -0,0 +1,133 @@
+<template>
+  <div class="right-click-menu">
+    <el-popover
+      placement="right-start"
+      trigger="click"
+      v-model="visible"
+      popper-class="right-click-popper"
+      :visible-arrow="false"
+    >
+      <div class="right-menu-body">
+        <ul>
+          <li @click="toEdit">编辑</li>
+          <li @click="toDelete">删除</li>
+        </ul>
+      </div>
+      <el-button :style="styles" slot="reference"></el-button>
+    </el-popover>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+
+export default {
+  name: "right-click-menu",
+  data() {
+    return {
+      visible: false,
+      styles: {
+        position: "fixed",
+        visibility: "hidden",
+        zIndex: 3000
+      }
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement"])
+  },
+  mounted() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("card", ["setOpenElementEditDialog"]),
+    ...mapActions("card", [
+      "actElementById",
+      "removeElement",
+      "removeElementChild",
+      "rebuildPages"
+    ]),
+    init() {
+      // 注册自定义右键事件菜单
+      document.oncontextmenu = function() {
+        return false;
+      };
+      document.addEventListener("mouseup", e => {
+        if (e.button === 2) {
+          this.visible = false;
+          this.$nextTick(() => {
+            this.rightClick(e);
+          });
+        }
+      });
+    },
+    rightClick(e) {
+      const id = this.getRelateElementId(e.target);
+      if (!id) return;
+
+      this.actElementById(id);
+      this.styles = Object.assign({}, this.styles, {
+        top: e.y + "px",
+        left: e.x - 50 + "px"
+      });
+      // TODO:需要研究一下popover弹出的机制
+      // console.log(`x:${this.styles.left},y:${this.styles.top}`);
+
+      this.$nextTick(() => {
+        this.visible = true;
+      });
+    },
+    getRelateElementId(dom) {
+      let parentNode = dom;
+      while (
+        !(
+          (parentNode["id"] && parentNode["id"].includes("element-")) ||
+          parentNode.className.includes("page-column-body")
+        )
+      ) {
+        parentNode = parentNode.parentNode;
+      }
+      const elementType = parentNode.getAttribute("data-type");
+      const unValidElement = ["TOPIC_HEAD", "CARD_HEAD"];
+
+      return parentNode["id"] &&
+        elementType &&
+        !unValidElement.includes(elementType)
+        ? parentNode["id"]
+        : null;
+    },
+    toEdit() {
+      this.visible = false;
+      this.setOpenElementEditDialog(true);
+    },
+    toDelete() {
+      this.visible = false;
+      this.$confirm("确定要删除当前元素吗?", "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      })
+        .then(() => {
+          this.removeSelectElement();
+        })
+        .catch(() => {});
+    },
+    removeSelectElement() {
+      if (this.curElement["container"]) {
+        this.removeElementChild(this.curElement);
+      } else {
+        this.removeElement(this.curElement);
+      }
+      // TODO: 解答题小题的删除要直接删除大题
+      // 作文题是个坑爹的设计,删除子元件之后会影响主体题卡布局,解答题小题不会
+      if (
+        !this.curElement["container"] ||
+        this.curElement["container"].type === "COMPOSITION"
+      )
+        this.$nextTick(() => {
+          this.rebuildPages();
+        });
+    }
+  }
+};
+</script>

+ 339 - 0
src/modules/card/components/SavePage.vue

@@ -0,0 +1,339 @@
+<template>
+  <el-button type="primary" @click="save" :disabled="!pages.length"
+    >保存</el-button
+  >
+</template>
+
+<script>
+import { mapState } from "vuex";
+import { APP_VERSION } from "../enumerate";
+import { deepCopy } from "@/plugins/utils";
+import { saveCard } from "../api";
+
+export default {
+  name: "save-page",
+  props: {
+    cardId: {
+      type: String
+    }
+  },
+  data() {
+    return {
+      fillAreaIndex: 0,
+      VALID_ELEMENTS_FOR_EXTERNAL: [
+        "LOCATOR",
+        "BARCODE",
+        "CARD_HEAD",
+        "FILL_QUESTION",
+        "FILL_LINE",
+        "EXPLAIN_CHILDREN",
+        "COMPOSITION"
+      ]
+    };
+  },
+  computed: {
+    ...mapState("card", ["pages", "cardConfig"])
+  },
+  methods: {
+    getFillAreaIndex() {
+      return this.fillAreaIndex++;
+    },
+    getElementHumpName(cont) {
+      return cont
+        .split("_")
+        .map(item => item[0] + item.substr(1).toLowerCase())
+        .join("");
+    },
+    parsePageExchange() {
+      const pages = deepCopy(this.pages);
+      pages.forEach(page => {
+        let exchange = {
+          locator: this.getLocatorInfo(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 (this.VALID_ELEMENTS_FOR_EXTERNAL.includes(element.type)) {
+              const funcName = this.getElementHumpName(element.type);
+              const info = this[`get${funcName}Info`](element);
+              Object.entries(info).forEach(([key, vals]) => {
+                exchange[key] = exchange[key].concat(vals);
+              });
+            }
+          });
+        });
+
+        page.exchange = exchange;
+      });
+
+      return pages;
+    },
+    getLocatorInfo(locators) {
+      return locators.map(locatorGroup => {
+        const locatorInfos = locatorGroup.map(locator => {
+          return this.getOffsetInfo(document.getElementById(locator.id));
+        });
+        return {
+          top: locatorInfos[0],
+          bottom: locatorInfos[1]
+        };
+      });
+    },
+    getBarcodeInfo(element) {
+      const dom = document.getElementById(element.id);
+
+      return {
+        barcode: [
+          {
+            field:
+              element.content && element.content.length
+                ? element.content[0].content
+                : "",
+            area: this.getOffsetInfo(dom)
+          }
+        ]
+      };
+    },
+    getCardHeadInfo(element) {
+      const dom = document.getElementById(element.id);
+      const headArea = this.getOffsetInfo(dom);
+      let fill_area = [];
+      let barcode = [];
+      // 学生考号
+      if (element.examNumberType === "fill") {
+        // fill_area
+        let listInfos = [];
+        dom
+          .querySelectorAll(".stdno-fill-list")
+          .forEach((questionItem, questionIndex) => {
+            let options = [];
+            questionItem.childNodes.forEach((optionItem, optionIndex) => {
+              options[optionIndex] = this.getOffsetInfo(optionItem);
+            });
+            listInfos[questionIndex] = {
+              main_number: null,
+              sub_number: null,
+              options
+            };
+          });
+
+        fill_area = [
+          ...fill_area,
+          {
+            field: "examNumber",
+            index: this.getFillAreaIndex(),
+            single: true,
+            horizontal: false,
+            items: listInfos
+          }
+        ];
+      } else if (element.examNumberType === "auto") {
+        // barcode
+        barcode.push({
+          field: "examNumber",
+          area: this.getOffsetInfo(dom.querySelector(".stdno-auto-barcode"))
+        });
+      }
+      // 缺考涂填
+      if (element.missAndFill && !element.isSimple) {
+        fill_area.push({
+          field: "absent",
+          index: this.getFillAreaIndex(),
+          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) {
+        if (element.aOrBType === "auto") {
+          // barcode
+          barcode.push({
+            field: "paperType",
+            area: this.getOffsetInfo(
+              document.getElementById("dynamic-aorb-barcode")
+            )
+          });
+        } else {
+          // 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(),
+            single: true,
+            horizontal: true,
+            items: [
+              {
+                main_number: null,
+                sub_number: null,
+                options
+              }
+            ]
+          });
+        }
+      }
+
+      return {
+        info_area: headArea,
+        fill_area,
+        barcode
+      };
+    },
+    getFillQuestionInfo(element) {
+      const dom = document.getElementById(element.id);
+      const single = !element.isMultiply;
+      const horizontal = element.optionDirection === "horizontal";
+
+      let listInfos = [];
+      dom
+        .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: null,
+            sub_number: null,
+            options
+          };
+        });
+
+      return {
+        fill_area: [
+          {
+            field: "question",
+            index: this.getFillAreaIndex(),
+            single,
+            horizontal,
+            items: listInfos
+          }
+        ]
+      };
+    },
+    getFillLineInfo(element) {
+      const dom = document.getElementById(element.id);
+
+      return {
+        answer_area: [
+          {
+            main_number: null,
+            sub_numbers: null,
+            area: this.getOffsetInfo(dom)
+          }
+        ]
+      };
+    },
+    getFillAreaInfo(element) {
+      const dom = document.getElementById(element.id);
+
+      let listInfos = [];
+      let options = [];
+      dom.querySelectorAll(".option-item").forEach((optionItem, index) => {
+        options[index] = this.getOffsetInfo(optionItem);
+      });
+      listInfos.push({
+        main_number: null,
+        sub_number: null,
+        options
+      });
+
+      return {
+        fill_area: [
+          {
+            field: "question",
+            index: this.getFillAreaIndex(),
+            single: true,
+            horizontal: element.optionDirection === "horizontal",
+            items: listInfos
+          }
+        ]
+      };
+    },
+    getExplainChildrenInfo(element) {
+      const dom = document.getElementById(element.id);
+
+      return {
+        answer_area: [
+          {
+            main_number: null,
+            sub_numbers: null,
+            area: this.getOffsetInfo(dom)
+          }
+        ]
+      };
+    },
+    getCompositionInfo(element) {
+      const dom = document.getElementById(element.id);
+
+      return {
+        answer_area: [
+          {
+            main_number: null,
+            sub_numbers: null,
+            area: this.getOffsetInfo(dom)
+          }
+        ]
+      };
+    },
+    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;
+
+      return [
+        offsetLeft / pw,
+        offsetTop / ph,
+        dom.offsetWidth / pw,
+        dom.offsetHeight / ph
+      ];
+    },
+    async save() {
+      return await saveCard({
+        id: this.cardId,
+        template: JSON.stringify(
+          {
+            version: APP_VERSION,
+            cardConfig: this.cardConfig,
+            pages: this.parsePageExchange()
+          },
+          (k, v) => (k.startsWith("_") ? undefined : v)
+        )
+      });
+    },
+    async toSave() {
+      const card = await this.save();
+      this.$emit("saved", card.id);
+    }
+  }
+};
+</script>

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

@@ -0,0 +1,106 @@
+<template>
+  <div class="topic-element">
+    <div
+      :class="classes"
+      :id="data.id"
+      :data-type="data.type"
+      v-if="data.type === 'CARD_HEAD' || data.type === 'TOPIC_HEAD'"
+    >
+      <!-- card-head 和 topic-head不需要调整高度 -->
+      <component :is="compName" :data="data"></component>
+    </div>
+    <element-resize
+      v-model="elemData"
+      :active="['b']"
+      :move="false"
+      fit-parent
+      @on-click="activeCurElement"
+      @resize-over="resizeOver"
+      v-else
+    >
+      <div :class="classes" :id="data.id" :data-type="data.type">
+        <component :is="compName" :data="data"></component>
+      </div>
+    </element-resize>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+
+import EditCardHead from "./elementEdit/CardHead";
+import EditFillQuestion from "./elementPreview/FillQuestion";
+import EditFillLine from "./elementPreview/FillLine";
+import EditExplainChildren from "./elementEdit/ExplainChildren";
+import EditComposition from "./elementEdit/Composition";
+import EditTopicHead from "./elementPreview/TopicHead";
+import ElementResize from "./common/ElementResize.vue";
+
+export default {
+  name: "topic-design",
+  components: {
+    EditCardHead,
+    EditTopicHead,
+    EditFillQuestion,
+    EditFillLine,
+    EditExplainChildren,
+    EditComposition,
+    ElementResize
+  },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {
+      elemData: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0,
+        init: false
+      }
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement"]),
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    compName() {
+      return `edit-${this.elementName}`;
+    },
+    classes() {
+      return [
+        "topic-design",
+        "element-item",
+        `element-item-${this.elementName}`,
+        {
+          "topic-design-act": this.curElement.id === this.data.id
+        }
+      ];
+    }
+  },
+  mounted() {},
+  created() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("card", ["setCurElement"]),
+    ...mapActions("card", ["rebuildPages"]),
+    init() {
+      this.elemData = this.$objAssign(this.elemData, this.data);
+    },
+    activeCurElement() {
+      this.setCurElement(this.data);
+    },
+    resizeOver() {
+      // 注意:当前组件并没有实时更新元件的尺寸信息,只是在rebuildPages时统一更新。
+      this.$nextTick(() => {
+        this.rebuildPages();
+      });
+    }
+  }
+};
+</script>

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

@@ -0,0 +1,60 @@
+<template>
+  <div class="topic-element">
+    <div :class="classes" :id="data.id" :data-type="data.type" :style="styles">
+      <component :is="compName" :data="data" preview></component>
+    </div>
+  </div>
+</template>
+
+<script>
+import PreviewCardHead from "./elementEdit/CardHead";
+import PreviewExplainChildren from "./elementPreview/ExplainChildren";
+import PreviewComposition from "./elementPreview/Composition";
+import PreviewFillQuestion from "./elementPreview/FillQuestion";
+import PreviewFillLine from "./elementPreview/FillLine";
+import PreviewTopicHead from "./elementPreview/TopicHead";
+
+export default {
+  name: "topic-preview",
+  components: {
+    PreviewCardHead,
+    PreviewTopicHead,
+    PreviewFillQuestion,
+    PreviewFillLine,
+    PreviewExplainChildren,
+    PreviewComposition
+  },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    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"
+      };
+    }
+  },
+  methods: {}
+};
+</script>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 156 - 0
src/modules/card/components/elementEdit/CardHead.vue

@@ -0,0 +1,156 @@
+<template>
+  <div :class="classes">
+    <div class="card-head-top">
+      <h1 class="card-head-title">{{ data.schoolName }}</h1>
+      <div class="card-head-subtitle">
+        <el-input
+          type="textarea"
+          :rows="2"
+          resize="none"
+          placeholder="请输入题卡名称"
+          @blur="nameChange"
+          v-model="cardName"
+          v-if="!preview"
+        >
+        </el-input>
+        <p v-else>{{ data.cardName }}</p>
+      </div>
+    </div>
+
+    <template v-if="data.columnNumber === 2">
+      <div class="card-head-body" v-if="data.examNumberType !== 'fill'">
+        <!-- TODO:预览时,剔除row/col -->
+        <el-row :gutter="10" type="flex">
+          <el-col :span="12">
+            <head-stdno :data="data"></head-stdno>
+          </el-col>
+          <el-col :span="12">
+            <head-stdinfo :data="data"></head-stdinfo>
+          </el-col>
+        </el-row>
+        <el-row :gutter="10" type="flex" v-if="!data.isSimple">
+          <el-col :span="12">
+            <head-notice :data="data"></head-notice>
+          </el-col>
+          <el-col :span="12">
+            <head-dynamic :data="data"></head-dynamic>
+          </el-col>
+        </el-row>
+      </div>
+      <div class="card-head-body" v-else>
+        <card-head-body-auto-resize>
+          <head-stdinfo :data="data" slot="stdinfo"></head-stdinfo>
+          <head-notice :data="data" slot="notice"></head-notice>
+          <head-stdno :data="data" slot="stdno"></head-stdno>
+          <head-dynamic
+            :data="data"
+            slot="dynamic"
+            v-if="!data.isSimple && hasDynamicArea"
+          ></head-dynamic>
+        </card-head-body-auto-resize>
+      </div>
+    </template>
+
+    <template v-if="data.columnNumber > 2">
+      <div class="card-head-body" v-if="data.examNumberType !== 'fill'">
+        <el-row :gutter="10">
+          <el-col :span="24">
+            <head-stdno :data="data"></head-stdno>
+          </el-col>
+          <el-col :span="24">
+            <head-stdinfo :data="data"></head-stdinfo>
+          </el-col>
+        </el-row>
+        <el-row :gutter="10" v-if="!data.isSimple">
+          <el-col :span="24" v-if="hasDynamicArea">
+            <head-dynamic :data="data"></head-dynamic>
+          </el-col>
+          <el-col :span="24">
+            <head-notice :data="data"></head-notice>
+          </el-col>
+        </el-row>
+      </div>
+      <div class="card-head-body" v-else>
+        <el-row :gutter="10">
+          <el-col :span="24">
+            <head-stdinfo :data="data"></head-stdinfo>
+          </el-col>
+          <el-col :span="24">
+            <head-stdno :data="data"></head-stdno>
+          </el-col>
+        </el-row>
+        <el-row :gutter="10">
+          <el-col :span="24" v-if="!data.isSimple && hasDynamicArea">
+            <head-dynamic :data="data"></head-dynamic>
+          </el-col>
+          <el-col :span="24">
+            <head-notice :data="data"></head-notice>
+          </el-col>
+        </el-row>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+import HeadDynamic from "./cardHeadSpin/HeadDynamic";
+import HeadNotice from "./cardHeadSpin/HeadNotice";
+import HeadStdinfo from "./cardHeadSpin/HeadStdinfo";
+import HeadStdno from "./cardHeadSpin/HeadStdno";
+import CardHeadBodyAutoResize from "./CardHeadBodyAutoResize";
+import { mapMutations } from "vuex";
+
+export default {
+  name: "card-head",
+  components: {
+    HeadStdno,
+    HeadStdinfo,
+    HeadNotice,
+    HeadDynamic,
+    CardHeadBodyAutoResize
+  },
+  props: {
+    data: {
+      type: Object
+    },
+    preview: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      cardName: this.data.cardName
+    };
+  },
+  computed: {
+    classes() {
+      return [
+        "page-element",
+        "card-head",
+        {
+          "card-head-narrow": this.data.columnNumber > 2,
+          "card-head-handle": this.data.examNumberType === "fill",
+          "card-head-normal":
+            this.data.examNumberType !== "fill" && this.data.columnNumber <= 2
+        }
+      ];
+    },
+    hasDynamicArea() {
+      const noDynamic =
+        this.data.examNumberType === "fill"
+          ? !this.data.missAndFill && !this.data.aOrB
+          : !this.data.missAndFill && !this.data.writeSign && !this.data.aOrB;
+
+      return !noDynamic;
+    }
+  },
+  mounted() {},
+  methods: {
+    ...mapMutations("card", ["setCardConfig"]),
+    nameChange() {
+      this.setCardConfig({ cardName: this.cardName });
+    }
+  }
+};
+</script>

+ 98 - 0
src/modules/card/components/elementEdit/CardHeadBodyAutoResize.vue

@@ -0,0 +1,98 @@
+<template>
+  <div :class="classes">
+    <div class="rect-col">
+      <div
+        class="rect-col-item"
+        ref="stdinfoContainer"
+        :style="{ height: heights.stdinfo + 'px' }"
+      >
+        <slot name="stdinfo"></slot>
+      </div>
+      <div
+        class="rect-col-item"
+        ref="noticeContainer"
+        :style="{ height: heights.notice + 'px' }"
+      >
+        <slot name="notice"></slot>
+      </div>
+    </div>
+    <div class="rect-col">
+      <div
+        class="rect-col-item"
+        ref="stdnoContainer"
+        :style="{ height: heights.stdno + 'px' }"
+      >
+        <slot name="stdno"></slot>
+      </div>
+      <div
+        class="rect-col-item"
+        ref="dynamicContainer"
+        :style="{ height: heights.dynamic + 'px' }"
+      >
+        <slot name="dynamic"></slot>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "card-head-body-auto-resize",
+  data() {
+    return {
+      orgHeights: {
+        stdinfo: 196,
+        notice: 205,
+        stdno: 306,
+        dynamic: 109
+      },
+      heights: {
+        stdinfo: 196,
+        notice: 205,
+        stdno: 306,
+        dynamic: 109
+      },
+      isOrg: true
+    };
+  },
+  computed: {
+    classes() {
+      return [
+        "card-head-body-auto-resize",
+        this.isOrg ? "col-item-auto-height" : "col-item-full-height"
+      ];
+    }
+  },
+  mounted() {
+    this.initStyles();
+  },
+  methods: {
+    initStyles() {
+      console.log("11");
+
+      this.isOrg = true;
+      this.$nextTick(() => {
+        const containers = ["stdinfo", "notice", "stdno", "dynamic"];
+        containers.forEach(container => {
+          const dom = this.$refs[`${container}Container`].children[0];
+          this.orgHeights[container] = dom ? dom.offsetHeight : 0;
+        });
+        this.heights = { ...this.orgHeights };
+        this.resizeRect();
+        this.isOrg = false;
+      });
+    },
+    resizeRect() {
+      const col1 = this.orgHeights.stdinfo + this.orgHeights.notice;
+      const col2 = this.orgHeights.stdno + this.orgHeights.dynamic;
+      if (col1 > col2) {
+        this.heights.stdno = col1 - col2 + this.orgHeights.stdno + 10;
+      } else {
+        const splitHeight = (col2 - col1) / 2;
+        this.heights.stdinfo = splitHeight + this.orgHeights.stdinfo;
+        this.heights.notice = splitHeight + this.orgHeights.notice;
+      }
+    }
+  }
+};
+</script>

+ 48 - 0
src/modules/card/components/elementEdit/CardHeadSample.vue

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

+ 89 - 0
src/modules/card/components/elementEdit/Composition.vue

@@ -0,0 +1,89 @@
+<template>
+  <div class="elem-composition">
+    <div class="elem-title" v-if="data.topicName">
+      {{ data.topicName }}
+    </div>
+    <div
+      class="elem-body"
+      @drop.prevent="dropInnerElement($event)"
+      @dragover.prevent
+      @dragleave.prevent
+    >
+      <div class="elem-composition-elements">
+        <composition-element
+          v-for="(element, eindex) in data.elements"
+          :key="eindex"
+          :data="element"
+          @change="elementChange"
+        ></composition-element>
+      </div>
+      <div class="elem-composition-lines">
+        <ul>
+          <li v-for="line in data.lineCount" :key="line"></li>
+        </ul>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import CompositionElement from "./CompositionElement";
+
+export default {
+  name: "elem-composition",
+  components: { CompositionElement },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    ...mapState("card", ["curDragElement"])
+  },
+  methods: {
+    ...mapMutations("card", ["setCurDragElement", "setCurElement"]),
+    ...mapActions("card", ["rebuildPages"]),
+    dropInnerElement(e) {
+      console.log(e);
+
+      // 作文题中只允许创建一个文本题,不允许创建线条
+      if (this.data.type.includes("LINE")) return;
+      const index = this.data.elements.findIndex(item => item.type === "TEXT");
+      if (index !== -1 && this.data.type === "TEXT") return;
+
+      // 作文题的子元素中会新增container字段
+      const curElement = {
+        ...this.curDragElement,
+        w: document.getElementById("column-0-0").offsetWidth,
+        container: {
+          id: this.data.id,
+          type: this.data.type
+        }
+      };
+      this.data.elements.push(curElement);
+      this.setCurDragElement({});
+      this.setCurElement(curElement);
+      this.elementSizeChange();
+    },
+    elementChange(element) {
+      const index = this.data.elements.findIndex(
+        elem => elem.id === element.id
+      );
+      if (index !== -1) {
+        this.data.elements.splice(index, 1, element);
+      }
+
+      this.elementSizeChange();
+    },
+    elementSizeChange() {
+      this.$nextTick(() => {
+        this.rebuildPages();
+      });
+    }
+  }
+};
+</script>

+ 96 - 0
src/modules/card/components/elementEdit/CompositionElement.vue

@@ -0,0 +1,96 @@
+<template>
+  <div :class="classes">
+    <element-resize
+      v-model="elemData"
+      :active="active"
+      :move="false"
+      @on-click="activeCurElement"
+      @resize-over="elementChange"
+    >
+      <div
+        class="composition-element-item"
+        :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 ElemText from "../elementPreview/ElemText";
+import ElemImage from "../elementPreview/ElemImage";
+import ElemLineHorizontal from "../elementPreview/ElemLineHorizontal";
+import ElemLineVertical from "../elementPreview/ElemLineVertical";
+import ElementResize from "../common/ElementResize.vue";
+
+export default {
+  name: "composition-element",
+  components: {
+    ElemText,
+    ElemImage,
+    ElemLineHorizontal,
+    ElemLineVertical,
+    ElementResize
+  },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {
+      elemData: {
+        w: 0,
+        h: 0
+      },
+      styles: {},
+      actives: {
+        TEXT: ["b"],
+        IMAGE: ["b"],
+        LINE_HORIZONTAL: ["l", "r"],
+        LINE_VERTICAL: ["t", "b"]
+      }
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement"]),
+    compName() {
+      return `elem-${this.data.type.toLowerCase().replace("_", "-")}`;
+    },
+    classes() {
+      return [
+        "composition-element",
+        {
+          "composition-element-act": this.curElement.id === this.data.id
+        }
+      ];
+    },
+    active() {
+      return this.actives[this.data.type];
+    }
+  },
+  created() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("card", ["setCurElement"]),
+    init() {
+      this.elemData = this.$objAssign(this.elemData, this.data);
+      this.styles = {
+        height: this.data.h + "px"
+      };
+    },
+    elementChange() {
+      this.$emit("change", Object.assign({}, this.data, this.elemData));
+    },
+    activeCurElement() {
+      this.setCurElement(this.data);
+    }
+  }
+};
+</script>

+ 99 - 0
src/modules/card/components/elementEdit/ExplainChildren.vue

@@ -0,0 +1,99 @@
+<template>
+  <div class="elem-explain-children">
+    <div
+      class="elem-title"
+      v-if="data.explainNumber === data.parent.startNumber"
+    >
+      {{ data.parent.topicName }}
+    </div>
+    <div class="elem-body" :style="explainBodyStyle">
+      <div class="elem-explain-no">{{ data.explainNumber }}、</div>
+      <!-- 解答题子元件编辑区域 -->
+      <div
+        class="elem-explain-elements"
+        @drop.prevent="dropInnerElement($event)"
+        @dragover.prevent
+        @dragleave.prevent
+      >
+        <explain-children-element
+          v-for="(element, eindex) in data.elements"
+          :key="eindex"
+          :data="element"
+          @change="elementChange"
+        ></explain-children-element>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import ExplainChildrenElement from "./ExplainChildrenElement";
+import { mapState, mapMutations } from "vuex";
+
+export default {
+  name: "elem-explain-children",
+  components: { ExplainChildrenElement },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    ...mapState("card", ["curDragElement"]),
+    explainBodyStyle() {
+      const height =
+        this.data.explainNumber === this.data.parent.startNumber
+          ? this.data.h - 30
+          : this.data.h;
+      return {
+        height: height + "px"
+      };
+    }
+  },
+  methods: {
+    ...mapMutations("card", ["setCurDragElement", "setCurElement"]),
+    dropInnerElement(e) {
+      let { layerX: x, layerY: y } = e;
+      const { offsetLeft, offsetTop } = this.getOffsetInfo(e.target);
+      // 解答题的子元素中会新增container字段
+      const curElement = {
+        ...this.curDragElement,
+        x: x + offsetLeft,
+        y: y + offsetTop,
+        container: {
+          id: this.data.id,
+          type: this.data.type
+        }
+      };
+      this.data.elements.push(curElement);
+      this.setCurDragElement({});
+      this.setCurElement(curElement);
+    },
+    getOffsetInfo(dom, endParent = "elem-explain-elements") {
+      let parentNode = dom;
+      let offsetTop = 0,
+        offsetLeft = 0;
+      while (!parentNode.className.includes(endParent)) {
+        offsetTop += parentNode.offsetTop;
+        offsetLeft += parentNode.offsetLeft;
+        parentNode = parentNode.offsetParent;
+      }
+      return {
+        offsetLeft,
+        offsetTop
+      };
+    },
+    elementChange(element) {
+      const index = this.data.elements.findIndex(
+        elem => elem.id === element.id
+      );
+      if (index !== -1) {
+        this.data.elements.splice(index, 1, element);
+      }
+    }
+  }
+};
+</script>

+ 101 - 0
src/modules/card/components/elementEdit/ExplainChildrenElement.vue

@@ -0,0 +1,101 @@
+<template>
+  <div :class="classes">
+    <element-resize
+      v-model="elemData"
+      :active="active"
+      fit-parent
+      @change="elementChange"
+      @on-click="activeCurElement"
+    >
+      <div
+        class="explain-element"
+        :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 ElemText from "../elementPreview/ElemText";
+import ElemImage from "../elementPreview/ElemImage";
+import ElemLineHorizontal from "../elementPreview/ElemLineHorizontal";
+import ElemLineVertical from "../elementPreview/ElemLineVertical";
+import ElementResize from "../common/ElementResize.vue";
+
+export default {
+  name: "explain-children-element",
+  components: {
+    ElemText,
+    ElemImage,
+    ElemLineHorizontal,
+    ElemLineVertical,
+    ElementResize
+  },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {
+      elemData: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0
+      },
+      styles: {},
+      actives: {
+        TEXT: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        IMAGE: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        LINE_HORIZONTAL: ["l", "r"],
+        LINE_VERTICAL: ["t", "b"]
+      }
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement"]),
+    compName() {
+      return `elem-${this.data.type.toLowerCase().replace("_", "-")}`;
+    },
+    classes() {
+      return [
+        "explain-children-element",
+        {
+          "explain-children-element-act": this.curElement.id === this.data.id
+        }
+      ];
+    },
+    active() {
+      return this.actives[this.data.type];
+    }
+  },
+  created() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("card", ["setCurElement"]),
+    init() {
+      this.elemData = this.$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"
+      };
+    },
+    elementChange() {
+      this.$emit("change", Object.assign({}, this.data, this.elemData));
+    },
+    activeCurElement() {
+      this.setCurElement(this.data);
+    }
+  }
+};
+</script>

+ 98 - 0
src/modules/card/components/elementEdit/cardHeadSpin/HeadDynamic.vue

@@ -0,0 +1,98 @@
+<template>
+  <div :class="classes">
+    <div
+      class="head-dynamic-part head-dynamic-write"
+      v-if="data.examNumberType !== 'fill' && data.writeSign"
+    >
+      <div class="stdinfo-item">
+        <span>手写签名</span>
+        <span>:</span>
+        <span></span>
+      </div>
+      <p>
+        注意:签名则表示您认可答题卡提供的信息与您本人信息相符;如签名与信息不符或者未签名,试卷作废。
+      </p>
+    </div>
+    <div
+      class="head-dynamic-part head-dynamic-missfill"
+      v-if="data.missAndFill"
+    >
+      <div class="head-dynamic-miss">
+        <div class="head-dynamic-content">
+          <span class="dynamic-miss-title">缺考标记</span>
+          <span class="dynamic-miss-body"
+            ><i class="head-dynamic-rect" id="dynamic-miss-area"></i
+          ></span>
+        </div>
+      </div>
+      <div class="head-dynamic-fill">
+        <div class="head-dynamic-content">
+          <p><span>正确填涂:</span><i></i></p>
+          <p>
+            <span>错误填涂:</span>
+            <i><i class="el-icon-check"></i></i>
+            <i><i class="el-icon-close"></i></i>
+            <i></i><i></i>
+          </p>
+        </div>
+      </div>
+    </div>
+    <div
+      :class="[
+        'head-dynamic-part',
+        'head-dynamic-aorb',
+        `head-dynamic-aorb-${data.aOrBType}`
+      ]"
+      id="head-dynamic-aorb"
+      v-if="data.aOrB"
+    >
+      <div class="dynamic-aorb-title">
+        <i class="center-cont">试卷类型:</i>
+      </div>
+      <div class="dynamic-aorb-body" v-if="data.aOrBType === 'fill'">
+        <span class="dynamic-aorb-rect"
+          ><i class="head-dynamic-rect">A</i></span
+        >
+        <span class="dynamic-aorb-rect"
+          ><i class="head-dynamic-rect">B</i></span
+        >
+      </div>
+      <div class="dynamic-aorb-body" v-else>
+        <span class="dynamic-aorb-type"><i class="center-cont">A</i></span>
+        <span class="dynamic-aorb-barcode" id="dynamic-aorb-barcode">
+          <img :src="aorbBarcodeSrc" alt="条形码" v-if="aorbBarcodeSrc" />
+          <img src="@/assets/images/barcode-sample.png" alt="条形码" v-else />
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "head-dynamic",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {
+      aorbBarcodeSrc:
+        this.data["fieldInfos"] && this.data["fieldInfos"]["paperType"]
+    };
+  },
+  computed: {
+    classes() {
+      let partNum = 0;
+      if (this.data.examNumberType !== "fill" && this.data.writeSign) partNum++;
+      if (this.data.missAndFill) partNum++;
+      if (this.data.aOrB) partNum++;
+
+      return ["head-dynamic", "card-head-body-spin", `head-dynamic-${partNum}`];
+    }
+  },
+  mounted() {},
+  methods: {}
+};
+</script>

+ 40 - 0
src/modules/card/components/elementEdit/cardHeadSpin/HeadNotice.vue

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

+ 31 - 0
src/modules/card/components/elementEdit/cardHeadSpin/HeadStdinfo.vue

@@ -0,0 +1,31 @@
+<template>
+  <div class="head-stdinfo card-head-body-spin">
+    <div
+      class="stdinfo-item"
+      v-for="(info, index) in data.businessParams"
+      :key="index"
+    >
+      <span>{{ info.name }}</span>
+      <span>:</span>
+      <span>{{ fieldInfos[info.field] }}</span>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "head-stdinfo",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {
+      fieldInfos: this.data["fieldInfos"] || {}
+    };
+  },
+  mounted() {},
+  methods: {}
+};
+</script>

+ 60 - 0
src/modules/card/components/elementEdit/cardHeadSpin/HeadStdno.vue

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

+ 38 - 0
src/modules/card/components/elementPreview/Composition.vue

@@ -0,0 +1,38 @@
+<template>
+  <div class="elem-composition">
+    <div class="elem-title" v-if="data.topicName">
+      {{ data.topicName }}
+    </div>
+    <div class="elem-body">
+      <div class="elem-composition-elements">
+        <composition-element
+          v-for="(element, eindex) in data.elements"
+          :key="eindex"
+          :data="element"
+        ></composition-element>
+      </div>
+      <div class="elem-composition-lines">
+        <ul>
+          <li v-for="line in data.lineCount" :key="line"></li>
+        </ul>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import CompositionElement from "./CompositionElement";
+
+export default {
+  name: "elem-composition",
+  components: { CompositionElement },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  }
+};
+</script>

+ 42 - 0
src/modules/card/components/elementPreview/CompositionElement.vue

@@ -0,0 +1,42 @@
+<template>
+  <div class="composition-element">
+    <div class="composition-element-item" :style="styles" :id="data.id">
+      <component :is="compName" :data="data"></component>
+    </div>
+  </div>
+</template>
+
+<script>
+import ElemText from "./ElemText";
+import ElemImage from "./ElemImage";
+import ElemLineHorizontal from "./ElemLineHorizontal";
+import ElemLineVertical from "./ElemLineVertical";
+
+export default {
+  name: "composition-element",
+  components: {
+    ElemText,
+    ElemImage,
+    ElemLineHorizontal,
+    ElemLineVertical
+  },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    compName() {
+      return `elem-${this.data.type.toLowerCase().replace("_", "-")}`;
+    },
+    styles() {
+      return {
+        height: this.data.h + "px"
+      };
+    }
+  }
+};
+</script>

+ 37 - 0
src/modules/card/components/elementPreview/ElemImage.vue

@@ -0,0 +1,37 @@
+<template>
+  <div class="elem-image" :style="styles">
+    <img :src="imageSrc" alt="图片" v-if="imageSrc" />
+    <p v-else><i class="el-icon-picture"></i></p>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "elem-image",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  computed: {
+    styles() {
+      return {
+        borderStyle: this.data.borderStyle,
+        borderColor: this.data.borderColor
+      };
+    },
+    imageSrc() {
+      const content =
+        this.data.content &&
+        this.data.content[0] &&
+        this.data.content[0].content;
+
+      return content && content.indexOf("base64") !== -1 ? content : "";
+    }
+  },
+  data() {
+    return {};
+  },
+  methods: {}
+};
+</script>

+ 30 - 0
src/modules/card/components/elementPreview/ElemLineHorizontal.vue

@@ -0,0 +1,30 @@
+<template>
+  <div class="elem-line-horizontal">
+    <div class="line-body" :style="styles"></div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "elem-line-horizontal",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  computed: {
+    styles() {
+      return {
+        borderBottomStyle: this.data.style,
+        borderBottomWidth: this.data.bold,
+        borderBottomColor: this.data.color,
+        width: this.w + "px"
+      };
+    }
+  },
+  data() {
+    return {};
+  },
+  methods: {}
+};
+</script>

+ 30 - 0
src/modules/card/components/elementPreview/ElemLineVertical.vue

@@ -0,0 +1,30 @@
+<template>
+  <div class="elem-line-vertical">
+    <div class="line-body" :style="styles"></div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "elem-line-horizontal",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  computed: {
+    styles() {
+      return {
+        borderLeftStyle: this.data.style,
+        borderLeftWidth: this.data.bold,
+        borderLeftColor: this.data.color,
+        height: this.h + "px"
+      };
+    }
+  },
+  data() {
+    return {};
+  },
+  methods: {}
+};
+</script>

+ 37 - 0
src/modules/card/components/elementPreview/ElemText.vue

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

+ 51 - 0
src/modules/card/components/elementPreview/ExplainChildren.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="elem-explain-children">
+    <div
+      class="elem-title"
+      v-if="data.explainNumber === data.parent.startNumber"
+    >
+      {{ data.parent.topicName }}
+    </div>
+    <div class="elem-body" :style="explainBodyStyle">
+      <div class="elem-explain-no">{{ data.explainNumber }}、</div>
+      <!-- 解答题子元件区域 -->
+      <div class="elem-explain-elements">
+        <explain-children-element
+          v-for="(element, eindex) in data.elements"
+          :key="eindex"
+          :data="element"
+        ></explain-children-element>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import ExplainChildrenElement from "./ExplainChildrenElement";
+
+export default {
+  name: "elem-explain-children",
+  components: { ExplainChildrenElement },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    explainBodyStyle() {
+      const firstHeight =
+        this.data.explainNumber === this.data.parent.startNumber
+          ? this.data.h - 30
+          : this.data.h;
+      const height = this.data.h ? firstHeight : 481;
+      return {
+        height: height + "px"
+      };
+    }
+  },
+  methods: {}
+};
+</script>

+ 46 - 0
src/modules/card/components/elementPreview/ExplainChildrenElement.vue

@@ -0,0 +1,46 @@
+<template>
+  <div class="explain-children-element">
+    <div class="explain-element" :style="styles" :id="data.id">
+      <component :is="compName" :data="data"></component>
+    </div>
+  </div>
+</template>
+
+<script>
+import ElemText from "./ElemText";
+import ElemImage from "./ElemImage";
+import ElemLineHorizontal from "./ElemLineHorizontal";
+import ElemLineVertical from "./ElemLineVertical";
+
+export default {
+  name: "explain-children-element",
+  components: {
+    ElemText,
+    ElemImage,
+    ElemLineHorizontal,
+    ElemLineVertical
+  },
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    compName() {
+      return `elem-${this.data.type.toLowerCase().replace("_", "-")}`;
+    },
+    styles() {
+      return {
+        left: this.data.x + "px",
+        top: this.data.y + "px",
+        width: this.data.w + "px",
+        height: this.data.h + "px"
+      };
+    }
+  },
+  methods: {}
+};
+</script>

+ 39 - 0
src/modules/card/components/elementPreview/FillArea.vue

@@ -0,0 +1,39 @@
+<template>
+  <div :class="classes">
+    <ul class="option-list">
+      <li
+        class="option-item"
+        v-for="n in data.optionCount"
+        :key="n"
+        :style="optionGapStyles"
+      ></li>
+    </ul>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "elem-fill-area",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {
+      options: []
+    };
+  },
+  computed: {
+    classes() {
+      return ["elem-fill-area", `elem-fill-area-${this.data.optionDirection}`];
+    },
+    optionGapStyles() {
+      return this.data.optionDirection === "vertical"
+        ? { marginBottom: this.data.optionGap + "px" }
+        : { marginRight: this.data.optionGap + "px" };
+    }
+  },
+  methods: {}
+};
+</script>

+ 41 - 0
src/modules/card/components/elementPreview/FillLine.vue

@@ -0,0 +1,41 @@
+<template>
+  <div class="elem-fill-line">
+    <div class="elem-title" v-if="data.topicName">{{ data.topicName }}</div>
+    <div class="elem-body">
+      <ul
+        class="elem-fill-quesiton"
+        v-for="question in data.questionsCount"
+        :key="question"
+        :style="groupStyles"
+      >
+        <li class="elem-fill-no">
+          {{ data.numberPre }}{{ data.startNumber + question - 1 }}、
+        </li>
+        <li v-for="line in data.lineNumberPerQuestion" :key="line"></li>
+      </ul>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "elem-fill-line",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    groupStyles() {
+      return {
+        width: 100 / this.data.questionNumberPerLine + "%"
+      };
+    }
+  },
+  mounted() {},
+  methods: {}
+};
+</script>

+ 107 - 0
src/modules/card/components/elementPreview/FillQuestion.vue

@@ -0,0 +1,107 @@
+<template>
+  <div :class="classes">
+    <div class="elem-title" v-if="data.topicName">{{ data.topicName }}</div>
+    <div class="elem-body">
+      <ul
+        class="group-item"
+        v-for="(group, gindex) in questions"
+        :key="gindex"
+        :style="groupGapStyles"
+      >
+        <li
+          class="question-item"
+          v-for="(question, qindex) in group"
+          :key="qindex"
+          :style="questionGapStyles"
+        >
+          <i
+            class="option-item"
+            v-for="(option, oindex) in question"
+            :key="oindex"
+            :style="optionGapStyles"
+            >{{ option }}</i
+          >
+        </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}`
+      ];
+    },
+    groupGapStyles() {
+      return {
+        marginRight: this.data.groupGap + "px",
+        marginBottom: this.data.groupGap / 2 + "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.optionCount, data.isSelect);
+      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];
+        }
+      }
+      this.questions = questions;
+    },
+    getChoiceList(num, isSelect) {
+      const options = isSelect ? "abcdefghijklmn" : "√×";
+      return options
+        .toUpperCase()
+        .slice(0, num)
+        .split("");
+    }
+  },
+  watch: {
+    data: {
+      immediate: true,
+      handler(val) {
+        this.parseQuestion(val);
+      }
+    }
+  }
+};
+</script>

+ 22 - 0
src/modules/card/components/elementPreview/TopicHead.vue

@@ -0,0 +1,22 @@
+<template>
+  <div class="page-element elem-topic-head">
+    <h3>{{ data.typeName }}答题区</h3>
+    <p>{{ data.content }}</p>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "topic-head",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  mounted() {},
+  methods: {}
+};
+</script>

+ 106 - 0
src/modules/card/components/elementPropEdit/EditComposition.vue

@@ -0,0 +1,106 @@
+<template>
+  <el-dialog
+    class="edit-composition edit-dialog"
+    :visible.sync="dialogIsShow"
+    title="作文题"
+    top="10vh"
+    width="640px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="opened"
+    @close="closed"
+  >
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      label-width="100px"
+    >
+      <el-form-item prop="topicName" label="题目名称:">
+        <el-input
+          v-model.trim="modalForm.topicName"
+          size=""
+          placeholder="请输入题目名称"
+          clearable
+        ></el-input>
+      </el-form-item>
+      <el-form-item prop="startEnd" label="作文行数:">
+        <el-input-number
+          style="width:125px;"
+          v-model="modalForm.lineCount"
+          :min="0"
+          :max="30"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+    </el-form>
+    <div slot="footer">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button @click="cancel" plain>取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+const initModalForm = {
+  id: "",
+  topicName: "",
+  lineCount: 10
+};
+
+export default {
+  name: "edit-composition",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      dialogIsShow: false,
+      modalForm: { ...initModalForm },
+      rules: {
+        topicName: [
+          {
+            required: true,
+            message: "请输入题目名称",
+            trigger: "change"
+          }
+        ]
+      }
+    };
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+    },
+    opened() {
+      this.initData(this.instance);
+    },
+    closed() {
+      this.$emit("closed");
+    },
+    cancel() {
+      this.dialogIsShow = false;
+    },
+    open() {
+      this.dialogIsShow = true;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate();
+      if (!valid) return;
+
+      this.$emit("modified", this.modalForm);
+      this.cancel();
+    }
+  }
+};
+</script>

+ 136 - 0
src/modules/card/components/elementPropEdit/EditExplain.vue

@@ -0,0 +1,136 @@
+<template>
+  <el-dialog
+    class="edit-explain edit-dialog"
+    :visible.sync="dialogIsShow"
+    title="解答题"
+    top="10vh"
+    width="640px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="opened"
+    @close="closed"
+  >
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      label-width="100px"
+    >
+      <el-form-item prop="topicName" label="题目名称:">
+        <el-input
+          v-model.trim="modalForm.topicName"
+          size=""
+          placeholder="请输入题目名称"
+          clearable
+        ></el-input>
+      </el-form-item>
+      <el-form-item prop="startEnd" label="起止题号:">
+        <el-input-number
+          style="width:40px;"
+          v-model="modalForm.startNumber"
+          :min="0"
+          :max="100"
+          :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="0"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+    </el-form>
+    <div slot="footer">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button @click="cancel" plain>取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+const initModalForm = {
+  id: "",
+  topicName: "",
+  startNumber: 1,
+  endNumber: 4,
+  questionsCount: 4
+};
+
+export default {
+  name: "edit-explain",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    const numberValidater = (rule, value, callback) => {
+      if (this.modalForm.startNumber < this.modalForm.endNumber) {
+        callback(new Error("开始序号不能小于结束序号"));
+      } else {
+        callback();
+      }
+    };
+
+    return {
+      dialogIsShow: false,
+      modalForm: { ...initModalForm },
+      rules: {
+        // topicName: [
+        //   {
+        //     required: true,
+        //     message: "请输入题目名称",
+        //     trigger: "change"
+        //   }
+        // ],
+        startEnd: [
+          {
+            validate: numberValidater,
+            trigger: "change"
+          }
+        ]
+      }
+    };
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+      this.modalForm.endNumber =
+        this.modalForm.startNumber + this.modalForm.questionsCount - 1;
+    },
+    opened() {
+      this.initData(this.instance);
+    },
+    closed() {
+      this.$emit("closed");
+    },
+    cancel() {
+      this.dialogIsShow = false;
+    },
+    open() {
+      this.dialogIsShow = true;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate();
+      if (!valid) return;
+
+      this.modalForm.questionsCount =
+        this.modalForm.endNumber - this.modalForm.startNumber + 1;
+      this.$emit("modified", this.modalForm);
+      this.cancel();
+    }
+  }
+};
+</script>

+ 163 - 0
src/modules/card/components/elementPropEdit/EditFillLine.vue

@@ -0,0 +1,163 @@
+<template>
+  <el-dialog
+    class="edit-fill-line edit-dialog"
+    :visible.sync="dialogIsShow"
+    title="填空题"
+    top="10vh"
+    width="640px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="opened"
+    @close="closed"
+  >
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      label-width="100px"
+    >
+      <el-form-item prop="topicName" label="题目名称:">
+        <el-input
+          v-model.trim="modalForm.topicName"
+          size=""
+          placeholder="请输入题目名称"
+          clearable
+        ></el-input>
+      </el-form-item>
+      <el-form-item prop="startEnd" label="起止题号:">
+        <el-input-number
+          style="width:40px;"
+          v-model="modalForm.startNumber"
+          :min="0"
+          :max="100"
+          :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="0"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item 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-number
+          style="width:125px;"
+          v-model="modalForm.lineNumberPerQuestion"
+          :min="1"
+          :max="15"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+        <span class="el-input-tips">*指每一题显示几行</span>
+      </el-form-item>
+      <el-form-item label="题号前缀:">
+        <el-input
+          style="width:125px;"
+          v-model.trim="modalForm.numberPre"
+          clearable
+        ></el-input>
+      </el-form-item>
+    </el-form>
+    <div slot="footer">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button @click="cancel" plain>取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+const initModalForm = {
+  id: "",
+  topicName: "",
+  startNumber: 1,
+  endNumber: 2,
+  questionsCount: 2,
+  questionNumberPerLine: 1,
+  lineNumberPerQuestion: 1,
+  numberPre: ""
+};
+
+export default {
+  name: "edit-fill-line",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    const numberValidater = (rule, value, callback) => {
+      if (this.modalForm.startNumber < this.modalForm.endNumber) {
+        callback(new Error("开始序号不能小于结束序号"));
+      } else {
+        callback();
+      }
+    };
+
+    return {
+      dialogIsShow: false,
+      modalForm: { ...initModalForm },
+      rules: {
+        startEnd: [
+          {
+            validate: numberValidater,
+            trigger: "change"
+          }
+        ]
+      }
+    };
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+      this.modalForm.endNumber =
+        this.modalForm.startNumber + this.modalForm.questionsCount - 1;
+    },
+    opened() {
+      this.initData(this.instance);
+    },
+    closed() {
+      this.$emit("closed");
+    },
+    cancel() {
+      this.dialogIsShow = false;
+    },
+    open() {
+      this.dialogIsShow = true;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate();
+      if (!valid) return;
+
+      this.modalForm.questionsCount =
+        this.modalForm.endNumber - this.modalForm.startNumber + 1;
+      this.$emit("modified", this.modalForm);
+      this.cancel();
+    }
+  }
+};
+</script>

+ 162 - 0
src/modules/card/components/elementPropEdit/EditFillQuestion.vue

@@ -0,0 +1,162 @@
+<template>
+  <el-dialog
+    class="edit-fill-question edit-dialog"
+    :visible.sync="dialogIsShow"
+    title="选择题"
+    top="10vh"
+    width="640px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="opened"
+    @close="closed"
+  >
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      label-width="100px"
+    >
+      <el-form-item prop="topicName" label="题目名称:">
+        <el-input
+          v-model.trim="modalForm.topicName"
+          size=""
+          placeholder="请输入题目名称"
+          clearable
+        ></el-input>
+      </el-form-item>
+      <el-form-item prop="startEnd" label="起止题号:">
+        <el-input-number
+          style="width:40px;"
+          v-model="modalForm.startNumber"
+          :min="0"
+          :max="100"
+          :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="0"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item label="选项个数:">
+        <el-input-number
+          style="width:125px;"
+          v-model="modalForm.optionCount"
+          :min="2"
+          :max="15"
+          :step="1"
+          step-strictly
+          :controls="false"
+          :disabled="!modalForm.isSelect"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item>
+        <el-checkbox v-model="modalForm.isSelect" @change="selectTypeChange"
+          >选择题</el-checkbox
+        >
+      </el-form-item>
+      <el-form-item>
+        <el-checkbox
+          v-model="modalForm.isMultiply"
+          :disabled="!modalForm.isSelect"
+          >多选</el-checkbox
+        >
+      </el-form-item>
+    </el-form>
+    <div slot="footer">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button @click="cancel" plain>取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+const initModalForm = {
+  id: "",
+  topicName: "",
+  startNumber: 1,
+  endNumber: 5,
+  questionsCount: 10,
+  optionCount: 5,
+  isSelect: true,
+  isMultiply: false
+};
+
+export default {
+  name: "edit-fill-question",
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    const numberValidater = (rule, value, callback) => {
+      if (this.modalForm.startNumber < this.modalForm.endNumber) {
+        callback(new Error("开始序号不能小于结束序号"));
+      } else {
+        callback();
+      }
+    };
+
+    return {
+      dialogIsShow: false,
+      modalForm: { ...initModalForm },
+      rules: {
+        startEnd: [
+          {
+            validate: numberValidater,
+            trigger: "change"
+          }
+        ]
+      }
+    };
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+      this.modalForm.endNumber =
+        this.modalForm.startNumber + this.modalForm.questionsCount - 1;
+    },
+    selectTypeChange(val) {
+      if (!val) {
+        this.modalForm.optionCount = 2;
+        this.modalForm.isMultiply = false;
+      }
+    },
+    opened() {
+      this.initData(this.instance);
+    },
+    closed() {
+      this.$emit("closed");
+    },
+    cancel() {
+      this.dialogIsShow = false;
+    },
+    open() {
+      this.dialogIsShow = true;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate();
+      if (!valid) return;
+
+      this.modalForm.questionsCount =
+        this.modalForm.endNumber - this.modalForm.startNumber + 1;
+      this.$emit("modified", this.modalForm);
+      this.cancel();
+    }
+  }
+};
+</script>

+ 120 - 0
src/modules/card/components/elementPropEdit/EditImage.vue

@@ -0,0 +1,120 @@
+<template>
+  <el-dialog
+    class="edit-image edit-dialog"
+    :visible.sync="dialogIsShow"
+    title="图片编辑"
+    top="10vh"
+    width="640px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="opened"
+    @close="closed"
+  >
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :key="modalForm.id"
+      label-width="100px"
+    >
+      <el-form-item label="边框颜色:">
+        <color-select v-model="modalForm.borderColor" show-empty></color-select>
+      </el-form-item>
+      <el-form-item label="边框线形:">
+        <line-style-select v-model="modalForm.borderStyle"></line-style-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="addImage">插入图片</el-button>
+        <input
+          ref="fileInput"
+          type="file"
+          style="display:none;"
+          @change="imageChange"
+          accept="image/jpg,image/png"
+        />
+      </el-form-item>
+      <div style="text-align: center;">
+        <img style="width: 150px;" :src="imageSrc" alt="图片" v-if="imageSrc" />
+      </div>
+    </el-form>
+    <div slot="footer">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button @click="cancel" plain>取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import ColorSelect from "../common/ColorSelect";
+import LineStyleSelect from "../common/LineStyleSelect";
+
+const initModalForm = {
+  id: "",
+  borderColor: "",
+  borderStyle: "",
+  content: []
+};
+
+export default {
+  name: "edit-image",
+  components: { ColorSelect, LineStyleSelect },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      dialogIsShow: false,
+      imageSrc: "",
+      modalForm: { ...initModalForm }
+    };
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+      const content = val.content && val.content[0] && val.content[0].content;
+      this.imageSrc =
+        content && content.indexOf("base64") !== -1 ? content : "";
+    },
+    addImage() {
+      this.$refs.fileInput.click();
+    },
+    imageChange(e) {
+      const file = e.target.files[0];
+      const reader = new FileReader();
+      reader.readAsDataURL(file);
+      reader.onload = e => {
+        this.modalForm.content = [
+          {
+            type: "text",
+            content: e.target.result
+          }
+        ];
+        this.imageSrc = e.target.result;
+        this.$refs.fileInput.value = null;
+      };
+    },
+    opened() {
+      this.initData(this.instance);
+    },
+    closed() {
+      this.$emit("closed");
+    },
+    cancel() {
+      this.dialogIsShow = false;
+    },
+    open() {
+      this.dialogIsShow = true;
+    },
+    submit() {
+      this.$emit("modified", this.modalForm);
+      this.cancel();
+    }
+  }
+};
+</script>

+ 89 - 0
src/modules/card/components/elementPropEdit/EditLine.vue

@@ -0,0 +1,89 @@
+<template>
+  <el-dialog
+    class="edit-line edit-dialog"
+    :visible.sync="dialogIsShow"
+    title="线条编辑"
+    top="10vh"
+    width="640px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="opened"
+    @close="closed"
+  >
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :key="modalForm.id"
+      label-width="100px"
+    >
+      <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 slot="footer">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button @click="cancel" plain>取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import ColorSelect from "../common/ColorSelect";
+import LineStyleSelect from "../common/LineStyleSelect";
+import LineWidthSelect from "../common/LineWidthSelect";
+
+const initModalForm = {
+  id: "",
+  bold: "1px",
+  color: "#000000",
+  style: "solid"
+};
+
+export default {
+  name: "edit-line",
+  components: { ColorSelect, LineStyleSelect, LineWidthSelect },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      dialogIsShow: false,
+      modalForm: { ...initModalForm }
+    };
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+    },
+    opened() {
+      this.initData(this.instance);
+    },
+    closed() {
+      this.$emit("closed");
+    },
+    cancel() {
+      this.dialogIsShow = false;
+    },
+    open() {
+      this.dialogIsShow = true;
+    },
+    submit() {
+      this.$emit("modified", this.modalForm);
+      this.cancel();
+    }
+  }
+};
+</script>

+ 166 - 0
src/modules/card/components/elementPropEdit/EditText.vue

@@ -0,0 +1,166 @@
+<template>
+  <el-dialog
+    class="edit-text edit-dialog"
+    :visible.sync="dialogIsShow"
+    title="文本编辑"
+    top="10vh"
+    width="640px"
+    :modal="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @open="opened"
+    @close="closed"
+  >
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :rules="rules"
+      :key="modalForm.id"
+      label-width="100px"
+    >
+      <el-form-item label="字号:">
+        <size-select
+          v-model="modalForm.fontSize"
+          style="width:100%;"
+        ></size-select>
+      </el-form-item>
+      <el-form-item label="字体:">
+        <font-family-select
+          v-model="modalForm.fontFamily"
+          style="width:100%;"
+        ></font-family-select>
+      </el-form-item>
+      <el-form-item label="颜色:">
+        <color-select v-model="modalForm.color"></color-select>
+      </el-form-item>
+      <el-form-item label="加粗:">
+        <el-checkbox v-model="isBold" @change="boldChange"
+          >是否加粗</el-checkbox
+        >
+      </el-form-item>
+      <el-form-item prop="contentStr" label="内容:">
+        <el-input
+          type="textarea"
+          :rows="4"
+          placeholder="请输入内容"
+          v-model="modalForm.contentStr"
+          @change="contentChange"
+          ref="contentTextarea"
+        >
+        </el-input>
+      </el-form-item>
+    </el-form>
+    <div slot="footer">
+      <el-button type="primary" @click="submit">确认</el-button>
+      <el-button @click="cancel" plain>取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import SizeSelect from "../common/SizeSelect";
+import ColorSelect from "../common/ColorSelect";
+import FontFamilySelect from "../common/FontFamilySelect";
+
+const initModalForm = {
+  id: "",
+  fontSize: "10.5pt",
+  color: "",
+  fontFamily: "",
+  fontWeight: 400,
+  rotation: 0,
+  content: [],
+  contentStr: ""
+};
+
+export default {
+  name: "edit-text",
+  components: {
+    SizeSelect,
+    ColorSelect,
+    FontFamilySelect
+  },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      dialogIsShow: false,
+      modalForm: { ...initModalForm },
+      isBold: false,
+      rules: {
+        contentStr: [
+          {
+            required: true,
+            message: "请输入文本内容",
+            trigger: "change"
+          }
+        ]
+      }
+    };
+  },
+  methods: {
+    initData(val) {
+      const contentStr = val.content
+        .map(item => {
+          return item.type === "text"
+            ? item.content
+            : "${" + item.content + "}";
+        })
+        .join("");
+      this.modalForm = { ...val, contentStr };
+      this.isBold = val.fontWeight > 400;
+    },
+    boldChange(isBold) {
+      this.modalForm.fontWeight = isBold ? 700 : 400;
+    },
+    contentChange() {
+      const constentStr = this.modalForm.contentStr;
+      const rexp = new RegExp(/\$\{.+?\}/, "g");
+      const variates = constentStr.match(rexp);
+      const texts = constentStr.split(rexp);
+      let contents = [];
+
+      texts.forEach((text, index) => {
+        if (text)
+          contents.push({
+            type: "text",
+            content: text
+          });
+
+        if (variates && variates[index])
+          contents.push({
+            type: "variate",
+            content: variates[index].replace("${", "").replace("}", "")
+          });
+      });
+      this.modalForm.content = contents;
+    },
+    opened() {
+      this.initData(this.instance);
+    },
+    closed() {
+      this.$emit("closed");
+    },
+    cancel() {
+      this.dialogIsShow = false;
+    },
+    open() {
+      this.dialogIsShow = true;
+    },
+    async submit() {
+      const valid = await this.$refs.modalFormComp.validate();
+      if (!valid) return;
+
+      this.$emit("modified", this.modalForm);
+      this.cancel();
+    }
+  }
+};
+</script>

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

@@ -0,0 +1,114 @@
+<template>
+  <div class="element-prop-edit">
+    <edit-composition
+      :instance="curElement"
+      @modified="modified"
+      @closed="closed"
+      ref="CompositionDialog"
+    ></edit-composition>
+    <edit-explain
+      :instance="curElement"
+      @modified="modified"
+      @closed="closed"
+      ref="ExplainDialog"
+    ></edit-explain>
+    <edit-fill-line
+      :instance="curElement"
+      @modified="modified"
+      @closed="closed"
+      ref="FillLineDialog"
+    ></edit-fill-line>
+    <edit-fill-question
+      :instance="curElement"
+      @modified="modified"
+      @closed="closed"
+      ref="FillQuestionDialog"
+    ></edit-fill-question>
+    <edit-text
+      :instance="curElement"
+      @modified="modified"
+      @closed="closed"
+      ref="TextDialog"
+    ></edit-text>
+    <edit-image
+      :instance="curElement"
+      @modified="modified"
+      @closed="closed"
+      ref="ImageDialog"
+    ></edit-image>
+    <edit-line
+      :instance="curElement"
+      @modified="modified"
+      @closed="closed"
+      ref="LineDialog"
+    ></edit-line>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import EditComposition from "./EditComposition";
+import EditExplain from "./EditExplain";
+import EditFillLine from "./EditFillLine";
+import EditFillQuestion from "./EditFillQuestion";
+import EditText from "./EditText";
+import EditImage from "./EditImage";
+import EditLine from "./EditLine";
+
+export default {
+  name: "element-prop-edit",
+  components: {
+    EditComposition,
+    EditExplain,
+    EditFillLine,
+    EditFillQuestion,
+    EditText,
+    EditImage,
+    EditLine
+  },
+  data() {
+    return {};
+  },
+  watch: {
+    openElementEditDialog(val) {
+      if (!val) return;
+      const type = this.curElement["parent"]
+        ? this.curElement["parent"].type
+        : this.curElement.type;
+      let elementName = type
+        .split("_")
+        .map(item => item[0] + item.substr(1).toLowerCase())
+        .join("");
+      elementName = elementName.indexOf("Line") === 0 ? "Line" : elementName;
+
+      this.$refs[`${elementName}Dialog`].open();
+    }
+  },
+  computed: {
+    ...mapState("card", ["curElement", "openElementEditDialog"])
+  },
+  methods: {
+    ...mapMutations("card", ["setOpenElementEditDialog"]),
+    ...mapActions("card", [
+      "modifyElement",
+      "modifyElementChild",
+      "rebuildPages"
+    ]),
+    modified(element) {
+      if (element["container"]) {
+        this.modifyElementChild(element);
+      } else {
+        this.modifyElement(element);
+      }
+      // 作文题和解答题小题的子元素修改之后,都不自动调整题目高度,也就不用重排页面
+      if (!element["container"])
+        this.$nextTick(() => {
+          this.rebuildPages();
+        });
+    },
+    closed() {
+      this.setOpenElementEditDialog(false);
+    }
+  }
+};
+</script>

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

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

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

@@ -0,0 +1,415 @@
+import { deepCopy, getNumList, randomCode } from "@/plugins/utils";
+
+// element ------------------- >
+// 页面
+const PAGE = {
+  type: "PAGE",
+  columnGap: 20,
+  locators: [],
+  globals: [],
+  columns: []
+};
+
+// 可编辑栏
+const COLUMN = {
+  type: "COLUMN",
+  x: "",
+  y: "",
+  w: "",
+  h: "",
+  isFull: false, // 是否已经填满元素
+  elements: []
+};
+// 定位点
+const LOCATOR = {
+  type: "LOCATOR",
+  x: "",
+  y: "",
+  w: "",
+  h: ""
+};
+
+// card-head
+const CARD_HEAD_PROP = {
+  type: "CARD_HEAD",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 0,
+  schoolName: "",
+  cardName: "",
+  aOrB: true,
+  aOrBType: "fill", // fill:手动填涂,auto:自动条码
+  missAndFill: true,
+  writeSign: true,
+  examNumberType: "auto", // auto:自动条码, empty:手动条码, fill:手动填涂
+  businessParams: [],
+  noticeHead: [],
+  columnNumber: 2,
+  isSimple: false, // 是否是简化形式
+  sign: "head"
+};
+
+const TOPIC_HEAD_PROP = {
+  type: "TOPIC_HEAD",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 60,
+  content: "",
+  typeName: "",
+  sign: "objective" // objective:客观题,subjective:主观题
+};
+
+// editable element ------------------- >
+// 横线
+const LINE_HORIZONTAL_PROP = {
+  type: "LINE_HORIZONTAL",
+  x: 0,
+  y: 0,
+  w: 300,
+  h: 10,
+  sign: "",
+  bold: "1px",
+  color: "#000000",
+  style: "solid"
+};
+// 竖线
+const LINE_VERTICAL_PROP = {
+  type: "LINE_VERTICAL",
+  x: 0,
+  y: 0,
+  w: 10,
+  h: 300,
+  sign: "",
+  bold: "1px",
+  color: "#000000",
+  style: "solid"
+};
+
+// 方框
+// const RECT_PROP = {
+//   type: "RECT",
+//   x: 0,
+//   y: 0,
+//   w: 100,
+//   h: 100,
+//   sign: "",
+//   bold: "1px",
+//   backgroundColor: "",
+//   borderStyle: "solid"
+// };
+
+// 文本
+const TEXT_PROP = {
+  type: "TEXT",
+  x: 0,
+  y: 0,
+  w: 200,
+  h: 50,
+  sign: "",
+  fontWeight: 400,
+  fontFamily: "宋体",
+  fontSize: "14px",
+  color: "#000",
+  content: [
+    {
+      type: "text",
+      content: "样例内容"
+    }
+  ]
+};
+
+// 条形码
+// const BARCODE_PROP = {
+//   type: "BARCODE",
+//   x: 0,
+//   y: 0,
+//   w: 300,
+//   h: 60,
+//   sign: "",
+//   rotation: 0,
+//   bold: "",
+//   backgroundColor: "",
+//   borderStyle: "solid",
+//   content: []
+// };
+
+// 图片
+const IMAGE_PROP = {
+  type: "IMAGE",
+  x: 0,
+  y: 0,
+  w: 150,
+  h: 100,
+  sign: "",
+  borderColor: "",
+  borderStyle: "",
+  content: []
+};
+
+// 选择题
+const FILL_QUESTION_PROP = {
+  type: "FILL_QUESTION",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 230,
+  sign: "objective",
+  topicName: "",
+  startNumber: 1,
+  questionsCount: 10,
+  optionCount: 5,
+  questionCountPerGroup: 5,
+  optionDirection: "horizontal",
+  questionGap: 10,
+  groupGap: 10,
+  optionGap: 20,
+  isSelect: true,
+  isMultiply: false,
+  fontSize: "14px"
+};
+
+// 填涂框
+// const FILL_AREA_PROP = {
+//   type: "FILL_AREA",
+//   x: 0,
+//   y: 0,
+//   w: 100,
+//   h: 30,
+//   sign: "objective",
+//   optionCount: 2,
+//   optionDirection: "horizontal",
+//   optionGap: 20
+// };
+
+// 填空题
+const FILL_LINE_PROP = {
+  type: "FILL_LINE",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 200,
+  sign: "subjective",
+  topicName: "",
+  startNumber: 1,
+  questionsCount: 2,
+  questionNumberPerLine: 1,
+  lineNumberPerQuestion: 1,
+  numberPre: ""
+};
+
+// 解答题
+// 由多个小题组成,每个小题都是一种元件
+const EXPLAIN_PROP = {
+  type: "EXPLAIN",
+  sign: "subjective",
+  topicName: "",
+  startNumber: 1,
+  questionsCount: 1
+};
+// 解答题-小题
+const EXPLAIN_CHILDREN_PROP = {
+  type: "EXPLAIN_CHILDREN",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 481,
+  sign: "subjective",
+  // 小题序号
+  explainNumber: 0,
+  // 每一个解答题小题都可以包含其他基础元件,这些基础元件都用绝对定位
+  elements: [],
+  // 解答题整体信息,EXPLAIN_PROP
+  parent: {}
+};
+
+// 作文题
+const COMPOSITION_PROP = {
+  type: "COMPOSITION",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 350,
+  sign: "subjective",
+  topicName: "",
+  lineCount: 5,
+  // 每一个作文题都可以包含其他基础元件,但这些基础元件都用相对定位
+  elements: []
+};
+
+// available infos
+const EDITABLE_ELEMENT = {
+  LINE_HORIZONTAL_PROP,
+  LINE_VERTICAL_PROP,
+  TEXT_PROP,
+  IMAGE_PROP
+};
+
+const EDITABLE_TOPIC = {
+  FILL_QUESTION_PROP,
+  FILL_LINE_PROP,
+  EXPLAIN_PROP,
+  COMPOSITION_PROP
+};
+
+const ELEMENT_INFOS = {
+  LINE_HORIZONTAL: {
+    name: "横线",
+    icon: "el-icon-minus"
+  },
+  LINE_VERTICAL: {
+    name: "竖线",
+    icon: "el-icon-minus"
+  },
+  RECT: {
+    name: "方框",
+    icon: "el-icon-full-screen"
+  },
+  TEXT: {
+    name: "文本",
+    icon: "el-icon-tickets"
+  },
+  BARCODE: {
+    name: "条形码",
+    icon: "el-icon-c-scale-to-original"
+  },
+  IMAGE: {
+    name: "图片",
+    icon: "el-icon-picture-outline"
+  },
+  FILL_QUESTION: {
+    name: "客观题",
+    icon: "el-icon-s-operation"
+  },
+  FILL_AREA: {
+    name: "填涂框",
+    icon: "el-icon-edit-outline"
+  },
+  FILL_LINE: {
+    name: "填空题",
+    icon: "el-icon-edit-outline"
+  },
+  EXPLAIN: {
+    name: "解答题",
+    icon: "el-icon-edit-outline"
+  },
+  COMPOSITION: {
+    name: "作文题",
+    icon: "el-icon-edit-outline"
+  }
+};
+
+const ELEMENT_LIST = Object.values(EDITABLE_ELEMENT).map(option => {
+  return {
+    ...ELEMENT_INFOS[option.type],
+    type: option.type
+  };
+});
+
+const TOPIC_LIST = Object.values(EDITABLE_TOPIC).map(option => {
+  return {
+    ...ELEMENT_INFOS[option.type],
+    type: option.type
+  };
+});
+
+const getElementId = () => {
+  return `element-${randomCode()}`;
+};
+
+// 获取元件默认数据结构
+const getElementModel = type => {
+  const infos = deepCopy(EDITABLE_ELEMENT[`${type}_prop`.toUpperCase()]);
+  infos.id = getElementId();
+  return infos;
+};
+// 获取题型默认数据结构
+const getTopicModel = type => {
+  const infos = deepCopy(EDITABLE_TOPIC[`${type}_prop`.toUpperCase()]);
+  infos.id = getElementId();
+  return infos;
+};
+
+/**
+ *
+ * @param {Object} cardConfig 题卡配置信息
+ */
+const getCardHeadModel = cardConfig => {
+  const infos = Object.assign({}, CARD_HEAD_PROP, cardConfig);
+  infos.id = getElementId();
+  return infos;
+};
+
+/**
+ *
+ * @param {String} content
+ * @param {String} type objective:客观题,subjective:主观题
+ */
+const getTopicHead = (content, type) => {
+  const typeName = type === "objective" ? "客观题" : "主观题";
+  const element = deepCopy(TOPIC_HEAD_PROP);
+  element.sign = type;
+  element.typeName = typeName;
+  element.content = content;
+  element.id = getElementId();
+  return element;
+};
+
+/**
+ *
+ * @param {Object} explainModel 解答题model
+ */
+const getExplainChildren = explainModel => {
+  const parent = { ...explainModel };
+
+  let elements = [];
+
+  for (var i = 0; i < explainModel.questionsCount; i++) {
+    let child = deepCopy(EXPLAIN_CHILDREN_PROP);
+    child.id = getElementId();
+    child.w = document.getElementById("column-0-0").offsetWidth;
+    child.explainNumber = i + explainModel.startNumber;
+    child.parent = parent;
+
+    elements[i] = child;
+  }
+
+  return elements;
+};
+
+// 创建新页面
+const getNewPage = (pageNo, columnNumber = 2) => {
+  let npage = deepCopy(PAGE);
+  if (columnNumber === 4) {
+    npage.columnGap = 2;
+  }
+  npage.locators = getNumList(1).map((item, index) => {
+    return [
+      {
+        ...LOCATOR,
+        id: `locator-${pageNo}-${index}0`
+      },
+      {
+        ...LOCATOR,
+        id: `locator-${pageNo}-${index}1`
+      }
+    ];
+  });
+  npage.columns = getNumList(columnNumber).map((item, index) => {
+    return deepCopy(COLUMN);
+  });
+  return npage;
+};
+
+export {
+  getElementModel,
+  getCardHeadModel,
+  getNewPage,
+  getTopicHead,
+  getTopicModel,
+  getExplainChildren,
+  getElementId,
+  ELEMENT_LIST,
+  TOPIC_LIST
+};

+ 1 - 0
src/modules/card/enumerate.js

@@ -0,0 +1 @@
+export const APP_VERSION = "1.0.0";

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

@@ -0,0 +1,14 @@
+export default [
+  {
+    path: "/card/design/:cardId?",
+    name: "CardDesign",
+    component: () =>
+      import(/* webpackChunkName: "CardDesign" */ "./views/CardDesign.vue")
+  },
+  {
+    path: "/card/preview/:cardId/:viewType/:studentNo?",
+    name: "CardPreview",
+    component: () =>
+      import(/* webpackChunkName: "CardPreview" */ "./views/CardPreview.vue")
+  }
+];

+ 346 - 0
src/modules/card/store/index.js

@@ -0,0 +1,346 @@
+import {
+  getExplainChildren,
+  getNewPage,
+  getTopicHead,
+  getElementId
+} from "../elementModel";
+import { deepCopy, calcSum } from "@/plugins/utils";
+
+const state = {
+  curElement: {},
+  curDragElement: {},
+  curPageNo: 0,
+  pages: [],
+  cardConfig: {},
+  openElementEditDialog: false
+};
+
+const mutations = {
+  setCurElement(state, curElement) {
+    state.curElement = curElement;
+  },
+  setCurDragElement(state, curDragElement) {
+    state.curDragElement = curDragElement;
+  },
+  setPages(state, pages) {
+    state.pages = pages;
+  },
+  setCardConfig(state, cardConfig) {
+    state.cardConfig = Object.assign({}, state.cardConfig, cardConfig);
+  },
+  setCurPageNo(state, curPageNo) {
+    state.curPageNo = curPageNo;
+  },
+  addPage(state, page) {
+    state.pages.push(page);
+  },
+  modifyPage(state, page) {
+    state.pages.splice(page._pageNo, 1, page);
+  },
+  setOpenElementEditDialog(state, openElementEditDialog) {
+    state.openElementEditDialog = openElementEditDialog;
+  }
+};
+
+const fetchElementPositionInfos = (element, pages) => {
+  let postionInfos = [];
+  for (let i = 0, ilen = pages.length; i < ilen; i++) {
+    for (let j = 0, jlen = pages[i].columns.length; j < jlen; j++) {
+      pages[i].columns[j].elements.forEach((item, eindex) => {
+        const itemId =
+          item.type === "EXPLAIN" ? item.parent && item.parent.id : item.id;
+
+        if (itemId === element.id) {
+          postionInfos.push({ _pageNo: i, _columnNo: j, _elementNo: eindex });
+        }
+      });
+    }
+  }
+
+  return postionInfos;
+};
+
+const fetchFirstSubjectiveTopicPositionInfo = pages => {
+  for (let i = 0, ilen = pages.length; i < ilen; i++) {
+    for (let j = 0, jlen = pages[i].columns.length; j < jlen; j++) {
+      const index = pages[i].columns[j].elements.findIndex(item => {
+        return item.sign === "subjective";
+      });
+      if (index !== -1) return { _pageNo: i, _columnNo: j, _elementNo: index };
+    }
+  }
+};
+
+const fetchPageLastColumnPositionInfo = pages => {
+  return {
+    _pageNo: pages.length - 1,
+    _columnNo: pages[pages.length - 1].columns.length - 1
+  };
+};
+
+const findElementById = (id, pages) => {
+  let curElement = null;
+  pages.forEach(page => {
+    page.columns.forEach(column => {
+      column.elements.forEach(element => {
+        if (curElement) return;
+        if (element.id === id) {
+          curElement = element;
+          return;
+        }
+
+        if (element["elements"]) {
+          element["elements"].forEach(elem => {
+            if (element.id === id) curElement = elem;
+          });
+        }
+      });
+    });
+  });
+  return curElement;
+};
+
+const actions = {
+  modifyElement({ state, commit, dispatch }, element) {
+    // 解答题
+    if (element.type === "EXPLAIN") {
+      const positionInfos = fetchElementPositionInfos(element, state.pages);
+      if (positionInfos.length) {
+        // 删除所有解答题
+        positionInfos.forEach(pos => {
+          const elems =
+            state.pages[pos._pageNo].columns[pos._columnNo].elements;
+          elems.splice(pos._elementNo, 1);
+        });
+        // 创建新的解答题元素
+        const newElements = getExplainChildren(element);
+        const pos = positionInfos[0];
+        newElements.forEach(newElement => {
+          state.pages[pos._pageNo].columns[pos._columnNo].elements.splice(
+            pos._elementNo,
+            0,
+            newElement
+          );
+        });
+      } else {
+        dispatch("addElement", element);
+      }
+    } else {
+      const positionInfos = fetchElementPositionInfos(element, state.pages);
+      if (positionInfos.length) {
+        const pos = positionInfos[0];
+        const elements =
+          state.pages[pos._pageNo].columns[pos._columnNo].elements;
+        elements.splice(pos._elementNo, 1, element);
+      } else {
+        dispatch("addElement", element);
+      }
+    }
+    commit("setCurElement", element);
+  },
+  addElement({ state, commit }, element) {
+    let pos = null;
+    // 客观题和主观题分别对待
+    if (element.type === "FILL_QUESTION") {
+      pos =
+        fetchFirstSubjectiveTopicPositionInfo(state.pages) ||
+        fetchPageLastColumnPositionInfo(state.pages);
+    } else {
+      pos = fetchPageLastColumnPositionInfo(state.pages);
+    }
+
+    const elements = state.pages[pos._pageNo].columns[pos._columnNo].elements;
+    const preElements =
+      element.type === "EXPLAIN" ? getExplainChildren(element) : [element];
+    preElements.forEach(preElement => {
+      elements.push(preElement);
+    });
+
+    commit("setCurElement", element);
+  },
+  modifyCardHead({ state }, element) {
+    state.pages[0].columns[0].elements.splice(0, 1, element);
+  },
+  removeElement({ state, commit }, element) {
+    const positionInfos = fetchElementPositionInfos(element, state.pages);
+    if (!positionInfos.length) return;
+    positionInfos.forEach(pos => {
+      const elements = state.pages[pos._pageNo].columns[pos._columnNo].elements;
+      elements.splice(pos._elementNo, 1);
+    });
+
+    commit("setCurElement", {});
+  },
+  removeElementChild({ state, commit }, element) {
+    // 删除解答题小题和作文题的子元素
+    const positionInfos = fetchElementPositionInfos(
+      element.container,
+      state.pages
+    );
+
+    if (!positionInfos.length) return;
+    positionInfos.forEach(pos => {
+      const columnElement =
+        state.pages[pos._pageNo].columns[pos._columnNo].elements[
+          pos._elementNo
+        ];
+      const childIndex = columnElement.elements.findIndex(
+        item => item.id === element.id
+      );
+      columnElement.elements.splice(childIndex, 1);
+    });
+    commit("setCurElement", {});
+  },
+  modifyElementChild({ state, commit }, element) {
+    // 修改解答题小题和作文题的子元素
+    const positionInfos = fetchElementPositionInfos(
+      element.container,
+      state.pages
+    );
+    if (!positionInfos.length) return;
+    positionInfos.forEach(pos => {
+      const columnElement =
+        state.pages[pos._pageNo].columns[pos._columnNo].elements[
+          pos._elementNo
+        ];
+      const childIndex = columnElement.elements.findIndex(
+        item => item.id === element.id
+      );
+      columnElement.elements.splice(childIndex, 1, element);
+    });
+    commit("setCurElement", element);
+  },
+  rebuildPages({ state, commit }) {
+    const columnNumber = state.cardConfig.columnNumber;
+    // 更新元件最新的高度信息
+    // 整理所有元件
+    const objectiveElements = [];
+    const subjectiveElements = [];
+    const cardHeadElement = state.pages[0].columns[0].elements[0];
+    state.pages.forEach(page => {
+      page.columns.forEach(column => {
+        column.elements.forEach(element => {
+          const elementDom = document.getElementById(element.id);
+          element.h = elementDom.offsetHeight;
+          element.w = elementDom.offsetWidth;
+          // 过滤掉所有topic-head元素,这个元素是动态加的,页面重排时可能会添加重复元件。
+          if (element.sign && element.type !== "TOPIC_HEAD") {
+            if (element.sign === "objective") objectiveElements.push(element);
+            if (element.sign === "subjective") subjectiveElements.push(element);
+          }
+        });
+      });
+    });
+
+    // 动态计算每列可以分配的元件
+    const columnHeight = document.getElementById("column-0-0").offsetHeight;
+    const simpleCardHeadHeight = document.getElementById("simple-card-head")
+      .offsetHeight;
+    let pages = [];
+    let page = {};
+    let columns = [];
+    let curColumnElements = [];
+    let curColumnHeight = 0;
+
+    const initCurColumnElements = () => {
+      curColumnElements = [];
+      curColumnHeight = 0;
+      const groupColumnNumber = columnNumber * 2;
+      // 奇数页第一栏;
+      if (!(columns.length % groupColumnNumber)) {
+        // 非第一页奇数页第一栏
+        if (columns.length) {
+          const cardHeadSimpleElement = {
+            ...cardHeadElement
+          };
+          cardHeadSimpleElement.id = getElementId();
+          cardHeadSimpleElement.isSimple = true;
+          cardHeadSimpleElement.h = simpleCardHeadHeight;
+          curColumnElements.push(cardHeadSimpleElement);
+          curColumnHeight += simpleCardHeadHeight;
+        } else {
+          curColumnElements.push(cardHeadElement);
+          curColumnHeight += cardHeadElement.h;
+        }
+      }
+    };
+
+    const checkElementIsCurColumnFirstType = element => {
+      const topicHeadIndex = curColumnElements.findIndex(
+        elem => elem.type === "TOPIC_HEAD" && elem.sign === element.sign
+      );
+      return topicHeadIndex === -1;
+    };
+
+    // 放入元素通用流程
+    const pushElement = element => {
+      // 当前栏中第一个题型之前新增题型头元素(topic-head)。
+      // 题型头和当前题要组合加入栏中,不可拆分。
+      let elementList = [element];
+      if (checkElementIsCurColumnFirstType(element)) {
+        elementList.unshift(
+          getTopicHead(state.cardConfig[`${element.sign}Notice`], element.sign)
+        );
+      }
+
+      const elementHeight = calcSum(elementList.map(elem => elem.h));
+      if (curColumnHeight + elementHeight > columnHeight) {
+        // 当前栏第一个元素高度超过一栏时,不拆分,直接放在当前栏。
+        // 解决可能空栏的情况
+        const curElementIsFirst = !curColumnElements.length;
+        if (curElementIsFirst) {
+          curColumnElements = [...curColumnElements, ...elementList];
+          curColumnHeight += elementHeight;
+        } else {
+          columns.push([...curColumnElements]);
+          initCurColumnElements();
+          pushElement(element);
+        }
+      } else {
+        curColumnElements = [...curColumnElements, ...elementList];
+        curColumnHeight += elementHeight;
+      }
+    };
+
+    // 批量添加所有元素。
+    initCurColumnElements();
+    [...objectiveElements, ...subjectiveElements].map((element, eindex) => {
+      element.serialNo = eindex;
+      pushElement(element);
+    });
+
+    // 最后一栏的处理。
+    columns.push([...curColumnElements]);
+    // 构建pages
+    columns.forEach((column, cindex) => {
+      const columnNo = cindex % columnNumber;
+      if (!columnNo) {
+        page = getNewPage(pages.length, columnNumber);
+      }
+      page.columns[columnNo].elements = column;
+
+      if (columnNo + 1 === columnNumber || cindex === columns.length - 1) {
+        pages.push(deepCopy(page));
+      }
+    });
+    // 保证页面总是偶数页
+    if (pages.length % 2) {
+      pages.push(getNewPage(pages.length, columnNumber));
+    }
+
+    commit("setPages", pages);
+  },
+  actElementById({ state, commit }, id) {
+    const curElement = findElementById(id, state.pages);
+    if (!curElement) return;
+
+    commit("setCurElement", curElement);
+  }
+};
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+};

+ 385 - 0
src/modules/card/views/CardDesign.vue

@@ -0,0 +1,385 @@
+<template>
+  <div class="card-design">
+    <div class="design-top">
+      <div class="design-top-logo">
+        <h1><i class="icon icon-back" @click="goback"></i>答题卡制作</h1>
+      </div>
+      <div class="design-top-info">
+        <div class="info-help"><i class="icon icon-help"></i>帮助</div>
+      </div>
+    </div>
+
+    <div class="design-main">
+      <!-- menus -->
+      <div class="design-head">
+        <div class="design-steps">
+          <div class="step-item" v-for="(step, index) in steps" :key="index">
+            <i>{{ index + 1 }}</i>
+            <span>{{ step }}</span>
+          </div>
+        </div>
+        <div class="design-control">
+          <div class="control-right">
+            <el-button
+              class="btn-white"
+              @click="toPreview"
+              :disabled="!pages.length"
+              >预览</el-button
+            >
+            <save-page
+              :card-id="cardId"
+              @saved="cardSaved"
+              ref="SavePage"
+            ></save-page>
+          </div>
+          <div class="control-left">
+            <el-button
+              v-for="(page, pageNo) in pages"
+              :key="pageNo"
+              :class="{ 'btn-white': curPageNo === pageNo }"
+              @click="swithPage(pageNo)"
+              >第{{ pageNo + 1 }}页</el-button
+            >
+          </div>
+        </div>
+      </div>
+
+      <!-- actions -->
+      <div class="design-action">
+        <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">
+            <div class="type-list">
+              <div
+                class="type-item"
+                v-for="(item, index) in TOPIC_LIST"
+                :key="index"
+              >
+                <el-button @click="addNewTopic(item)"
+                  ><i class="el-icon-plus"></i>{{ item.name }}</el-button
+                >
+              </div>
+            </div>
+          </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>
+            </div>
+          </div>
+          <!-- Develop btns -->
+          <card-config-prop-edit></card-config-prop-edit>
+          <br /><br />
+          <el-button @click="initCard">新建页面</el-button>
+        </div>
+      </div>
+
+      <!-- edit body -->
+      <div class="design-body">
+        <template v-for="(page, pageNo) in pages">
+          <div
+            :class="['page-box', `page-box-${pageNo % 2}`]"
+            v-if="curPageNo === pageNo"
+            :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-edit
+                        class="page-column-element"
+                        v-for="element in column.elements"
+                        :key="element.id"
+                        :data="element"
+                      ></topic-element-edit>
+                    </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"></div>
+          </div>
+        </template>
+      </div>
+
+      <!-- design other pages -->
+      <div class="design-other-pages">
+        <template v-for="(page, pageNo) in pages">
+          <div
+            :class="['page-box', `page-box-${pageNo % 2}`]"
+            v-if="curPageNo !== pageNo"
+            :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">
+                      <topic-element-edit
+                        class="page-column-element"
+                        v-for="(element, eindex) in column.elements"
+                        :key="eindex"
+                        :data="element"
+                      ></topic-element-edit>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </template>
+      </div>
+
+      <!-- element-prop-edit -->
+      <element-prop-edit></element-prop-edit>
+      <!-- right-click-menu -->
+      <right-click-menu></right-click-menu>
+      <!-- card-head-sample -->
+      <card-head-sample v-if="pages.length"></card-head-sample>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations, mapActions } from "vuex";
+import { cardConfigInfos } from "../api";
+import {
+  getElementModel,
+  getCardHeadModel,
+  getNewPage,
+  getTopicModel,
+  ELEMENT_LIST,
+  TOPIC_LIST
+} from "../elementModel";
+import TopicElementEdit from "../components/TopicElementEdit";
+import PagePropEdit from "../components/PagePropEdit";
+import CardConfigPropEdit from "../components/CardConfigPropEdit";
+import ElementPropEdit from "../components/elementPropEdit/ElementPropEdit";
+import RightClickMenu from "../components/RightClickMenu";
+import CardHeadSample from "../components/elementEdit/CardHeadSample";
+import SavePage from "../components/SavePage";
+import card from "../card.temp.json";
+
+export default {
+  name: "card-design",
+  components: {
+    TopicElementEdit,
+    PagePropEdit,
+    CardConfigPropEdit,
+    ElementPropEdit,
+    RightClickMenu,
+    CardHeadSample,
+    SavePage
+  },
+  data() {
+    return {
+      cardId: this.$route.params.cardId,
+      ELEMENT_LIST,
+      TOPIC_LIST,
+      steps: ["添加标题", "基本设置", "试题配置", "试题配置"],
+      columnWidth: 0
+    };
+  },
+  computed: {
+    ...mapState("card", ["cardConfig", "pages", "curElement", "curPageNo"]),
+    isEdit() {
+      return !!this.cardId;
+    }
+  },
+  mounted() {
+    // this.initCard();
+    this.$store.commit("card/setPages", card.pages);
+    this.$store.commit("card/setCardConfig", card.cardConfig);
+  },
+  methods: {
+    ...mapMutations("card", [
+      "addPage",
+      "setCurPageNo",
+      "setCurElement",
+      "setCardConfig",
+      "setOpenElementEditDialog",
+      "setCurDragElement"
+    ]),
+    ...mapActions("card", [
+      "removePage",
+      "addElement",
+      "modifyCardHead",
+      "modifyElement",
+      "rebuildPages"
+    ]),
+    async initCard() {
+      if (this.isEdit) {
+        // TODO:编辑页复现
+      } else {
+        await this.getCardConfig();
+        this.initPageData();
+      }
+      this.addWatch();
+    },
+    initPageData() {
+      this.addNewPage();
+      this.addNewPage();
+
+      this.$nextTick(() => {
+        this.modifyCardHead({
+          ...getCardHeadModel(this.cardConfig)
+        });
+      });
+    },
+    async getCardConfig() {
+      const data = await cardConfigInfos();
+      this.setCardConfig({
+        ...data,
+        pageSize: "A3",
+        columnNumber: 2,
+        columnGap: 20,
+        aOrB: !!data["aOrBSystem"],
+        showForbidArea: true,
+        cardName: ""
+      });
+    },
+    addNewTopic(item) {
+      let element = getTopicModel(item.type);
+      element.w = document.getElementById("column-0-0").offsetWidth;
+      this.setCurElement(element);
+      this.setOpenElementEditDialog(true);
+      // to elementPropEdit/ElementPropEdit open topic edit dialog
+    },
+    addNewPage() {
+      const page = getNewPage(this.pages.length, this.cardConfig.columnNumber);
+      this.addPage(page);
+    },
+    // 元件编辑
+    dragstart(element) {
+      this.setCurDragElement(getElementModel(element.type));
+    },
+    // 操作
+    cardSaved(cardId) {
+      this.cardId = cardId;
+    },
+    async toPreview() {
+      this.$router.push({
+        name: "CardPreview",
+        params: {
+          cardId: 1,
+          viewType: "view"
+        }
+      });
+
+      return;
+
+      // const card = await this.$refs.SavePage.save();
+      // this.cardSaved(card.id);
+      // this.$router.push({
+      //   name: "CardPreview",
+      //   params: {
+      //     cardId: card.id,
+      //     viewType: "view"
+      //   }
+      // });
+    },
+    swithPage(pindex) {
+      if (this.curPageNo === pindex) return;
+      this.setCurPageNo(pindex);
+      this.setCurElement({});
+    },
+    addWatch() {
+      this.$watch("cardConfig", val => {
+        const element = getCardHeadModel(val);
+        this.modifyCardHead(element);
+        this.$nextTick(() => {
+          this.rebuildPages();
+        });
+      });
+    }
+  }
+};
+</script>

+ 161 - 0
src/modules/card/views/CardPreview.vue

@@ -0,0 +1,161 @@
+<template>
+  <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-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"></div>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script>
+import TopicElementPreview from "../components/TopicElementPreview";
+import { cardDetail, cardStudentInfo } from "../api";
+const JsBarcode = require("jsbarcode");
+
+export default {
+  name: "card-preview",
+  components: { TopicElementPreview },
+  data() {
+    return {
+      isPrint: this.$route.params.viewType === "print",
+      studentNo: this.$route.params.studentNo,
+      cardId: this.$route.params.cardId,
+      cardConfig: {},
+      pages: []
+    };
+  },
+  computed: {
+    classes() {
+      return [
+        "card-preview",
+        {
+          "card-print": this.isPrint
+        }
+      ];
+    }
+  },
+  mounted() {
+    this.init();
+  },
+  methods: {
+    async init() {
+      let pages = this.$store.state.card.pages;
+      let cardConfig = this.$store.state.card.cardConfig;
+      const fieldInfos = await this.fetchFieldInfos(cardConfig, {});
+
+      this.cardConfig = cardConfig;
+      this.pages = this.appendFieldInfo(pages, fieldInfos);
+    },
+    async init1() {
+      const card = await cardDetail(this.cardId);
+      let stdInfo = {};
+      if (this.studentNo) {
+        stdInfo = await cardStudentInfo({
+          cardId: this.cardId,
+          studentNo: this.studentNo
+        });
+      }
+      const { cardConfig, pages } = JSON.parse(card.template);
+      const fieldInfos = this.fetchFieldInfos(cardConfig, stdInfo);
+
+      this.cardConfig = cardConfig;
+      this.pages = this.appendFieldInfo(pages, fieldInfos);
+    },
+    fetchFieldInfos(cardConfig, stdInfo) {
+      let fieldInfos = {};
+      const defContent = "相关信息";
+      const defNumber = "123456789";
+      cardConfig.businessParams.map(item => {
+        fieldInfos[item.field] = stdInfo[item.field] || defContent;
+      });
+      if (cardConfig.examNumberType === "auto")
+        fieldInfos.examNumber = this.getBase64Barcode(
+          stdInfo["examNumber"] || defNumber
+        );
+      if (cardConfig.aOrB && cardConfig.aOrBType === "auto")
+        fieldInfos.paperType = this.getBase64Barcode(
+          stdInfo["paperType"] || defNumber
+        );
+
+      return fieldInfos;
+    },
+    getBase64Barcode(str) {
+      const canvas = document.createElement("CANVAS");
+      JsBarcode(canvas, str, {
+        width: 2,
+        height: 60,
+        displayValue: false,
+        margin: 0
+      });
+
+      return canvas.toDataURL();
+    },
+    appendFieldInfo(pages, fieldInfos) {
+      pages.forEach((page, pageNo) => {
+        if (pageNo % 2) return;
+        const cardHeadElement = page.columns[0].elements[0];
+
+        if (cardHeadElement.type === "CARD_HEAD") {
+          cardHeadElement.fieldInfos = fieldInfos;
+        }
+      });
+      return pages;
+    }
+  }
+};
+</script>

+ 6 - 1
src/modules/exam-center/views/CardManage.vue

@@ -38,7 +38,7 @@
           <el-button type="primary" icon="icon icon-search" @click="toPage(1)"
             >查询</el-button
           >
-          <el-button type="warning" icon="icon icon-plus" @click="toPage(1)"
+          <el-button type="warning" icon="icon icon-plus" @click="toAdd"
             >创建题卡</el-button
           >
         </el-form-item>
@@ -172,6 +172,11 @@ export default {
         };
       });
     },
+    toAdd() {
+      this.$router.push({
+        name: "CardDesign"
+      });
+    },
     toEdit(row) {
       this.curExam = row;
       this.$refs.ModifyData.open();

+ 1 - 1
src/plugins/mixins.js

@@ -8,7 +8,7 @@ export default {
       this.toPage && this.toPage(page);
     },
     goback() {
-      window.history.go(-1);
+      window.history.back();
     }
   }
 };

+ 11 - 4
src/plugins/utils.js

@@ -25,11 +25,9 @@ function objTypeOf(obj) {
  * 深拷贝
  * @param {Object/Array} data 需要拷贝的数据
  */
-function deepCopy(data) {
+function deepCopy(data, options) {
   const defObj = objTypeOf(data) === "array" ? [] : {};
-  return deepmerge(defObj, data, {
-    arrayMerge: (destinationArray, sourceArray, options) => sourceArray
-  });
+  return deepmerge(defObj, data, options || {});
 }
 
 /**
@@ -188,6 +186,14 @@ function localNowDateTime() {
   return formatDate("YYYY年MM月DD日HH时mm分ss秒");
 }
 
+/**
+ * 获取指定元素个数的数组
+ * @param {Number} num
+ */
+function getNumList(num) {
+  return "#".repeat(num).split("");
+}
+
 /**
  * 清除html标签
  * @param {String} str html字符串
@@ -216,6 +222,7 @@ export {
   qsParams,
   formatDate,
   localNowDateTime,
+  getNumList,
   removeHtmlTag,
   calcSum
 };

+ 3 - 1
src/router.js

@@ -9,6 +9,7 @@ import base from "./modules/base/router";
 import examCenter from "./modules/exam-center/router";
 import scorePaper from "./modules/score-paper/router";
 import analyze from "./modules/analyze/router";
+import card from "./modules/card/router";
 
 Vue.use(Router);
 
@@ -26,7 +27,8 @@ export default new Router({
       redirect: { name: "WaitTask" },
       children: [...account, ...base, ...examCenter, ...scorePaper, ...analyze]
     },
-    { ...login }
+    { ...login },
+    ...card
     // [lazy-loaded] route level code-splitting
     // {
     //   path: "/about",

+ 2 - 2
src/store.js

@@ -4,7 +4,7 @@ import Vuex from "vuex";
 Vue.use(Vuex);
 
 // modules
-// import account from "./modules/account/store";
+import card from "./modules/card/store";
 
 export default new Vuex.Store({
   state: {
@@ -17,6 +17,6 @@ export default new Vuex.Store({
   },
   actions: {},
   modules: {
-    // account
+    card
   }
 });

+ 5 - 0
yarn.lock

@@ -5050,6 +5050,11 @@ js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.9.1:
     argparse "^1.0.7"
     esprima "^4.0.0"
 
+jsbarcode@^3.11.0:
+  version "3.11.0"
+  resolved "https://registry.npm.taobao.org/jsbarcode/download/jsbarcode-3.11.0.tgz#20623e008b101ef45d0cce9c8022cdf49be28547"
+  integrity sha1-IGI+AIsQHvRdDM6cgCLN9JvihUc=
+
 jsbn@~0.1.0:
   version "0.1.1"
   resolved "https://registry.npm.taobao.org/jsbn/download/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"