Ver código fonte

card part mod

zhangjie 4 anos atrás
pai
commit
b025b77e69
100 arquivos alterados com 4836 adições e 725 exclusões
  1. 5 0
      card/App.vue
  2. 97 0
      card/INTRODUCTION.md
  3. 67 0
      card/api.js
  4. 0 0
      card/assets/images/barcode-sample-notext.png
  5. 0 0
      card/assets/images/icon-back.png
  6. BIN
      card/assets/images/icon-checked.png
  7. BIN
      card/assets/images/icon-close-act.png
  8. BIN
      card/assets/images/icon-close.png
  9. BIN
      card/assets/images/icon-doubt.png
  10. 0 0
      card/assets/images/icon-four-gray.png
  11. 0 0
      card/assets/images/icon-four-white.png
  12. 0 0
      card/assets/images/icon-help.png
  13. BIN
      card/assets/images/icon-radio-checked.png
  14. 0 0
      card/assets/images/icon-three-gray.png
  15. 0 0
      card/assets/images/icon-three-white.png
  16. 0 0
      card/assets/images/icon-two-gray.png
  17. 0 0
      card/assets/images/icon-two-white.png
  18. BIN
      card/assets/logo.png
  19. 163 0
      card/assets/styles/base.scss
  20. 136 60
      card/assets/styles/card-design.scss
  21. 200 61
      card/assets/styles/card-preview.scss
  22. 478 0
      card/assets/styles/element-ui-costom.scss
  23. 223 0
      card/assets/styles/home.scss
  24. 59 0
      card/assets/styles/icons.scss
  25. 10 0
      card/assets/styles/index.scss
  26. 5 0
      card/assets/styles/module.scss
  27. 37 0
      card/assets/styles/variables.scss
  28. 0 0
      card/card.temp.json
  29. 4 3
      card/components/CardConfigPropEdit.vue
  30. 107 0
      card/components/ElementPropEdit.vue
  31. 0 0
      card/components/PageNumber.vue
  32. 8 5
      card/components/PagePropEdit.vue
  33. 9 7
      card/components/PaperParams.vue
  34. 313 0
      card/components/RightClickMenu.vue
  35. 19 12
      card/components/TopicElementEdit.vue
  36. 13 8
      card/components/TopicElementPreview.vue
  37. 66 0
      card/components/TopicSelectDialog.vue
  38. 149 0
      card/components/UploadButton.vue
  39. 0 0
      card/components/common/ColorSelect.vue
  40. 0 0
      card/components/common/DirectionSelect.vue
  41. 120 63
      card/components/common/ElementResize.vue
  42. 0 0
      card/components/common/FontFamilySelect.vue
  43. 0 0
      card/components/common/LineStyleSelect.vue
  44. 0 0
      card/components/common/LineWidthSelect.vue
  45. 0 0
      card/components/common/PopoverButton.vue
  46. 0 0
      card/components/common/RotationSelect.vue
  47. 0 0
      card/components/common/SizeSelect.vue
  48. 0 0
      card/components/common/TopicNumber.vue
  49. 0 0
      card/directives/move-ele.js
  50. 170 0
      card/elementModel.js
  51. 31 6
      card/elements/card-head/CardHead.vue
  52. 0 0
      card/elements/card-head/CardHeadBodyAutoResize.vue
  53. 0 0
      card/elements/card-head/CardHeadSample.vue
  54. 2 2
      card/elements/card-head/cardHeadSpin/HeadDynamic.vue
  55. 0 0
      card/elements/card-head/cardHeadSpin/HeadNotice.vue
  56. 0 0
      card/elements/card-head/cardHeadSpin/HeadStdinfo.vue
  57. 1 1
      card/elements/card-head/cardHeadSpin/HeadStdno.vue
  58. 29 0
      card/elements/card-head/model.js
  59. 70 0
      card/elements/composition/EditComposition.vue
  60. 45 0
      card/elements/composition/ElemComposition.vue
  61. 133 0
      card/elements/composition/ElemCompositionEdit.vue
  62. 57 0
      card/elements/composition/ElemCompositionElement.vue
  63. 39 15
      card/elements/composition/ElemCompositionElementEdit.vue
  64. 67 0
      card/elements/composition/model.js
  65. 5 78
      card/elements/explain/EditExplain.vue
  66. 12 7
      card/elements/explain/ElemExplain.vue
  67. 54 26
      card/elements/explain/ElemExplainEdit.vue
  68. 55 0
      card/elements/explain/ElemExplainElement.vue
  69. 114 0
      card/elements/explain/ElemExplainElementEdit.vue
  70. 65 0
      card/elements/explain/model.js
  71. 108 87
      card/elements/fill-line/EditFillLine.vue
  72. 80 0
      card/elements/fill-line/ElemFillLine.vue
  73. 103 0
      card/elements/fill-line/model.js
  74. 100 81
      card/elements/fill-question/EditFillQuestion.vue
  75. 29 18
      card/elements/fill-question/ElemFillQuestion.vue
  76. 85 0
      card/elements/fill-question/model.js
  77. 105 0
      card/elements/grids/EditGrids.vue
  78. 63 0
      card/elements/grids/ElemGrids.vue
  79. 25 0
      card/elements/grids/model.js
  80. 7 33
      card/elements/image/EditImage.vue
  81. 0 0
      card/elements/image/ElemImage.vue
  82. 22 0
      card/elements/image/model.js
  83. 8 34
      card/elements/line/EditLine.vue
  84. 43 0
      card/elements/line/ElemLine.vue
  85. 35 0
      card/elements/line/model.js
  86. 98 0
      card/elements/lines/EditLines.vue
  87. 14 3
      card/elements/lines/ElemLines.vue
  88. 25 0
      card/elements/lines/model.js
  89. 8 35
      card/elements/text/EditText.vue
  90. 0 0
      card/elements/text/ElemText.vue
  91. 29 0
      card/elements/text/model.js
  92. 0 0
      card/elements/topic-head/TopicHead.vue
  93. 27 0
      card/elements/topic-head/model.js
  94. 63 0
      card/enumerate.js
  95. 20 0
      card/main.js
  96. 21 80
      card/mixins/exchange.js
  97. 192 0
      card/mixins/guideLines.js
  98. 121 0
      card/plugins/ajax.js
  99. 23 0
      card/plugins/md5.js
  100. 145 0
      card/plugins/utils.js

+ 5 - 0
card/App.vue

@@ -0,0 +1,5 @@
+<template>
+  <div id="app">
+    <router-view />
+  </div>
+</template>

+ 97 - 0
card/INTRODUCTION.md

@@ -0,0 +1,97 @@
+# 功能清单
+
+## v0.2.0
+
+#### 填空题
+
+- 行间距需要可调。
+- 每个小题空数不同。(两种编辑模式:纵向模式和横向模式)
+- 自定义小题数量,与标准小题数量。
+
+#### 选择题
+
+- 小题可选择“横向排列”、“纵向排列”。
+- 已 4×4 为标准展示样例。
+- 判断题:提供是非的文本配置,默认“√”与“×”。
+
+#### 解答题
+
+- 小题内的元素可复制,也可复制到其他小题。
+- 元素移动是新增辅助线。
+- 题卡制作时,同栏子答题区之间的横线给虚线。预览时,不显示虚线。
+
+#### 作文题
+
+- 转变成如解答题一样的处理方式。
+- 文字,图案和排线可单独插入。可以新增扩展区域。
+
+#### 其他
+
+- 优化右键菜单。
+- 新增多横线元素。
+- 高度不够时及时提醒。
+- 客观题和主观题改变大题顺序。
+- 在当前大题下新建大题。
+- 所有试题元素新增最小高度字段。
+
+## v0.1.0
+
+### 主功能
+
+- 基础设置:纸质规格 A3,栏位布局,启用 A/B 卷,启用答题区,已建题号(只展示)。
+- 试题配置:选择题,填空题,解答题,作文题。
+- 插入元素:横线,竖线,文本,图片。
+- 配置题卡设置。
+- 导出卡格式。
+
+### 试题功能
+
+#### 选择题
+
+- 题目名称,起止题号,选项个数,判断题,多选题。
+- 分组拆分展示。
+
+#### 填空题
+
+- 题目名称,起止题号,每行空数,每空行数,题号前缀。
+- 分组拆分展示。
+
+#### 解答题
+
+- 题目名称,起止题号。
+- 小题内可插入元素。
+- 小题可以新增额外答题区,新增的答题区不展示题号。
+- 只有一个小题的解答题,不展示题号。
+
+#### 作文题
+
+- 题目名称,作文行数。
+- 可插入文本和图片,不能左右移动,可调整高度。
+
+### 卡头功能
+
+- 标题处可编辑学校名称,题卡名称以及副标题信息。
+- 条码区支持自动条码,手动条码和手动填涂。
+- 个人信息区支持动态配置字段。
+- 注意事项支持动态编辑。
+- 其他功能区:手写签名,缺考标记,试卷类型。
+- 在填空非第一页的奇数页会展示简易卡头。
+- 卡头在不同栏位布局中展示有差异,三栏和四栏布局保持一致。
+
+### 题型头
+
+- 客观题和主观题首题前需要添加提醒头。
+- 栏位顶部需要添加题型头,如果有卡头,则添加在卡头之下。
+- 客观题始终在主观题前面。
+
+### 元素功能
+
+- 横竖线:线长,样式,颜色,粗细。
+- 文字:内容,字体,字号,加粗,颜色。
+- 图片:边框颜色,边框形状,上传图片。
+
+### 其他功能
+
+- 暂存时不生产卡格式。
+- 提交时,生产卡格式,后台题卡模板。
+- 预览页支持路由预览和 frame 预览,题卡提交会使用 frame 预览。

+ 67 - 0
card/api.js

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

+ 0 - 0
src/assets/images/barcode-sample-notext.png → card/assets/images/barcode-sample-notext.png


+ 0 - 0
src/assets/images/icon-back.png → card/assets/images/icon-back.png


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


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


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


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


+ 0 - 0
src/assets/images/icon-four-gray.png → card/assets/images/icon-four-gray.png


+ 0 - 0
src/assets/images/icon-four-white.png → card/assets/images/icon-four-white.png


+ 0 - 0
src/assets/images/icon-help.png → card/assets/images/icon-help.png


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


+ 0 - 0
src/assets/images/icon-three-gray.png → card/assets/images/icon-three-gray.png


+ 0 - 0
src/assets/images/icon-three-white.png → card/assets/images/icon-three-white.png


+ 0 - 0
src/assets/images/icon-two-gray.png → card/assets/images/icon-two-gray.png


+ 0 - 0
src/assets/images/icon-two-white.png → card/assets/images/icon-two-white.png


BIN
card/assets/logo.png


+ 163 - 0
card/assets/styles/base.scss

@@ -0,0 +1,163 @@
+/* reset */
+body,
+div,
+ul,
+ol,
+li,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+input,
+p,
+tr,
+th,
+td,
+span,
+a,
+header,
+footer {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  -webkit-tap-highlight-color: rgba(255, 255, 255, 0);
+}
+li {
+  list-style: none;
+}
+em,
+i,
+u {
+  font-style: normal;
+}
+input {
+  outline: none;
+  border: none;
+  box-shadow: 100px 100px 100px #fff inset;
+  font-family: $--font-family;
+}
+button {
+  font-family: $--font-family;
+}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  font-size: 100%;
+  font-weight: bold;
+}
+fieldset,
+img {
+  border: 0;
+}
+abbr {
+  border: 0;
+  font-variant: normal;
+}
+a {
+  text-decoration: none;
+  color: inherit;
+  *color: $--color-text-secondary;
+}
+img {
+  vertical-align: middle;
+}
+
+/* common-style */
+input:-webkit-autofill {
+  box-shadow: 0 0 0 1000px white inset;
+}
+input[type="text"]:focus,
+input[type="password"]:focus,
+input[type="number"]:focus,
+textarea:focus {
+  box-shadow: 0 0 0 1000px white inset;
+}
+
+/* browse style */
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+  background: transparent;
+}
+::-webkit-scrollbar-button {
+  display: none;
+}
+::-webkit-scrollbar-track {
+  background: transparent;
+}
+::-webkit-scrollbar-thumb {
+  border-radius: 8px;
+  background: $--color-text-primary;
+}
+::-webkit-scrollbar-corner {
+  background: transparent;
+}
+::-webkit-scrollbar-resizer {
+  background: transparent;
+}
+
+body {
+  font-family: $--font-family;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  font-size: $--font-size-base;
+  color: $--color-text-primary;
+  background: $--color-background;
+  min-width: 1366px;
+}
+// color
+.color-primary {
+  color: $--color-primary !important;
+}
+.color-success {
+  color: $--color-success;
+}
+.color-warning {
+  color: $--color-warning;
+}
+.color-danger {
+  color: $--color-danger;
+}
+.color-info {
+  color: $--color-info;
+}
+// text
+.text-center {
+  text-align: center;
+}
+
+// other
+.btn--danger {
+  &.el-button--text {
+    color: $--color-danger !important;
+  }
+}
+.btn-white {
+  background-color: #fff !important;
+  color: #999 !important;
+}
+.font-bold {
+  font-weight: bold;
+}
+.table-head-bg {
+  th {
+    background-color: #f6f6f6;
+    color: $--color-text-primary;
+  }
+}
+.btn-act {
+  background: rgba(28, 208, 161, 1) !important;
+  box-shadow: 5px 5px 4px 0px rgba(28, 208, 161, 0.3);
+  border-radius: 10px;
+}
+.mr-1 {
+  margin-right: 5px;
+}
+.mr-2 {
+  margin-right: 10px;
+}

+ 136 - 60
src/assets/styles/card-design.scss → card/assets/styles/card-design.scss

@@ -116,6 +116,37 @@
         }
       }
     }
+    .element-resize-compact {
+      > .resize-control {
+        > .control-line {
+          display: block;
+        }
+      }
+
+      &:hover {
+        > .resize-control {
+          > .control-line {
+            border-color: #4794b3;
+          }
+        }
+      }
+
+      &.element-resize-act {
+        > .resize-control {
+          > .control-line {
+            border-color: #4794b3;
+            &-left,
+            &-right {
+              border-left-style: solid;
+            }
+            &-top,
+            &-bottom {
+              border-top-style: solid;
+            }
+          }
+        }
+      }
+    }
 
     .element-item-topic-number {
       position: absolute;
@@ -131,6 +162,31 @@
       color: #fff;
       border-radius: 50% 0 50% 50%;
     }
+    // 编辑时,小题扩展答题区之间用虚线
+    .element-item {
+      &-explain {
+        &::before {
+          border-bottom: 1px dashed #333;
+        }
+      }
+      &-type-last {
+        &::before {
+          border-bottom: 1px solid #333;
+        }
+      }
+    }
+    &:last-child {
+      .element-item {
+        &::before {
+          border-bottom: 1px solid #333;
+        }
+      }
+      .element-item-card-head.element-item-type-pre {
+        &::before {
+          border-bottom: none;
+        }
+      }
+    }
   }
 
   // page-main-outer
@@ -272,7 +328,7 @@
       border-radius: 50%;
       border: 2px solid $--color-primary;
       color: $--color-primary;
-      line-height: 24px;
+      line-height: 28px;
       font-weight: bold;
       z-index: 8;
     }
@@ -398,6 +454,22 @@
   padding: 180px 30px 30px 300px;
 }
 
+// topic-list
+.topic-list {
+  position: absolute;
+  top: -2000px;
+  left: -5000px;
+  z-index: auto;
+  visibility: hidden;
+  .element-item-card-head {
+    width: 100% !important;
+    height: auto !important;
+  }
+  .page-main-inner {
+    overflow: hidden;
+  }
+}
+
 // tool-tips
 .tool-tips {
   color: #999;
@@ -463,32 +535,27 @@
     }
   }
 }
-//
-// explain-children-element
-.composition-element {
-  > .element-resize {
-    width: 100% !important;
-  }
-}
 
 // right-menu-body
 .right-menu-body {
-  width: 62px;
   border: 1px solid #cccccc;
-  border-radius: 10px;
+  border-radius: 5px;
   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;
+    padding: 6px;
     font-weight: 400;
     cursor: pointer;
 
     &:hover {
       background-color: #f6f6f6;
+      color: $--color-primary;
+    }
+    &.li-danger:hover {
+      color: $--color-danger;
     }
 
     &:not(:last-child) {
@@ -496,13 +563,6 @@
     }
   }
 }
-.right-click-popper {
-  box-shadow: none;
-  border: none;
-  background-color: transparent;
-  padding: 0;
-  min-width: 0;
-}
 
 // design-preview-frame
 .design-preview-frame {
@@ -600,6 +660,63 @@
   }
 }
 
+// paper-params
+.paper-params {
+  .params-dialog-title {
+    font-size: 16px;
+    > span {
+      font-size: 14px;
+      margin-left: 24px;
+      color: $--color-text-regular;
+    }
+  }
+  .params-main {
+    margin-top: -10px;
+    padding: 0 10px;
+  }
+  .params-head {
+    margin-bottom: 20px;
+  }
+  .params-part {
+    margin: 15px 0;
+  }
+  .params-title {
+    margin-bottom: 10px;
+  }
+  .params-subtitle {
+    margin-bottom: 10px;
+    > span {
+      display: inline-block;
+      vertical-align: middle;
+      &:first-child {
+        margin-right: 20px;
+      }
+    }
+    .el-input-number {
+      width: 60px;
+      margin: 0 5px;
+    }
+  }
+  .param-sum-score {
+    color: $--color-danger;
+  }
+}
+
+// element-guide-lines
+.element-guide-lines {
+  .guide-line {
+    position: absolute;
+    z-index: 999;
+
+    &-x {
+      border-bottom: 1px solid $--color-success;
+    }
+    &-y {
+      border-left: 1px solid $--color-success;
+    }
+  }
+}
+
 @media screen and (max-width: 1600px) {
   .card-design {
     .design-top {
@@ -663,44 +780,3 @@
     }
   }
 }
-// paper-params
-.paper-params {
-  .params-dialog-title {
-    font-size: 16px;
-    > span {
-      font-size: 14px;
-      margin-left: 24px;
-      color: $--color-text-regular;
-    }
-  }
-  .params-main {
-    margin-top: -10px;
-    padding: 0 10px;
-  }
-  .params-head {
-    margin-bottom: 20px;
-  }
-  .params-part {
-    margin: 15px 0;
-  }
-  .params-title {
-    margin-bottom: 10px;
-  }
-  .params-subtitle {
-    margin-bottom: 10px;
-    > span {
-      display: inline-block;
-      vertical-align: middle;
-      &:first-child {
-        margin-right: 20px;
-      }
-    }
-    .el-input-number {
-      width: 60px;
-      margin: 0 5px;
-    }
-  }
-  .param-sum-score {
-    color: $--color-danger;
-  }
-}

+ 200 - 61
src/assets/styles/card-preview.scss → card/assets/styles/card-preview.scss

@@ -26,7 +26,8 @@
   position: relative;
   // width: 420mm;
   // height: 297mm;
-  width: 1587px;
+  // width: 1587px;
+  width: 1586px;
   height: 1122px;
   background: #fff;
   margin: 0 auto;
@@ -164,31 +165,63 @@
   &-element {
     .element-item {
       position: relative;
-      border: 1px solid #333;
-      border-top: 0;
+
+      &::before {
+        content: "";
+        position: absolute;
+        width: 100%;
+        height: 100%;
+        top: 0;
+        left: 0;
+        box-sizing: border-box;
+        z-index: 2;
+        border: 1px solid #333;
+        border-top: 0;
+      }
+
+      > div {
+        z-index: 9;
+        position: relative;
+      }
       &-card-head {
-        border: 0;
+        &::before {
+          border: 0;
+        }
       }
 
       &-topic-head {
-        border: 0;
+        &::before {
+          border: 0;
+        }
       }
       &-fill-question,
       &-fill-line {
-        border-bottom: 0;
+        &::before {
+          border-bottom: 0;
+        }
+      }
+      // 预览时,小题扩展答题区之间隐藏分割线
+      &-explain {
+        &::before {
+          border-bottom-color: transparent;
+        }
       }
       &-type-last {
-        border-bottom: 1px solid #333;
+        &::before {
+          border-bottom: 1px solid #333;
+        }
       }
     }
     &:last-child {
       .element-item {
-        &-type-pre {
+        &::before {
           border-bottom: 1px solid #333;
         }
       }
       .element-item-card-head.element-item-type-pre {
-        border-bottom: none;
+        &::before {
+          border-bottom: none;
+        }
       }
     }
   }
@@ -294,9 +327,13 @@
 .grid-row {
   display: table;
   width: 100%;
-  border-spacing: 10px 5px;
+  border-spacing: 10px 0;
   border-collapse: separate;
 
+  &:nth-of-type(2) {
+    margin-top: 10px;
+  }
+
   .grid-col {
     display: table-cell;
     width: 50%;
@@ -312,26 +349,59 @@
 .card-head {
   &-top {
     text-align: center;
+    color: #000;
+
+    .el-input > input {
+      text-align: center;
+      border-radius: 0;
+      border: 0;
+      background-color: transparent;
+      box-shadow: 0 0 1px #ccc;
+      color: #000;
+    }
   }
   &-title {
     font-size: 24px;
     font-family: "楷体";
     font-weight: bold;
-    line-height: 33px;
+    .el-input__inner {
+      font-size: 24px;
+      font-family: "楷体";
+      font-weight: bold;
+      line-height: 33px;
+      height: 33px;
+    }
+    > h1 {
+      line-height: 33px;
+    }
   }
   &-subtitle {
-    height: 45px;
-    textarea,
+    height: 23px;
+    font-family: $--font-family;
+    font-size: 14px;
+    font-weight: bold;
+    .el-input__inner {
+      line-height: 23px;
+      height: 23px;
+    }
     > p {
       padding: 0 10px;
-      font-size: 14px;
-      font-family: $--font-family;
-      font-weight: bold;
-      line-height: 20px;
-      text-align: center;
-      border-color: transparent;
-      background-color: transparent;
-      color: #000;
+      line-height: 23px;
+      overflow: hidden;
+    }
+  }
+  &-title-desc {
+    height: 22px;
+    font-family: $--font-family;
+    font-size: 14px;
+    font-weight: bold;
+    .el-input__inner {
+      line-height: 22px;
+      height: 22px;
+    }
+    > p {
+      padding: 0 10px;
+      line-height: 22px;
       overflow: hidden;
     }
   }
@@ -588,7 +658,7 @@
           }
           &:first-child {
             i {
-              width: 24px;
+              width: 28px;
               height: 14px;
               background-color: #000;
             }
@@ -607,17 +677,47 @@
               &:last-child {
                 margin-right: 0;
               }
-
               &:nth-of-type(1) {
+                position: relative;
                 &::before {
-                  content: "√";
+                  content: "";
+                  display: block;
+                  position: absolute;
+                  left: 30%;
+                  top: 1px;
+                  height: 5px;
+                  width: 11px;
+                  transform: rotate(-45deg);
+                  border-left: 1px solid #000;
+                  border-bottom: 1px solid #000;
                 }
               }
               &:nth-of-type(2) {
+                position: relative;
                 &::before {
-                  content: "×";
+                  content: "";
+                  display: block;
+                  position: absolute;
+                  left: 7px;
+                  top: 5px;
+                  width: 11px;
+                  transform: rotate(-45deg);
+                  transform-origin: center center;
+                  border-bottom: 1px solid #000;
+                }
+                &::after {
+                  content: "";
+                  display: block;
+                  position: absolute;
+                  left: 8px;
+                  top: 5px;
+                  width: 11px;
+                  transform: rotate(45deg);
+                  transform-origin: center center;
+                  border-bottom: 1px solid #000;
                 }
               }
+
               &:nth-of-type(3) {
                 &::before {
                   content: "";
@@ -815,7 +915,7 @@
 // elem-line
 .elem-line-horizontal {
   height: 100%;
-  line-height: 10px;
+  line-height: 30px;
   .line-body {
     display: inline-block;
     vertical-align: middle;
@@ -833,6 +933,13 @@
     border-left: 1px solid #000;
   }
 }
+// elem-lines
+.elem-lines {
+  .line-item {
+    display: inline-block;
+    vertical-align: top;
+  }
+}
 // elem-rect
 .elem-rect {
   .rect-body {
@@ -904,6 +1011,23 @@
     margin: auto;
   }
 }
+// elem-girds
+.elem-grids {
+  > table {
+    table-layout: fixed;
+    border-spacing: 0;
+    border-collapse: collapse;
+    td {
+      border: 1px solid #333;
+    }
+  }
+  &-halving {
+    > table {
+      table-layout: auto;
+      width: 100%;
+    }
+  }
+}
 // elem-fill-question
 .elem-fill-question {
   white-space: normal;
@@ -1007,48 +1131,61 @@
   white-space: normal;
 
   .elem-body {
-    padding: 0 10px;
+    padding: 0 15px 0 10px;
     font-size: 0;
   }
   .elem-fill-quesiton {
     display: inline-block;
     vertical-align: top;
     position: relative;
-    padding: 0 5px;
+    padding: 0 1px;
     font-size: 12px;
 
     li {
-      height: 50px;
-      position: relative;
-      z-index: 8;
-      &:not(:first-child) {
+      &.elem-fill-line {
+        height: 40px;
+        position: relative;
+        margin: 0 10px 0 20px;
+        z-index: 8;
         &::after {
           content: "";
           display: block;
           position: absolute;
           width: 100%;
           border-bottom: 1px solid #000;
-          bottom: 10px;
+          bottom: 8px;
         }
       }
-    }
-
-    > li:first-child {
-      position: absolute;
-      background-color: #fff;
-      height: auto;
-      bottom: 5px;
-      top: 5px;
-      left: 5px;
-      z-index: 9;
-      padding-top: 20px;
-      border: none;
+      &.elem-fill-no {
+        position: absolute;
+        top: 0;
+        left: 1px;
+        z-index: 9;
+        transform: translateY(-100%);
+        width: 20px;
+        padding-bottom: 4px;
+        text-align: center;
+        background-color: #fff;
+        border: none;
+      }
+      &.elem-fill-comma {
+        position: absolute;
+        top: 0;
+        right: -10px;
+        z-index: 9;
+        transform: translateY(-100%);
+        width: 10px;
+        padding-bottom: 4px;
+        background-color: #fff;
+        text-align: center;
+        border: none;
+      }
     }
   }
 }
 
-// elem-explain-children
-.elem-explain-children {
+// elem-explain
+.elem-explain {
   .elem-title {
     padding-bottom: 0;
   }
@@ -1071,31 +1208,33 @@
     left: 0;
     z-index: 8;
   }
-  .explain-children-element {
-    .explain-element {
+  .elem-explain-element {
+    .explain-element-body {
       position: absolute;
     }
   }
 }
 // .elem-composition
 .elem-composition {
-  .elem-body {
-    padding: 0;
+  .elem-title {
+    padding-bottom: 0;
   }
-  &-lines {
-    padding: 10px;
-    li {
-      height: 50px;
-      border-bottom: 1px solid #000;
-    }
+  .elem-body {
+    min-height: 60px;
+    position: relative;
   }
   &-elements {
-    padding: 5px 0;
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    z-index: 8;
   }
 
-  .composition-element {
-    &-item {
-      position: relative;
+  .elem-composition-element {
+    .composition-element-body {
+      position: absolute;
     }
   }
 }

+ 478 - 0
card/assets/styles/element-ui-costom.scss

@@ -0,0 +1,478 @@
+// dialog
+.el-dialog {
+  border-radius: 10px;
+  overflow: hidden;
+  border: 1px solid #c8c8ca;
+  background-color: #f6f6f6;
+
+  box-shadow: 5px 5px 4px 0px rgba(0, 0, 0, 0.1);
+}
+.el-dialog__header {
+  padding: 15px 20px;
+  background-color: #f6f6f6;
+  .el-dialog__title {
+    color: #666666;
+    font-weight: bold;
+    font-size: 16px;
+    line-height: 19px;
+  }
+}
+.el-dialog__body {
+  padding: 30px 20px 20px;
+  background-color: #f6f6f6;
+  position: relative;
+  border-top: 1px solid #e5e5e5;
+
+  &::after {
+    content: "";
+    position: absolute;
+    top: 0;
+    left: 0;
+    z-index: auto;
+    width: 100%;
+    height: 10px;
+    background: linear-gradient(
+      0deg,
+      rgba(245, 245, 245, 0) 0%,
+      rgba(238, 238, 238, 1) 100%
+    );
+  }
+  .el-form-item__label {
+    font-weight: bold;
+    color: rgba(153, 153, 153, 1);
+    padding-right: 2px;
+  }
+  .el-input-tips {
+    font-weight: bold;
+    color: rgba(187, 187, 187, 1);
+    margin-left: 13px;
+  }
+}
+.el-dialog__footer {
+  background-color: #f6f6f6;
+  text-align: center;
+  .el-button {
+    width: 100px;
+    border-radius: 10px;
+  }
+  .el-button--default {
+    background: rgba(245, 245, 245, 1);
+    color: #999;
+  }
+}
+.el-popup-close {
+  height: 20px;
+  width: 20px;
+  background-image: url(../images/icon-close.png);
+  background-repeat: no-repeat;
+  background-size: 100% 100%;
+  &::before {
+    content: "";
+  }
+  &:hover {
+    background-image: url(../images/icon-close-act.png);
+  }
+}
+.el-dialog__headerbtn {
+  top: 14px;
+  right: 14px;
+  .el-dialog__close {
+    @extend .el-popup-close;
+  }
+}
+// form
+.el-form {
+  &-item {
+    &__error {
+      font-size: 12px;
+      font-weight: bold;
+      color: rgba(254, 108, 105, 1);
+    }
+    &__content {
+      .el-table {
+        line-height: 1;
+      }
+    }
+  }
+  &--inline {
+    .el-form-item {
+      margin-right: 20px;
+    }
+  }
+}
+// input
+.el-input {
+  &.is-focus {
+    .el-input__inner {
+      border-color: $--color-primary !important;
+    }
+  }
+  .el-input__inner {
+    border-radius: 7px;
+    border-color: #ddd;
+    background-color: #f5f5f5;
+  }
+}
+// upload
+.el-upload,
+.el-upload-dragger {
+  width: 100%;
+}
+// radio
+.el-radio-button {
+  &:hover {
+    .el-radio-button__inner {
+      color: $--color-primary;
+    }
+  }
+}
+.el-radio-button__orig-radio:checked + .el-radio-button__inner {
+  color: $--color-white;
+  border-color: $--color-primary;
+  background: $--color-primary;
+  background-image: linear-gradient(
+    -90deg,
+    $--color-success 0%,
+    $--color-primary 100%
+  );
+}
+// button
+.el-button {
+  border-radius: $--border-radius;
+
+  > .icon {
+    margin-right: 5px;
+  }
+  > span {
+    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--default {
+  color: $--color-text-regular;
+  background: $--color-background;
+  border: 1px solid $--color-border;
+  &:hover,
+  &:focus {
+    color: $--color-text-regular;
+    background: $--color-white;
+    border: 1px solid $--color-border;
+  }
+  &.el-button--default-act {
+    color: $--color-text-regular !important;
+    background: $--color-background !important;
+    border: 1px solid $--color-border !important;
+    &:hover,
+    &:focus {
+      color: $--color-text-regular !important;
+      background: $--color-white !important;
+      border: 1px solid $--color-border !important;
+    }
+  }
+}
+.el-button--primary {
+  color: $--color-white;
+  border-color: $--color-primary;
+  background: $--color-primary;
+  background-image: linear-gradient(
+    -90deg,
+    $--color-success 0%,
+    $--color-primary 100%
+  );
+
+  &:hover,
+  &:focus {
+    color: $--color-white;
+    border-color: $--color-primary;
+    background: $--color-primary;
+    background-image: linear-gradient(
+      -90deg,
+      mix($--color-white, $--color-success, 20%) 0%,
+      mix($--color-white, $--color-primary, 20%) 100%
+    );
+  }
+}
+.el-button--warning {
+  color: $--color-white;
+  border-color: $--color-warning-lighter;
+  background: $--color-warning-lighter;
+  background-image: linear-gradient(
+    -90deg,
+    $--color-warning-lighter 0%,
+    $--color-warning 100%
+  );
+
+  &:hover,
+  &:focus {
+    color: $--color-white;
+    border-color: $--color-warning-lighter;
+    background: $--color-warning-lighter;
+    background-image: linear-gradient(
+      -90deg,
+      mix($--color-white, $--color-warning-lighter, 20%) 0%,
+      mix($--color-white, $--color-warning, 20%) 100%
+    );
+  }
+}
+
+.el-button + .popover-button,
+.popover-button + .el-button {
+  margin-left: 10px;
+}
+.el-button + .el-button {
+  margin-left: 18px;
+}
+.el-button--text + .el-button--text {
+  margin-left: 5px;
+}
+.el-button--text {
+  color: $--color-text-regular;
+
+  &.btn-table-icon {
+    padding: 0;
+    min-width: 10px;
+    > i {
+      width: 20px;
+      height: 20px;
+    }
+
+    &:hover {
+      transform: scale(1.2);
+    }
+  }
+}
+// table
+.el-table {
+  color: $--color-text-regular;
+  font-weight: bold;
+
+  &--border {
+    border-radius: $--border-radius;
+    border: 1px solid $--color-border;
+  }
+
+  &__header thead {
+    font-size: 16px;
+    color: $--color-text-primary;
+    font-weight: bold;
+  }
+  tr.el-table__row {
+    background-color: $--color-background;
+    &.el-table__row--striped {
+      td {
+        background-color: $--color-white;
+      }
+    }
+  }
+  td,
+  th {
+    border-color: $--color-border-light !important;
+  }
+  .el-table__row.row-danger {
+    color: $--color-danger;
+  }
+  &.el-table--noback {
+    tr.el-table__row {
+      background-color: $--color-white;
+    }
+  }
+}
+// el-checkbox
+.el-checkbox {
+  .el-checkbox__input {
+    .el-checkbox__inner {
+      width: 18px;
+      height: 18px;
+      border-color: $--color-border;
+      &::after {
+        display: none;
+      }
+    }
+    &.is-checked {
+      .el-checkbox__inner {
+        border: none;
+        background-color: transparent;
+        background-image: url(../images/icon-checked.png);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+      }
+    }
+    &.is-focus {
+      .el-checkbox__inner {
+        border-color: $--color-border;
+      }
+    }
+  }
+  .el-checkbox__label {
+    color: $--color-text-regular !important;
+    font-weight: bold;
+  }
+}
+.el-radio {
+  .el-radio__input {
+    .el-radio__inner {
+      width: 18px;
+      height: 18px;
+      border-color: $--color-border;
+
+      &::after {
+        display: none;
+      }
+    }
+    &.is-checked {
+      .el-radio__inner {
+        border: none;
+        background-color: transparent;
+        background-image: url(../images/icon-radio-checked.png);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+      }
+    }
+    &.is-focus {
+      .el-radio__inner {
+        border-color: $--color-border;
+      }
+    }
+  }
+  .el-radio__label {
+    color: $--color-text-regular !important;
+    font-weight: bold;
+  }
+  &.is-disabled {
+    .el-radio__label {
+      color: $--color-text-secondary !important;
+    }
+  }
+}
+
+// el-pagination
+.el-pagination-li {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  overflow: hidden;
+  background-color: $--color-white;
+  border: 1px solid $--color-border;
+}
+.el-pagination {
+  &.is-background {
+    .btn-prev,
+    .btn-next {
+      color: $--color-text-regular;
+      margin: 0 13px;
+      @extend .el-pagination-li;
+    }
+    .btn-prev:disabled,
+    .btn-next:disabled {
+      background-color: $--color-background;
+    }
+
+    .el-pager li {
+      color: $--color-text-regular;
+      font-weight: bold;
+      margin: 0 5px;
+      line-height: 32px;
+
+      @extend .el-pagination-li;
+      &:not(.disabled).active {
+        color: #fff;
+        @extend .el-button--primary;
+      }
+    }
+  }
+}
+// el-message-box
+.el-message-box {
+  width: 350px;
+  background-color: #f6f6f6;
+  border-radius: 10px;
+  &__title {
+    display: none;
+  }
+  &__headerbtn {
+    top: 10px;
+    right: 10px;
+  }
+  &__close.el-icon-close {
+    @extend .el-popup-close;
+  }
+  &__content {
+    text-align: center;
+
+    .el-message-box__status {
+      position: relative;
+      top: 0;
+      height: 50px;
+      width: 50px;
+      transform: none;
+      margin-bottom: 10px;
+
+      &.el-icon-warning {
+        border-radius: 50%;
+        &::before {
+          content: "";
+          position: absolute;
+          top: 0;
+          left: 0;
+          width: 100%;
+          height: 100%;
+          background-image: url(../images/icon-doubt.png);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+        }
+      }
+    }
+    .el-message-box__message {
+      padding: 0;
+    }
+  }
+  &__btns {
+    position: relative;
+    height: 75px;
+    padding: 20px;
+
+    > .el-button {
+      width: 85px;
+      position: absolute;
+      left: 50%;
+      top: 20px;
+
+      &:first-child {
+        margin-left: 5px;
+      }
+      &:last-child {
+        margin-left: -90px;
+      }
+    }
+  }
+}
+.alert-message {
+  .el-message-box__btns {
+    text-align: center;
+    > .el-button {
+      position: relative;
+      left: auto;
+      top: 0;
+      margin: 0;
+    }
+  }
+}
+
+// el-date-editor
+.el-date-editor {
+  .el-range-separator {
+    width: auto;
+    line-height: 28px;
+  }
+  .el-range-input {
+    background-color: transparent;
+  }
+  .el-input__icon {
+    line-height: 28px;
+  }
+}

+ 223 - 0
card/assets/styles/home.scss

@@ -0,0 +1,223 @@
+/* home */
+.home-body {
+  padding-top: 60px;
+}
+
+/* home-header */
+.home-header {
+  position: fixed;
+  width: 100%;
+  padding: 10px 0;
+  height: 60px;
+  top: 0;
+  left: 0;
+  z-index: 99;
+  color: #fff;
+  line-height: 40px;
+  background: #4f63db;
+}
+.head-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;
+  }
+  img {
+    display: block;
+    margin: 0 auto;
+    width: 160px;
+    height: 40px;
+  }
+  &-tiny {
+    width: 60px;
+    padding: 10px;
+    font-size: 18px;
+    overflow: hidden;
+    img {
+      width: 40px;
+      height: 40px;
+    }
+  }
+}
+.head-switch {
+  float: left;
+  padding: 5px 25px 5px 15px;
+  height: 40px;
+  line-height: 30px;
+  cursor: pointer;
+  &:hover {
+    color: $--color-primary;
+  }
+  i {
+    vertical-align: middle;
+    font-size: 20px;
+  }
+}
+.rotate-icon {
+  transform: rotate(-90deg);
+}
+.head-info {
+  float: left;
+  padding: 13px 0;
+  height: 40px;
+}
+.head-user {
+  padding-right: 20px;
+  float: right;
+  height: 40px;
+  line-height: 40px;
+  position: relative;
+  color: $--color-text-regular;
+  cursor: pointer;
+}
+.user-avatar {
+  display: inline-block;
+  vertical-align: top;
+}
+.user-help {
+  display: inline-block;
+  vertical-align: top;
+  color: #fff;
+  font-size: 20px;
+  .icon {
+    margin-right: 12px;
+    margin-top: -2px;
+  }
+}
+.user-name {
+  display: inline-block;
+  vertical-align: top;
+  margin-left: 10px;
+  min-width: 60px;
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-weight: 600;
+}
+.user-logout {
+  display: inline-block;
+  vertical-align: top;
+  margin-left: 10px;
+  font-size: 20px;
+  cursor: pointer;
+  i {
+    vertical-align: middle;
+    margin-top: -3px;
+  }
+  &:hover {
+    color: $--color-danger;
+  }
+}
+
+/* home-footer */
+.home-footer {
+  position: relative;
+  width: 100%;
+  height: 60px;
+  bottom: 0;
+  left: 0;
+  z-index: auto;
+  padding: 20px 0;
+  line-height: 20px;
+  color: $--color-text-secondary;
+  text-align: center;
+  font-size: 13px;
+  a {
+    color: $--color-text-secondary;
+    &:hover {
+      color: $--color-text-primary;
+    }
+  }
+}
+
+/* part */
+.part-box {
+  box-shadow: 0 0 2px 1px #e9e9e9;
+  padding: 20px 30px;
+  margin-bottom: 15px;
+  background-color: #fff;
+
+  .ivu-form-item-label {
+    text-align: right;
+  }
+  .part-title {
+    font-size: 16px;
+    margin-bottom: 15px;
+    height: 32px;
+    line-height: 32px;
+  }
+  .part-title h2 {
+    float: left;
+    font-weight: 600;
+  }
+  .part-title-infos {
+    float: right;
+    > .ivu-btn:not(:first-child) {
+      margin-left: 5px;
+    }
+  }
+  .part-page {
+    margin-top: 15px;
+    text-align: right;
+  }
+  .part-filter {
+    border-bottom: 1px dashed #e0e0e0;
+    margin-bottom: 20px;
+  }
+  .part-none {
+    padding: 100px;
+    font-size: 20px;
+    color: $--color-text-secondary;
+    text-align: center;
+  }
+}
+
+/* table */
+.table {
+  width: 100%;
+  border-spacing: 0;
+  border-collapse: collapse;
+  text-align: center;
+  margin-bottom: 30px;
+  th {
+    padding: 10px;
+    line-height: 20px;
+    letter-spacing: 1px;
+    border: 1px solid $--border-color-light;
+  }
+  td {
+    padding: 10px;
+    line-height: 20px;
+    border: 1px solid $--border-color-light;
+  }
+  .td-th {
+    font-weight: 600;
+    color: $--color-text-primary;
+  }
+
+  &.table-white {
+    background-color: #fff;
+  }
+}
+
+// other
+.tips-info {
+  font-size: 14px;
+  height: 25px;
+  line-height: 25px;
+  color: $--color-text-secondary;
+}
+.tips-error {
+  color: $--color-danger;
+}

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

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

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

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

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

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

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

@@ -0,0 +1,37 @@
+// color ------------------->
+$--color-text-primary: #545454 !default;
+$--color-text-regular: #878787 !default;
+$--color-text-secondary: #bbbbbb !default;
+$--color-text-placeholder: #cccccc !default;
+$--border-color-base: #dcdfe6 !default;
+$--border-color-light: #e4e7ed !default;
+$--border-color-lighter: #ebeef5 !default;
+$--border-color-extra-light: #f2f6fc !default;
+// status
+$--color-primary: rgba(35, 196, 185, 1) !default;
+$--color-success: rgba(28, 208, 161, 1) !default;
+$--color-warning: rgba(255, 159, 69, 1) !default;
+$--color-warning-lighter: rgba(253, 203, 90, 1) !default;
+$--color-danger: rgba(254, 108, 105, 1) !default;
+$--color-info: #909399 !default;
+$--color-blue: #556dff !default;
+$--color-blue-white: #667cff !default;
+$--color-white: #ffffff;
+// skin
+$--color-background: #f5f5f5;
+$--color-background-dark: #21252b;
+$--color-background-act-dark: #2c313a;
+// border
+$--color-border: #e2e2e2;
+$--color-border-light: #e8e8e8;
+// shadow
+$--shadow-light: 0 0 1px rgba(0, 0, 0, 0.15) !default;
+
+// size ------------------->
+$--font-size-base: 14px !default;
+$--font-size-large: 18px !default;
+$--font-size-medium: 16px !default;
+$--border-radius: 10px;
+
+$--font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
+  "Microsoft YaHei", Arial, sans-serif;

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
card/card.temp.json


+ 4 - 3
src/modules/card/components/CardConfigPropEdit.vue → card/components/CardConfigPropEdit.vue

@@ -1,10 +1,10 @@
 <template>
   <div class="card-config-prop-edit">
     <el-button @click="drawer = true" type="primary">
-      配置CardConfig信息
+      配置题卡信息
     </el-button>
     <el-drawer
-      title="配置CardConfig信息"
+      title="配置题卡信息"
       :visible.sync="drawer"
       :with-header="false"
       append-to-body
@@ -73,6 +73,7 @@
 
 <script>
 import { mapState, mapMutations } from "vuex";
+import { objAssign } from "../plugins/utils";
 
 export default {
   name: "card-config-prop-edit",
@@ -119,7 +120,7 @@ export default {
   },
   watch: {
     cardConfig(val) {
-      this.form = this.$objAssign(this.form, val);
+      this.form = objAssign(this.form, val);
     }
   },
   methods: {

+ 107 - 0
card/components/ElementPropEdit.vue

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

+ 0 - 0
src/modules/card/components/pageInfo/PageNumber.vue → card/components/PageNumber.vue


+ 8 - 5
src/modules/card/components/PagePropEdit.vue → card/components/PagePropEdit.vue

@@ -51,9 +51,9 @@
           >启用禁答区</el-checkbox
         >
       </el-form-item>
-      <el-form-item label="已建题号">
+      <el-form-item label="大题数">
         <ul class="topicno-list">
-          <li v-for="item in topicNos" :key="item">{{ item }}</li>
+          <li>{{ topicSeries.length }}</li>
         </ul>
       </el-form-item>
     </el-form>
@@ -62,6 +62,7 @@
 
 <script>
 import { mapState, mapMutations, mapActions } from "vuex";
+import { objAssign } from "../plugins/utils";
 
 export default {
   name: "page-prop-edit",
@@ -102,7 +103,7 @@ export default {
     };
   },
   computed: {
-    ...mapState("card", ["curPageNo", "pages", "cardConfig", "topicNos"]),
+    ...mapState("card", ["curPageNo", "pages", "cardConfig", "topicSeries"]),
     curPage() {
       return this.pages[this.curPageNo];
     }
@@ -114,7 +115,7 @@ export default {
     cardConfig: {
       immediate: true,
       handler(val) {
-        this.form = this.$objAssign(this.form, val);
+        this.form = objAssign(this.form, val);
         this.prePageSize = this.form.pageSize;
         this.columnOptions[2].disabled = val.examNumberStyle === "fill";
       }
@@ -124,6 +125,7 @@ export default {
   methods: {
     ...mapMutations("card", [
       "setPages",
+      "setTopics",
       "setCurElement",
       "setCardConfig",
       "setTopicNos"
@@ -148,6 +150,7 @@ export default {
       this.form.columnNumber = val;
       this.setCardConfig(this.form);
       this.setPages([]);
+      this.setTopics([]);
       this.setTopicNos([]);
       this.$emit("init-page");
     },
@@ -164,7 +167,7 @@ export default {
         });
       });
     },
-    modifyPageSize(pageSize) {
+    modifyPageSize() {
       this.$confirm("此操作将会重置当前页面所有元素信息, 是否继续?", "提示", {
         cancelButtonClass: "el-button--danger is-plain",
         confirmButtonClass: "el-button--primary",

+ 9 - 7
src/modules/card/components/PaperParams.vue → card/components/PaperParams.vue

@@ -119,8 +119,8 @@
 </template>
 
 <script>
-import { calcSum, isEmptyObject, objAssign } from "@/plugins/utils";
-import UploadButton from "@/components/UploadButton";
+import { calcSum, isEmptyObject, objAssign } from "../plugins/utils";
+import UploadButton from "./UploadButton";
 
 export default {
   name: "paper-params",
@@ -203,7 +203,7 @@ export default {
           sumScore: 0,
           commonQuestionScore: 1,
           name: this.getObjectiveTopicName(topic),
-          choiceList: this.getChoiceList(topic.optionCount, topic.isFill),
+          choiceList: this.getChoiceList(topic),
           questions: this.getQuestions(topic),
           ...objAssign(this.initTopic, topic)
         };
@@ -268,7 +268,7 @@ export default {
     getObjectiveTopicName(data) {
       if (data.isMultiply) {
         return "选择题(多选)";
-      } else if (data.isFill) {
+      } else if (data.isBoolean) {
         return "填空题";
       } else {
         return "选择题(单选)";
@@ -282,11 +282,13 @@ export default {
       };
       return names[data.type];
     },
-    getChoiceList(num, isFill) {
-      const options = !isFill ? "abcdefghijklmnopqrstuv" : "√×";
+    getChoiceList(data) {
+      const options = data.isBoolean
+        ? data.booleanType
+        : "abcdefghijklmnopqrstuv";
       return options
         .toUpperCase()
-        .slice(0, num)
+        .slice(0, data.optionCount)
         .split("");
     },
     commonQuestionScoreChange(topic) {

+ 313 - 0
card/components/RightClickMenu.vue

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

+ 19 - 12
src/modules/card/components/TopicElementEdit.vue → card/components/TopicElementEdit.vue

@@ -14,10 +14,11 @@
       :class="{ 'element-resize-act': curElement.id === data.id }"
       :active="['b']"
       :move="false"
-      :min-height="20"
+      :min-height="data.minHeight"
       :fit-parent="['h']"
       @on-click="activeCurElement"
       @resize-over="resizeOver"
+      @change="sizeChange"
       v-else
     >
       <div :class="classes" :id="data.id" :data-type="data.type">
@@ -31,14 +32,15 @@
 
 <script>
 import { mapState, mapMutations, mapActions } from "vuex";
+import { objAssign } from "../plugins/utils";
 
-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";
+import EditCardHead from "../elements/card-head/CardHead";
+import EditFillQuestion from "../elements/fill-question/ElemFillQuestion";
+import EditFillLine from "../elements/fill-line/ElemFillLine";
+import EditExplain from "../elements/explain/ElemExplainEdit";
+import EditComposition from "../elements/composition/ElemCompositionEdit";
+import EditTopicHead from "../elements/topic-head/TopicHead";
+import ElementResize from "./common/ElementResize";
 import TopicNumber from "./common/TopicNumber";
 
 export default {
@@ -48,7 +50,7 @@ export default {
     EditTopicHead,
     EditFillQuestion,
     EditFillLine,
-    EditExplainChildren,
+    EditExplain,
     EditComposition,
     ElementResize,
     TopicNumber
@@ -91,20 +93,25 @@ export default {
       ];
     }
   },
-  mounted() {},
   created() {
     this.init();
   },
   methods: {
     ...mapMutations("card", ["setCurElement"]),
-    ...mapActions("card", ["rebuildPages"]),
+    ...mapActions("card", ["modifyTopic", "rebuildPages"]),
     init() {
-      this.elemData = this.$objAssign(this.elemData, this.data);
+      this.elemData = objAssign(this.elemData, this.data);
     },
     activeCurElement() {
       this.setCurElement(this.data);
     },
+    sizeChange() {
+      const elementDom = document.getElementById(this.data.id);
+      this.data.isCovered =
+        elementDom.offsetHeight < elementDom.firstChild.offsetHeight;
+    },
     resizeOver() {
+      this.modifyTopic(Object.assign({}, this.curElement, this.elemData));
       // 注意:当前组件并没有实时更新元件的尺寸信息,只是在rebuildPages时统一更新。
       this.$nextTick(() => {
         this.rebuildPages();

+ 13 - 8
src/modules/card/components/TopicElementPreview.vue → card/components/TopicElementPreview.vue

@@ -1,18 +1,23 @@
 <template>
   <div class="topic-element">
-    <div :class="classes" :id="data.id" :data-type="data.type" :style="styles">
+    <div
+      :class="classes"
+      :id="`preview-${data.id}`"
+      :data-type="data.type"
+      :style="styles"
+    >
       <component :is="compName" :data="data" preview></component>
     </div>
   </div>
 </template>
 
 <script>
-import 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";
+import PreviewCardHead from "../elements/card-head/CardHead";
+import PreviewExplain from "../elements/explain/ElemExplain";
+import PreviewComposition from "../elements/composition/ElemComposition";
+import PreviewFillQuestion from "../elements/fill-question/ElemFillQuestion";
+import PreviewFillLine from "../elements/fill-line/ElemFillLine";
+import PreviewTopicHead from "../elements/topic-head/TopicHead";
 
 export default {
   name: "topic-preview",
@@ -21,7 +26,7 @@ export default {
     PreviewTopicHead,
     PreviewFillQuestion,
     PreviewFillLine,
-    PreviewExplainChildren,
+    PreviewExplain,
     PreviewComposition
   },
   props: {

+ 66 - 0
card/components/TopicSelectDialog.vue

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

+ 149 - 0
card/components/UploadButton.vue

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

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


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


+ 120 - 63
src/modules/card/components/common/ElementResize.vue → card/components/common/ElementResize.vue

@@ -50,7 +50,7 @@ export default {
     },
     minWidth: {
       type: Number,
-      default: 0,
+      default: 30,
       validator(val) {
         return val >= 0;
       }
@@ -64,7 +64,7 @@ export default {
     },
     minHeight: {
       type: Number,
-      default: 0,
+      default: 30,
       validator(val) {
         return val >= 0;
       }
@@ -81,6 +81,17 @@ export default {
       default() {
         return ["w", "h"];
       }
+    },
+    isCompact: {
+      type: Boolean,
+      default: false
+    },
+    transformFit: {
+      type: Function
+    },
+    elementPk: {
+      type: String,
+      default: ""
     }
   },
   data() {
@@ -91,12 +102,14 @@ export default {
         w: 0,
         h: 0
       },
+      offsetTopOrigin: 0,
       sizePos: {
         x: 0,
         y: 0,
         w: 0,
         h: 0
       },
+      lastSizePos: {},
       initOver: false,
       controlPoints: [],
       positionType: "static",
@@ -123,7 +136,8 @@ export default {
         "element-resize",
         {
           "element-resize-move": this.move,
-          "element-resize-init": this.initOver
+          "element-resize-init": this.initOver,
+          "element-resize-compact": this.isCompact
         }
       ];
     },
@@ -166,42 +180,79 @@ export default {
       const resizeDom = this.$el.childNodes[0];
       this.positionType = window.getComputedStyle(resizeDom).position;
       this.sizePos = { ...this.value };
-      this.sizePosOrigin = { ...this.sizePos };
+      this.lastSizePos = { ...this.value };
+      this.sizePosOrigin = { ...this.value };
+      if (this.positionType === "relative")
+        this.offsetTopOrigin = this.$el.offsetTop;
       this.initOver = true;
     },
-    checkValidSizePos(sizePos) {
-      if (
-        sizePos.w < this.minWidth ||
-        (this.maxWidth !== 0 && sizePos.w > this.maxWidth)
-      )
-        return false;
+    fetchValidSizePos(sizePos, actionType) {
+      if (sizePos.w <= this.minWidth) {
+        sizePos.w = this.minWidth;
+      }
+      if (this.maxWidth !== 0 && sizePos.w >= this.maxWidth) {
+        sizePos.w = this.maxWidth;
+      }
 
-      if (
-        sizePos.h < this.minHeight ||
-        (this.maxHeight !== 0 && sizePos.h > this.maxHeight)
-      )
-        return false;
+      if (sizePos.h <= this.minHeight) {
+        sizePos.h = this.minHeight;
+      }
+      if (this.maxHeight !== 0 && sizePos.h >= this.maxHeight) {
+        sizePos.h = this.maxHeight;
+      }
+
+      if (!this.fitParent.length) {
+        this.lastSizePos = { ...sizePos };
+        return sizePos;
+      }
 
       // 不同的定位方式,计算方式有差异
-      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.length) return true;
+      if (this.fitParentTypeWidth) {
+        if (sizePos.x <= 0) {
+          sizePos.x = 0;
+          if (actionType.includes("left")) sizePos.w = this.lastSizePos.w;
+        }
 
-      if (sizePos.x < 0 || elOffsetTop < 0) return false;
+        if (sizePos.x + sizePos.w > this.parentNodeSize.w) {
+          sizePos.x = this.lastSizePos.x;
+          sizePos.w = this.parentNodeSize.w - sizePos.x;
+        }
+      }
 
-      const wValid =
-        !this.fitParentTypeWidth ||
-        sizePos.x + sizePos.w <= this.parentNodeSize.w;
-      const hValid =
-        !this.fitParentTypeHeight ||
-        elOffsetTop + sizePos.h <= this.parentNodeSize.h;
+      if (this.fitParentTypeHeight) {
+        if (this.positionType === "relative") {
+          const elOffsetTop = this.$el.offsetTop;
+          if (this.sizePosOrigin.y - sizePos.y >= this.offsetTopOrigin) {
+            sizePos.h = this.lastSizePos.h;
+            sizePos.y = this.sizePosOrigin.y - this.offsetTopOrigin;
+          }
+          if (elOffsetTop + sizePos.h >= this.parentNodeSize.h) {
+            sizePos.y = this.lastSizePos.y;
+            sizePos.h = this.lastSizePos.h;
+            // TODO:这里如果拖动太快,会有问题
+            // console.log(this.parentNodeSize.h, elOffsetTop, sizePos.h);
+          }
+        } else {
+          if (sizePos.y <= 0) {
+            sizePos.y = 0;
+            if (actionType.includes("top")) sizePos.h = this.lastSizePos.h;
+          }
+          if (sizePos.y + sizePos.h > this.parentNodeSize.h) {
+            sizePos.y = this.lastSizePos.y;
+            sizePos.h = this.parentNodeSize.h - sizePos.y;
+          }
+        }
+      }
 
-      return wValid && hValid;
+      this.lastSizePos = { ...sizePos };
+      return this.transformFit
+        ? this.transformFit({ id: this.elementPk, ...sizePos }, actionType)
+        : sizePos;
     },
     getLeftSize(left) {
       return {
@@ -227,31 +278,23 @@ export default {
     },
     moveLeftPoint({ left }) {
       const sp = { ...this.sizePos, ...this.getLeftSize(left) };
-      if (this.checkValidSizePos(sp)) {
-        this.sizePos = { ...sp };
-        this.emitChange();
-      }
+      this.sizePos = { ...this.fetchValidSizePos(sp, "left") };
+      this.emitChange();
     },
     moveRightPoint({ left }) {
       const sp = { ...this.sizePos, ...this.getRightSize(left) };
-      if (this.checkValidSizePos(sp)) {
-        this.sizePos = { ...sp };
-        this.emitChange();
-      }
+      this.sizePos = { ...this.fetchValidSizePos(sp, "right") };
+      this.emitChange();
     },
     moveTopPoint({ top }) {
       const sp = { ...this.sizePos, ...this.getTopSize(top) };
-      if (this.checkValidSizePos(sp)) {
-        this.sizePos = { ...sp };
-        this.emitChange();
-      }
+      this.sizePos = { ...this.fetchValidSizePos(sp, "top") };
+      this.emitChange();
     },
     moveBottomPoint({ top }) {
       const sp = { ...this.sizePos, ...this.getBottomSize(top) };
-      if (this.checkValidSizePos(sp)) {
-        this.sizePos = { ...sp };
-        this.emitChange();
-      }
+      this.sizePos = { ...this.fetchValidSizePos(sp, "bottom") };
+      this.emitChange();
     },
     moveLeftTopPoint({ left, top }) {
       const sp = {
@@ -259,10 +302,8 @@ export default {
         ...this.getLeftSize(left),
         ...this.getTopSize(top)
       };
-      if (this.checkValidSizePos(sp)) {
-        this.sizePos = { ...sp };
-        this.emitChange();
-      }
+      this.sizePos = { ...this.fetchValidSizePos(sp, "left-top") };
+      this.emitChange();
     },
     moveRightTopPoint({ left, top }) {
       const sp = {
@@ -270,10 +311,8 @@ export default {
         ...this.getRightSize(left),
         ...this.getTopSize(top)
       };
-      if (this.checkValidSizePos(sp)) {
-        this.sizePos = { ...sp };
-        this.emitChange();
-      }
+      this.sizePos = { ...this.fetchValidSizePos(sp, "right-top") };
+      this.emitChange();
     },
     moveLeftBottomPoint({ left, top }) {
       const sp = {
@@ -281,10 +320,8 @@ export default {
         ...this.getLeftSize(left),
         ...this.getBottomSize(top)
       };
-      if (this.checkValidSizePos(sp)) {
-        this.sizePos = { ...sp };
-        this.emitChange();
-      }
+      this.sizePos = { ...this.fetchValidSizePos(sp, "left-bottom") };
+      this.emitChange();
     },
     moveRightBottomPoint({ left, top }) {
       const sp = {
@@ -292,14 +329,15 @@ export default {
         ...this.getRightSize(left),
         ...this.getBottomSize(top)
       };
-      if (this.checkValidSizePos(sp)) {
-        this.sizePos = { ...sp };
-        this.emitChange();
-      }
+      this.sizePos = { ...this.fetchValidSizePos(sp, "right-bottom") };
+      this.emitChange();
     },
     moveOver() {
       this.sizePosOrigin = { ...this.sizePos };
-      this.$emit("resize-over");
+      this.lastSizePos = { ...this.sizePos };
+      if (this.positionType === "relative")
+        this.offsetTopOrigin = this.$el.offsetTop < 0 ? 0 : this.$el.offsetTop;
+      this.$emit("resize-over", this.sizePos);
     },
     moveStart() {
       this.$emit("on-click");
@@ -314,10 +352,8 @@ export default {
           y: top + this.sizePosOrigin.y
         }
       };
-      if (this.checkValidSizePos(sp)) {
-        this.sizePos = { ...sp };
-        this.emitChange();
-      }
+      this.sizePos = { ...this.fetchValidSizePos(sp, "move") };
+      this.emitChange();
     },
     moveElementOver() {
       if (!this.move) return;
@@ -514,5 +550,26 @@ export default {
       border-top: 1px solid #4794b3;
     }
   }
+
+  &-compact {
+    .control-line {
+      &-left {
+        left: 0;
+        border-left: 1px dashed #bbb;
+      }
+      &-right {
+        right: 0;
+        border-left: 1px dashed #bbb;
+      }
+      &-top {
+        top: 0;
+        border-top: 1px dashed #bbb;
+      }
+      &-bottom {
+        bottom: 0;
+        border-top: 1px dashed #bbb;
+      }
+    }
+  }
 }
 </style>

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


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


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


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


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


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


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


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


+ 170 - 0
card/elementModel.js

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

+ 31 - 6
src/modules/card/components/elementEdit/CardHead.vue → card/elements/card-head/CardHead.vue

@@ -1,12 +1,20 @@
 <template>
   <div :class="classes">
     <div class="card-head-top">
-      <h1 class="card-head-title">{{ data.schoolName }}</h1>
+      <div class="card-head-title">
+        <el-input
+          placeholder="请输入主标题"
+          size="small"
+          @blur="nameChange"
+          v-model="cardTitle"
+          id="cardTitleInput"
+          v-if="!preview && !data.isSimple"
+        >
+        </el-input>
+        <h1 v-else>{{ data.schoolName }}</h1>
+      </div>
       <div class="card-head-subtitle">
         <el-input
-          type="textarea"
-          :rows="2"
-          resize="none"
           placeholder="请输入题卡标题"
           @blur="nameChange"
           v-model="cardName"
@@ -16,6 +24,17 @@
         </el-input>
         <p v-else>{{ data.cardName }}</p>
       </div>
+      <div class="card-head-title-desc">
+        <el-input
+          placeholder="请输入内容"
+          @blur="nameChange"
+          v-model="cardTitleDesc"
+          id="cardTileDescInput"
+          v-if="!preview && !data.isSimple"
+        >
+        </el-input>
+        <p v-else>{{ data.cardTitleDesc }}</p>
+      </div>
     </div>
 
     <template v-if="data.columnNumber === 2">
@@ -110,7 +129,9 @@ export default {
   },
   data() {
     return {
-      cardName: this.data.cardName
+      cardName: this.data.cardName,
+      cardTitle: this.data.schoolName,
+      cardTitleDesc: this.data.cardTitleDesc
     };
   },
   computed: {
@@ -139,7 +160,11 @@ export default {
   methods: {
     ...mapMutations("card", ["setCardConfig"]),
     nameChange() {
-      this.setCardConfig({ cardName: this.cardName });
+      this.setCardConfig({
+        cardName: this.cardName,
+        schoolName: this.cardTitle,
+        cardTitleDesc: this.cardTitleDesc
+      });
     }
   }
 };

+ 0 - 0
src/modules/card/components/elementEdit/CardHeadBodyAutoResize.vue → card/elements/card-head/CardHeadBodyAutoResize.vue


+ 0 - 0
src/modules/card/components/elementEdit/CardHeadSample.vue → card/elements/card-head/CardHeadSample.vue


+ 2 - 2
src/modules/card/components/elementEdit/cardHeadSpin/HeadDynamic.vue → card/elements/card-head/cardHeadSpin/HeadDynamic.vue

@@ -74,7 +74,7 @@
       >
         <div class="dynamic-aorb-content">
           <img :src="aorbBarcodeSrc" v-if="aorbBarcodeSrc" />
-          <img src="@/assets/images/barcode-sample-notext.png" v-else />
+          <img src="../../../assets/images/barcode-sample-notext.png" v-else />
         </div>
       </div>
     </div>
@@ -82,7 +82,7 @@
 </template>
 
 <script>
-import { calcSum } from "@/plugins/utils";
+import { calcSum } from "../../../plugins/utils";
 
 export default {
   name: "head-dynamic",

+ 0 - 0
src/modules/card/components/elementEdit/cardHeadSpin/HeadNotice.vue → card/elements/card-head/cardHeadSpin/HeadNotice.vue


+ 0 - 0
src/modules/card/components/elementEdit/cardHeadSpin/HeadStdinfo.vue → card/elements/card-head/cardHeadSpin/HeadStdinfo.vue


+ 1 - 1
src/modules/card/components/elementEdit/cardHeadSpin/HeadStdno.vue → card/elements/card-head/cardHeadSpin/HeadStdno.vue

@@ -6,7 +6,7 @@
     <div class="stdno-auto" v-if="data.examNumberStyle === 'auto'">
       <div class="stdno-auto-barcode">
         <img :src="examNumberBarcodeSrc" v-if="examNumberBarcodeSrc" />
-        <img src="@/assets/images/barcode-sample-notext.png" v-else />
+        <img src="../../../assets/images/barcode-sample-notext.png" v-else />
         <p>{{ examNumberBarcodeName || "123456789" }}</p>
       </div>
     </div>

+ 29 - 0
card/elements/card-head/model.js

@@ -0,0 +1,29 @@
+import { getElementId, deepCopy } from "../../plugins/utils";
+
+const MODEL = {
+  type: "CARD_HEAD",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 0,
+  schoolName: "",
+  cardName: "",
+  aOrB: true,
+  aOrBType: "fill", // fill:手动填涂,auto:自动条码
+  missAndFill: true,
+  writeSign: true,
+  examNumberStyle: "auto", // auto:自动条码, empty:手动条码, fill:手动填涂
+  businessParams: [],
+  noticeHead: [],
+  columnNumber: 2,
+  isSimple: false, // 是否是简化形式
+  sign: "head"
+};
+
+const getModel = cardConfig => {
+  const infos = Object.assign({}, deepCopy(MODEL), cardConfig);
+  infos.id = getElementId();
+  return infos;
+};
+
+export { MODEL, getModel };

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

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

+ 45 - 0
card/elements/composition/ElemComposition.vue

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

+ 133 - 0
card/elements/composition/ElemCompositionEdit.vue

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

+ 57 - 0
card/elements/composition/ElemCompositionElement.vue

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

+ 39 - 15
src/modules/card/components/elementEdit/ExplainChildrenElement.vue → card/elements/composition/ElemCompositionElementEdit.vue

@@ -1,14 +1,17 @@
 <template>
-  <div class="explain-children-element">
+  <div class="elem-composition-element elem-composition-element-edit">
     <element-resize
       v-model="elemData"
       :class="{ 'element-resize-act': curElement.id === data.id }"
       :active="active"
-      @change="elementChange"
+      :transform-fit="transformFit"
+      :element-pk="data.id"
+      isCompact
+      @resize-over="resizeOver"
       @on-click="activeCurElement"
     >
       <div
-        class="explain-element"
+        :class="classes"
         :style="styles"
         :id="data.id"
         :data-type="data.type"
@@ -21,25 +24,34 @@
 
 <script>
 import { mapState, mapMutations } from "vuex";
+import { objAssign } from "../../plugins/utils";
 
-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";
+import ElementResize from "../../components/common/ElementResize";
+import ElemText from "../text/ElemText";
+import ElemImage from "../image/ElemImage";
+import ElemLine from "../line/ElemLine";
+import ElemLines from "../lines/ElemLines";
+import ElemGrids from "../grids/ElemGrids";
 
 export default {
-  name: "explain-children-element",
+  name: "elem-composition-element-edit",
   components: {
     ElemText,
     ElemImage,
-    ElemLineHorizontal,
-    ElemLineVertical,
+    ElemLine,
+    ElemLines,
+    ElemGrids,
     ElementResize
   },
   props: {
     data: {
       type: Object
+    },
+    transformFit: {
+      type: Function,
+      default() {
+        return {};
+      }
     }
   },
   data() {
@@ -54,6 +66,8 @@ export default {
       actives: {
         TEXT: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
         IMAGE: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        LINES: ["b"],
+        GRIDS: ["b"],
         LINE_HORIZONTAL: ["l", "r"],
         LINE_VERTICAL: ["t", "b"]
       }
@@ -62,7 +76,17 @@ export default {
   computed: {
     ...mapState("card", ["curElement"]),
     compName() {
-      return `elem-${this.data.type.toLowerCase().replace("_", "-")}`;
+      if (this.data.type.includes("LINE_")) return "elem-line";
+      return `elem-${this.data.type.toLowerCase()}`;
+    },
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    classes() {
+      return [
+        "composition-element-body",
+        `composition-element-${this.elementName}`
+      ];
     },
     active() {
       return this.actives[this.data.type];
@@ -74,7 +98,7 @@ export default {
   methods: {
     ...mapMutations("card", ["setCurElement"]),
     init() {
-      this.elemData = this.$objAssign(this.elemData, this.data);
+      this.elemData = objAssign(this.elemData, this.data);
       this.styles = {
         left: this.data.x + "px",
         top: this.data.y + "px",
@@ -82,8 +106,8 @@ export default {
         height: this.data.h + "px"
       };
     },
-    elementChange() {
-      this.$emit("change", Object.assign({}, this.data, this.elemData));
+    resizeOver() {
+      this.$emit("resize-over", Object.assign({}, this.data, this.elemData));
     },
     activeCurElement() {
       this.setCurElement(this.data);

+ 67 - 0
card/elements/composition/model.js

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

+ 5 - 78
src/modules/card/components/elementPropEdit/EditExplain.vue → card/elements/explain/EditExplain.vue

@@ -1,16 +1,5 @@
 <template>
-  <el-dialog
-    class="edit-explain edit-dialog"
-    :visible.sync="dialogIsShow"
-    title="解答题"
-    top="10vh"
-    width="640px"
-    :close-on-click-modal="false"
-    :close-on-press-escape="false"
-    append-to-body
-    @open="opened"
-    @close="closed"
-  >
+  <div class="edit-explain">
     <el-form
       ref="modalFormComp"
       :model="modalForm"
@@ -18,17 +7,6 @@
       :key="modalForm.id"
       label-width="100px"
     >
-      <el-form-item prop="topicNo" label="大题题号:">
-        <el-input-number
-          style="width:125px;"
-          v-model.number="modalForm.topicNo"
-          :min="1"
-          :max="100"
-          :step="1"
-          step-strictly
-          :controls="false"
-        ></el-input-number>
-      </el-form-item>
       <el-form-item prop="topicName" label="题目名称:">
         <el-input
           v-model.trim="modalForm.topicName"
@@ -61,17 +39,12 @@
         ></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>
+  </div>
 </template>
 
 <script>
 const initModalForm = {
   id: "",
-  topicNo: null,
   topicName: "",
   startNumber: 1,
   endNumber: 4,
@@ -86,12 +59,6 @@ export default {
       default() {
         return {};
       }
-    },
-    topicNos: {
-      type: Array,
-      default() {
-        return [];
-      }
     }
   },
   data() {
@@ -105,25 +72,7 @@ export default {
         callback();
       }
     };
-    const topicNoValidater = (rule, value, callback) => {
-      if (!this.instance.topicNo) {
-        // 新增题目
-        if (this.topicNos.includes(value)) {
-          callback(new Error("当前大题题号已经存在,请重新输入"));
-        } else {
-          callback();
-        }
-      } else {
-        // 修改题目
-        if (value !== this.instance.topicNo && this.topicNos.includes(value)) {
-          callback(new Error("当前大题题号已经存在,请重新输入"));
-        } else {
-          callback();
-        }
-      }
-    };
     return {
-      dialogIsShow: false,
       modalForm: { ...initModalForm },
       rules: {
         topicName: [
@@ -133,18 +82,6 @@ export default {
             trigger: "change"
           }
         ],
-        topicNo: [
-          {
-            required: true,
-            message: "请输入大题题号",
-            trigger: "change"
-          },
-          {
-            type: "number",
-            validator: topicNoValidater,
-            trigger: "change"
-          }
-        ],
         endNumber: [
           {
             required: true,
@@ -160,6 +97,9 @@ export default {
       }
     };
   },
+  mounted() {
+    this.initData(this.instance);
+  },
   methods: {
     initData(val) {
       const valInfo = val.parent || val;
@@ -167,18 +107,6 @@ export default {
       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().catch(() => {});
       if (!valid) return;
@@ -187,7 +115,6 @@ export default {
         this.modalForm.endNumber - this.modalForm.startNumber + 1;
       console.log(this.modalForm);
       this.$emit("modified", this.modalForm);
-      this.cancel();
     }
   }
 };

+ 12 - 7
src/modules/card/components/elementPreview/ExplainChildren.vue → card/elements/explain/ElemExplain.vue

@@ -1,28 +1,33 @@
 <template>
-  <div class="elem-explain-children">
+  <div class="elem-explain">
     <div class="elem-title" v-if="data.showTitle" ref="ElemTitle">
       {{ data.parent.topicName }}
     </div>
     <div class="elem-body" ref="ElemBody">
-      <div class="elem-explain-no">{{ data.explainNumber }}、</div>
+      <div
+        class="elem-explain-no"
+        v-if="data.parent.questionsCount > 1 && !data.isExtend"
+      >
+        {{ data.serialNumber }}、
+      </div>
       <!-- 解答题子元件区域 -->
       <div class="elem-explain-elements">
-        <explain-children-element
+        <elem-explain-element
           v-for="element in data.elements"
           :key="element.id"
           :data="element"
-        ></explain-children-element>
+        ></elem-explain-element>
       </div>
     </div>
   </div>
 </template>
 
 <script>
-import ExplainChildrenElement from "./ExplainChildrenElement";
+import ElemExplainElement from "./ElemExplainElement";
 
 export default {
-  name: "elem-explain-children",
-  components: { ExplainChildrenElement },
+  name: "elem-explain",
+  components: { ElemExplainElement },
   props: {
     data: {
       type: Object

+ 54 - 26
src/modules/card/components/elementEdit/ExplainChildren.vue → card/elements/explain/ElemExplainEdit.vue

@@ -1,10 +1,15 @@
 <template>
-  <div class="elem-explain-children">
+  <div class="elem-explain elem-explain-edit">
     <div class="elem-title" v-if="data.showTitle" ref="ElemTitle">
       {{ data.parent.topicName }}
     </div>
-    <div class="elem-body" :style="explainBodyStyle">
-      <div class="elem-explain-no">{{ data.explainNumber }}、</div>
+    <div class="elem-body" :style="bodyStyle">
+      <div
+        class="elem-explain-no"
+        v-if="data.parent.questionsCount > 1 && !data.isExtend"
+      >
+        {{ data.serialNumber }}、
+      </div>
       <!-- 解答题子元件编辑区域 -->
       <div
         class="elem-explain-elements"
@@ -12,24 +17,42 @@
         @dragover.prevent
         @dragleave.prevent
       >
-        <explain-children-element
+        <elem-explain-element-edit
           v-for="element in data.elements"
           :key="element.id"
           :data="element"
-          @change="elementChange"
-        ></explain-children-element>
+          :transform-fit="rebuildGuides"
+          @resize-over="elementResizeOver"
+        ></elem-explain-element-edit>
+        <!-- guide-lines -->
+        <div class="element-guide-lines">
+          <div
+            class="guide-line guide-line-x"
+            v-for="line in xLines"
+            :key="`x-${line.top}`"
+            :style="line"
+          ></div>
+          <div
+            class="guide-line guide-line-y"
+            v-for="line in yLines"
+            :key="`y-${line.left}`"
+            :style="line"
+          ></div>
+        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script>
-import ExplainChildrenElement from "./ExplainChildrenElement";
-import { mapState, mapMutations } from "vuex";
+import { mapState, mapMutations, mapActions } from "vuex";
+import ElemExplainElementEdit from "./ElemExplainElementEdit";
+import guideLinesMixins from "../../mixins/guideLines";
 
 export default {
-  name: "elem-explain-children",
-  components: { ExplainChildrenElement },
+  name: "elem-explain-edit",
+  components: { ElemExplainElementEdit },
+  mixins: [guideLinesMixins],
   props: {
     data: {
       type: Object
@@ -37,7 +60,7 @@ export default {
   },
   data() {
     return {
-      explainBodyStyle: {}
+      bodyStyle: {}
     };
   },
   computed: {
@@ -55,14 +78,17 @@ export default {
   },
   methods: {
     ...mapMutations("card", ["setCurDragElement", "setCurElement"]),
+    ...mapActions("card", ["rebuildPages", "modifyElementChild"]),
     modifyBodyStyle() {
-      let height = this.data.h;
-      if (this.data.showTitle) {
-        height = this.data.h - this.$refs.ElemTitle.clientHeight;
-      }
-      this.explainBodyStyle = {
-        height: height + "px"
-      };
+      this.$nextTick(() => {
+        let height = this.data.h;
+        if (this.data.showTitle) {
+          height = this.data.h - this.$refs.ElemTitle.clientHeight;
+        }
+        this.bodyStyle = {
+          height: height + "px"
+        };
+      });
     },
     dropInnerElement(e) {
       let { layerX: x, layerY: y } = e;
@@ -77,7 +103,7 @@ export default {
           type: this.data.type
         }
       };
-      this.data.elements.push(curElement);
+      this.elementResizeOver(curElement);
       this.setCurDragElement({});
       this.setCurElement(curElement);
     },
@@ -95,13 +121,15 @@ export default {
         offsetTop
       };
     },
-    elementChange(element) {
-      const index = this.data.elements.findIndex(
-        elem => elem.id === element.id
-      );
-      if (index !== -1) {
-        this.data.elements.splice(index, 1, element);
-      }
+    elementResizeOver(element) {
+      this.clear();
+      this.modifyElementChild(element);
+      this.$nextTick(() => {
+        this.rebuildPages();
+      });
+    },
+    rebuildGuides(element, actionType) {
+      return this.rebuild(this.data.elements, element, actionType);
     }
   }
 };

+ 55 - 0
card/elements/explain/ElemExplainElement.vue

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

+ 114 - 0
card/elements/explain/ElemExplainElementEdit.vue

@@ -0,0 +1,114 @@
+<template>
+  <div class="elem-explain-element elem-explain-element-edit">
+    <element-resize
+      v-model="elemData"
+      :class="{ 'element-resize-act': curElement.id === data.id }"
+      :active="active"
+      :transform-fit="transformFit"
+      :element-pk="data.id"
+      isCompact
+      @resize-over="resizeOver"
+      @on-click="activeCurElement"
+    >
+      <div
+        :class="classes"
+        :style="styles"
+        :id="data.id"
+        :data-type="data.type"
+      >
+        <component :is="compName" :data="data"></component>
+      </div>
+    </element-resize>
+  </div>
+</template>
+
+<script>
+import { mapState, mapMutations } from "vuex";
+import { objAssign } from "../../plugins/utils";
+
+import ElementResize from "../../components/common/ElementResize";
+import ElemText from "../text/ElemText";
+import ElemImage from "../image/ElemImage";
+import ElemLine from "../line/ElemLine";
+import ElemLines from "../lines/ElemLines";
+import ElemGrids from "../grids/ElemGrids";
+
+export default {
+  name: "elem-explain-element-edit",
+  components: {
+    ElemText,
+    ElemImage,
+    ElemLine,
+    ElemLines,
+    ElemGrids,
+    ElementResize
+  },
+  props: {
+    data: {
+      type: Object
+    },
+    transformFit: {
+      type: Function,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      elemData: {
+        x: 0,
+        y: 0,
+        w: 0,
+        h: 0
+      },
+      styles: {},
+      actives: {
+        TEXT: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        IMAGE: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        LINES: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        GRIDS: ["r", "rb", "b", "lb", "l", "lt", "t", "rt"],
+        LINE_HORIZONTAL: ["l", "r"],
+        LINE_VERTICAL: ["t", "b"]
+      }
+    };
+  },
+  computed: {
+    ...mapState("card", ["curElement"]),
+    compName() {
+      if (this.data.type.includes("LINE_")) return "elem-line";
+      return `elem-${this.data.type.toLowerCase()}`;
+    },
+    elementName() {
+      return this.data.type.toLowerCase().replace("_", "-");
+    },
+    classes() {
+      return ["explain-element-body", `explain-element-${this.elementName}`];
+    },
+    active() {
+      return this.actives[this.data.type];
+    }
+  },
+  created() {
+    this.init();
+  },
+  methods: {
+    ...mapMutations("card", ["setCurElement"]),
+    init() {
+      this.elemData = objAssign(this.elemData, this.data);
+      this.styles = {
+        left: this.data.x + "px",
+        top: this.data.y + "px",
+        width: this.data.w + "px",
+        height: this.data.h + "px"
+      };
+    },
+    resizeOver() {
+      this.$emit("resize-over", Object.assign({}, this.data, this.elemData));
+    },
+    activeCurElement() {
+      this.setCurElement(this.data);
+    }
+  }
+};
+</script>

+ 65 - 0
card/elements/explain/model.js

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

+ 108 - 87
src/modules/card/components/elementPropEdit/EditFillLine.vue → card/elements/fill-line/EditFillLine.vue

@@ -1,34 +1,12 @@
 <template>
-  <el-dialog
-    class="edit-fill-line edit-dialog"
-    :visible.sync="dialogIsShow"
-    title="填空题"
-    top="10vh"
-    width="640px"
-    :close-on-click-modal="false"
-    :close-on-press-escape="false"
-    append-to-body
-    @open="opened"
-    @close="closed"
-  >
+  <div class="edit-fill-line ">
     <el-form
       ref="modalFormComp"
       :model="modalForm"
       :rules="rules"
       :key="modalForm.id"
-      label-width="100px"
+      label-width="120px"
     >
-      <el-form-item prop="topicNo" label="大题题号:">
-        <el-input-number
-          style="width:125px;"
-          v-model.number="modalForm.topicNo"
-          :min="1"
-          :max="100"
-          :step="1"
-          step-strictly
-          :controls="false"
-        ></el-input-number>
-      </el-form-item>
       <el-form-item prop="topicName" label="题目名称:">
         <el-input
           v-model.trim="modalForm.topicName"
@@ -60,29 +38,28 @@
           :controls="false"
         ></el-input-number>
       </el-form-item>
-      <el-form-item prop="questionNumberPerLine" label="每行空数:">
+      <el-form-item prop="lineSpacing" label="空位上下间距:">
         <el-input-number
           style="width:125px;"
-          v-model="modalForm.questionNumberPerLine"
-          :min="1"
-          :max="10"
+          v-model.number="modalForm.lineSpacing"
+          :min="20"
+          :max="100"
           :step="1"
           step-strictly
           :controls="false"
         ></el-input-number>
-        <span class="el-input-tips">*指一行显示填空题数量</span>
       </el-form-item>
-      <el-form-item prop="lineNumberPerQuestion" label="每空行数:">
+      <el-form-item prop="questionNumberPerLine" label="每行空数:">
         <el-input-number
           style="width:125px;"
-          v-model="modalForm.lineNumberPerQuestion"
+          v-model="modalForm.questionNumberPerLine"
           :min="1"
-          :max="15"
+          :max="10"
           :step="1"
           step-strictly
           :controls="false"
         ></el-input-number>
-        <span class="el-input-tips">*指每一题显示几行</span>
+        <span class="el-input-tips">*指一行显示空位数量</span>
       </el-form-item>
       <el-form-item label="题号前缀:">
         <el-input
@@ -91,24 +68,84 @@
           clearable
         ></el-input>
       </el-form-item>
+      <el-form-item label="空位排列方向:" required>
+        <el-radio-group v-model="modalForm.questionDirection" size="small">
+          <el-radio-button
+            v-for="(val, key) in DIRECTION_TYPE"
+            :key="key"
+            :label="key"
+            >{{ val }}</el-radio-button
+          >
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="小题空数类型:">
+        <el-radio-group
+          v-model="modalForm.questionLineType"
+          size="small"
+          @change="lineTypeChange"
+        >
+          <el-radio-button label="norm">标准</el-radio-button>
+          <el-radio-button label="custom">自定义</el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item
+        v-if="modalForm.questionLineType === 'norm'"
+        prop="lineNumberPerQuestion"
+        label="每题空数:"
+      >
+        <el-input-number
+          style="width:125px;"
+          v-model="modalForm.lineNumberPerQuestion"
+          :min="1"
+          :max="15"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+        <span class="el-input-tips">*指每一小题的空位数量</span>
+      </el-form-item>
+      <el-form-item v-else prop="questionLineNums" label="各小题空数:">
+        <table class="table table-white">
+          <tr>
+            <th>题号</th>
+            <th>空数</th>
+          </tr>
+          <tr v-for="option in questionLineNumOptions" :key="option.no">
+            <td>{{ option.no }}</td>
+            <td>
+              <el-input-number
+                v-model="option.count"
+                size="mini"
+                :min="1"
+                :max="50"
+                :step="1"
+                step-strictly
+                :controls="false"
+                style="width:125px;"
+              ></el-input-number>
+            </td>
+          </tr>
+        </table>
+      </el-form-item>
     </el-form>
-    <div slot="footer">
-      <el-button type="primary" @click="submit">确认</el-button>
-      <el-button @click="cancel" plain>取消</el-button>
-    </div>
-  </el-dialog>
+  </div>
 </template>
 
 <script>
+import { DIRECTION_TYPE } from "../../enumerate";
+
 const initModalForm = {
   id: "",
-  topicNo: null,
   topicName: "",
   startNumber: 1,
   endNumber: 2,
   questionsCount: 2,
   questionNumberPerLine: 1,
   lineNumberPerQuestion: 1,
+  lineSpacing: 40,
+  questionDirection: "horizontal",
+  questionLineType: "norm",
+  questionLineNums: [],
   numberPre: ""
 };
 
@@ -120,12 +157,6 @@ export default {
       default() {
         return {};
       }
-    },
-    topicNos: {
-      type: Array,
-      default() {
-        return [];
-      }
     }
   },
   data() {
@@ -140,27 +171,10 @@ export default {
       }
     };
 
-    const topicNoValidater = (rule, value, callback) => {
-      if (!this.instance.topicNo) {
-        // 新增题目
-        if (this.topicNos.includes(value)) {
-          callback(new Error("当前大题题号已经存在,请重新输入"));
-        } else {
-          callback();
-        }
-      } else {
-        // 修改题目
-        if (value !== this.instance.topicNo && this.topicNos.includes(value)) {
-          callback(new Error("当前大题题号已经存在,请重新输入"));
-        } else {
-          callback();
-        }
-      }
-    };
-
     return {
-      dialogIsShow: false,
       modalForm: { ...initModalForm },
+      DIRECTION_TYPE,
+      questionLineNumOptions: [{ no: 1, count: 1 }],
       rules: {
         topicName: [
           {
@@ -169,27 +183,23 @@ export default {
             trigger: "change"
           }
         ],
-        topicNo: [
+        endNumber: [
           {
             required: true,
-            message: "请输入大题题号",
+            message: "请输入起止题号",
             trigger: "change"
           },
           {
             type: "number",
-            validator: topicNoValidater,
+            validator: numberRangeValidater,
             trigger: "change"
           }
         ],
-        endNumber: [
+        lineSpacing: [
           {
             required: true,
-            message: "请输入起止题号",
-            trigger: "change"
-          },
-          {
             type: "number",
-            validator: numberRangeValidater,
+            message: "请输入空位上下间距",
             trigger: "change"
           }
         ],
@@ -197,7 +207,7 @@ export default {
           {
             required: true,
             type: "number",
-            message: "请输入选项个数",
+            message: "请输入每行空数",
             trigger: "change"
           }
         ],
@@ -205,31 +215,42 @@ export default {
           {
             required: true,
             type: "number",
-            message: "请输入每空数",
+            message: "请输入每空数",
             trigger: "change"
           }
         ]
       }
     };
   },
+  mounted() {
+    this.initData(this.instance);
+  },
   methods: {
     initData(val) {
       const valInfo = val.parent || val;
       this.modalForm = { ...valInfo };
       this.modalForm.endNumber =
         this.modalForm.startNumber + this.modalForm.questionsCount - 1;
+      this.questionLineNumOptions = this.modalForm.questionLineNums;
     },
-    opened() {
-      this.initData(this.instance);
-    },
-    closed() {
-      this.$emit("closed");
-    },
-    cancel() {
-      this.dialogIsShow = false;
-    },
-    open() {
-      this.dialogIsShow = true;
+    lineTypeChange() {
+      // check start end number
+      if (this.modalForm.questionLineType === "custom") {
+        let questionLineNumOptions = [];
+        for (
+          let i = this.modalForm.startNumber;
+          i <= this.modalForm.endNumber;
+          i++
+        ) {
+          questionLineNumOptions.push({
+            no: i,
+            count: this.modalForm.lineNumberPerQuestion
+          });
+        }
+        this.questionLineNumOptions = questionLineNumOptions;
+      } else {
+        this.questionLineNumOptions = [];
+      }
     },
     async submit() {
       const valid = await this.$refs.modalFormComp.validate().catch(() => {});
@@ -237,8 +258,8 @@ export default {
 
       this.modalForm.questionsCount =
         this.modalForm.endNumber - this.modalForm.startNumber + 1;
+      this.modalForm.questionLineNums = this.questionLineNumOptions;
       this.$emit("modified", this.modalForm);
-      this.cancel();
     }
   }
 };

+ 80 - 0
card/elements/fill-line/ElemFillLine.vue

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

+ 103 - 0
card/elements/fill-line/model.js

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

+ 100 - 81
src/modules/card/components/elementPropEdit/EditFillQuestion.vue → card/elements/fill-question/EditFillQuestion.vue

@@ -1,34 +1,12 @@
 <template>
-  <el-dialog
-    class="edit-fill-question edit-dialog"
-    :visible.sync="dialogIsShow"
-    title="选择题"
-    top="10vh"
-    width="640px"
-    :close-on-click-modal="false"
-    :close-on-press-escape="false"
-    append-to-body
-    @open="opened"
-    @close="closed"
-  >
+  <div class="edit-fill-question">
     <el-form
       ref="modalFormComp"
       :model="modalForm"
       :rules="rules"
       :key="modalForm.id"
-      label-width="100px"
+      label-width="120px"
     >
-      <el-form-item prop="topicNo" label="大题题号:">
-        <el-input-number
-          style="width:125px;"
-          v-model.number="modalForm.topicNo"
-          :min="1"
-          :max="100"
-          :step="1"
-          step-strictly
-          :controls="false"
-        ></el-input-number>
-      </el-form-item>
       <el-form-item prop="topicName" label="题目名称:">
         <el-input
           v-model.trim="modalForm.topicName"
@@ -69,40 +47,86 @@
           :step="1"
           step-strictly
           :controls="false"
-          :disabled="modalForm.isFill"
+          :disabled="modalForm.isBoolean"
         ></el-input-number>
       </el-form-item>
+      <el-form-item label="小题排列方向:" required>
+        <el-radio-group v-model="modalForm.questionDirection" size="small">
+          <el-radio-button
+            v-for="(val, key) in DIRECTION_TYPE"
+            :key="key"
+            :label="key"
+            >{{ val }}</el-radio-button
+          >
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item>
+        <el-checkbox
+          v-model="modalForm.isMultiply"
+          :disabled="modalForm.isBoolean"
+          >多选</el-checkbox
+        >
+      </el-form-item>
       <el-form-item>
         <el-checkbox
-          v-model="modalForm.isFill"
+          v-model="modalForm.isBoolean"
           @change="selectTypeChange"
           :disabled="modalForm.isMultiply"
           >判断题</el-checkbox
         >
-      </el-form-item>
-      <el-form-item>
-        <el-checkbox v-model="modalForm.isMultiply" :disabled="modalForm.isFill"
-          >多选</el-checkbox
+        <el-select
+          v-if="modalForm.isBoolean"
+          v-model="modalForm.booleanType"
+          style="margin-left: 20px;width:125px;"
+          placeholder="请选择"
+          @change="booleanTypeChange"
         >
+          <el-option
+            v-for="item in BOOLEAN_TYPE"
+            :key="item"
+            :label="item"
+            :value="item"
+          ></el-option>
+        </el-select>
+        <span v-if="modalForm.isBoolean">(备选是否配置)</span>
+      </el-form-item>
+      <el-form-item
+        v-if="modalForm.isBoolean"
+        prop="booleanType"
+        label="是否配置:"
+      >
+        <span>是:</span>
+        <el-input
+          v-model.trim="booleanTypes.yes"
+          :maxlength="2"
+          placeholder="是"
+          style="margin-right: 20px;width:60px;"
+        ></el-input>
+        <span>否:</span>
+        <el-input
+          v-model.trim="booleanTypes.no"
+          :maxlength="2"
+          placeholder="否"
+          style="margin-right: 20px;width:60px;"
+        ></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>
+  </div>
 </template>
 
 <script>
+import { BOOLEAN_TYPE, DIRECTION_TYPE } from "../../enumerate";
+
 const initModalForm = {
   id: "",
-  topicNo: null,
   topicName: "",
   startNumber: 1,
   endNumber: 5,
   questionsCount: 10,
   optionCount: 5,
-  isFill: false,
+  questionDirection: "horizontal",
+  isBoolean: false,
+  booleanType: BOOLEAN_TYPE[0],
   isMultiply: false
 };
 
@@ -114,12 +138,6 @@ export default {
       default() {
         return {};
       }
-    },
-    topicNos: {
-      type: Array,
-      default() {
-        return [];
-      }
     }
   },
   data() {
@@ -134,27 +152,31 @@ export default {
       }
     };
 
-    const topicNoValidater = (rule, value, callback) => {
-      if (!this.instance.topicNo) {
-        // 新增题目
-        if (this.topicNos.includes(value)) {
-          callback(new Error("当前大题题号已经存在,请重新输入"));
-        } else {
+    const booleanTypeValidater = (rule, value, callback) => {
+      if (this.modalForm.isBoolean) {
+        if (
+          this.booleanTypes.yes &&
+          this.booleanTypes.no &&
+          this.booleanTypes.yes.length <= 2 &&
+          this.booleanTypes.no.length <= 2
+        ) {
           callback();
-        }
-      } else {
-        // 修改题目
-        if (value !== this.instance.topicNo && this.topicNos.includes(value)) {
-          callback(new Error("当前大题题号已经存在,请重新输入"));
         } else {
-          callback();
+          callback(new Error("请设置是否配置,单个设置最多两个字符。"));
         }
+      } else {
+        callback();
       }
     };
 
     return {
-      dialogIsShow: false,
       modalForm: { ...initModalForm },
+      BOOLEAN_TYPE,
+      DIRECTION_TYPE,
+      booleanTypes: {
+        yes: "",
+        no: ""
+      },
       rules: {
         topicName: [
           {
@@ -163,18 +185,6 @@ export default {
             trigger: "change"
           }
         ],
-        topicNo: [
-          {
-            required: true,
-            message: "请输入大题题号",
-            trigger: "change"
-          },
-          {
-            type: "number",
-            validator: topicNoValidater,
-            trigger: "change"
-          }
-        ],
         endNumber: [
           {
             required: true,
@@ -194,34 +204,40 @@ export default {
             message: "请输入选项个数",
             trigger: "change"
           }
+        ],
+        booleanType: [
+          {
+            required: true,
+            validator: booleanTypeValidater,
+            trigger: "change"
+          }
         ]
       }
     };
   },
+  mounted() {
+    this.initData(this.instance);
+  },
   methods: {
     initData(val) {
       const valInfo = val.parent || val;
-      this.modalForm = { ...valInfo };
+      this.modalForm = Object.assign({}, this.initModalForm, valInfo);
       this.modalForm.endNumber =
         this.modalForm.startNumber + this.modalForm.questionsCount - 1;
+      this.booleanTypeChange();
     },
     selectTypeChange(val) {
       if (val) {
         this.modalForm.optionCount = 2;
         this.modalForm.isMultiply = false;
+        this.modalForm.booleanType = BOOLEAN_TYPE[0];
+        this.booleanTypeChange();
       }
     },
-    opened() {
-      this.initData(this.instance);
-    },
-    closed() {
-      this.$emit("closed");
-    },
-    cancel() {
-      this.dialogIsShow = false;
-    },
-    open() {
-      this.dialogIsShow = true;
+    booleanTypeChange() {
+      const [yes, no] = this.modalForm.booleanType.split(",");
+      this.booleanTypes.yes = yes;
+      this.booleanTypes.no = no;
     },
     async submit() {
       const valid = await this.$refs.modalFormComp.validate().catch(() => {});
@@ -229,8 +245,11 @@ export default {
 
       this.modalForm.questionsCount =
         this.modalForm.endNumber - this.modalForm.startNumber + 1;
+      this.modalForm.booleanType = [
+        this.booleanTypes.yes,
+        this.booleanTypes.no
+      ].join();
       this.$emit("modified", this.modalForm);
-      this.cancel();
     }
   }
 };

+ 29 - 18
src/modules/card/components/elementPreview/FillQuestion.vue → card/elements/fill-question/ElemFillQuestion.vue

@@ -73,28 +73,39 @@ export default {
     parseQuestion(data) {
       let questionNo = data.startNumber;
       let questions = [];
-      const choiceList = this.getChoiceList(data.optionCount, data.isFill);
-      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];
+      const choiceList = this.getChoiceList(data);
+      if (data.questionDirection === "vertical") {
+        const groupNum = Math.ceil(
+          data.questionsCount / data.questionCountPerGroup
+        );
+        for (let i = 0; i < groupNum; i++) {
+          questions[i] = [];
+          const questionCountPerGroup =
+            i === groupNum - 1
+              ? data.questionsCount - data.questionCountPerGroup * i
+              : data.questionCountPerGroup;
+          for (let j = 0; j < questionCountPerGroup; j++) {
+            questions[i][j] = [questionNo++, ...choiceList];
+          }
+        }
+      } else {
+        for (let i = 0; i < data.questionsCount; i++) {
+          const groupIndex = i % data.groupPerLine;
+          if (!questions[groupIndex]) questions[groupIndex] = [];
+          questions[groupIndex].push([questionNo++, ...choiceList]);
         }
       }
       this.questions = questions;
     },
-    getChoiceList(num, isFill) {
-      const options = !isFill ? "abcdefghijklmnopqrstuv" : "√×";
-      return options
-        .toUpperCase()
-        .slice(0, num)
-        .split("");
+    getChoiceList(data) {
+      if (data.isBoolean) {
+        return data.booleanType.split(",");
+      } else {
+        return "abcdefghijklmnopqrstuv"
+          .toUpperCase()
+          .slice(0, data.optionCount)
+          .split("");
+      }
     }
   },
   watch: {

+ 85 - 0
card/elements/fill-question/model.js

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

+ 105 - 0
card/elements/grids/EditGrids.vue

@@ -0,0 +1,105 @@
+<template>
+  <div class="edit-grids">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :key="modalForm.id"
+      label-width="100px"
+    >
+      <el-form-item prop="columnCount" label="单行网格数:">
+        <el-input-number
+          style="width:125px;"
+          v-model.number="modalForm.columnCount"
+          :min="2"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="rowCount" label="网格行数:">
+        <el-input-number
+          style="width:125px;"
+          v-model.number="modalForm.rowCount"
+          :min="2"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="rowSpace" label="网格行间距:">
+        <el-input-number
+          style="width:125px;"
+          v-model.number="modalForm.rowSpace"
+          :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.number="modalForm.columnSize"
+          :min="0"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+          :disabled="modalForm.halving"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item>
+        <el-checkbox v-model="modalForm.halving">是否自动网格宽度</el-checkbox>
+      </el-form-item>
+      <el-form-item label="线条形状:">
+        <line-style-select v-model="modalForm.style"></line-style-select>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import LineStyleSelect from "../../components/common/LineStyleSelect";
+
+const initModalForm = {
+  id: "",
+  columnSize: 43,
+  columnCount: 16,
+  rowCount: 3,
+  rowSpace: 0,
+  halving: true,
+  style: "solid"
+};
+
+export default {
+  name: "edit-grids",
+  components: { LineStyleSelect },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      modalForm: { ...initModalForm }
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+    },
+    submit() {
+      this.$emit("modified", this.modalForm);
+    }
+  }
+};
+</script>

+ 63 - 0
card/elements/grids/ElemGrids.vue

@@ -0,0 +1,63 @@
+<template>
+  <div :class="classes">
+    <table :style="tableStyles">
+      <template v-for="(row, rindex) in data.rowCount">
+        <tr :key="rindex">
+          <td
+            v-for="(col, cindex) in data.columnCount"
+            :key="cindex"
+            :style="styles"
+          >
+            <div v-if="data.halving" style="paddingBottom: 100%;"></div>
+          </td>
+        </tr>
+        <tr :key="`space-${rindex}`" v-if="data.rowSpace">
+          <td :style="rowSpaceStyle" :colspan="data.columnCount"></td>
+        </tr>
+      </template>
+    </table>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "elem-grids",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    classes() {
+      return ["elem-grids", { "elem-grids-halving": this.data.halving }];
+    },
+    styles() {
+      let data = {
+        borderStyle: this.data.style
+      };
+      if (!this.data.halving) {
+        data.width = this.data.columnSize + "px";
+        data.height = this.data.columnSize + "px";
+      }
+      return data;
+    },
+    tableStyles() {
+      let data = {};
+      if (!this.data.halving) {
+        data.width = this.data.columnSize * this.data.columnCount + "px";
+      }
+      return data;
+    },
+    rowSpaceStyle() {
+      return {
+        height: this.data.rowSpace + "px",
+        borderStyle: this.data.style
+      };
+    }
+  },
+  methods: {}
+};
+</script>

+ 25 - 0
card/elements/grids/model.js

@@ -0,0 +1,25 @@
+import { getElementId } from "../../plugins/utils";
+
+const MODEL = {
+  type: "GRIDS",
+  x: 0,
+  y: 0,
+  w: 300,
+  h: 130,
+  sign: "",
+  columnSize: 43,
+  columnCount: 16,
+  rowCount: 3,
+  rowSpace: 0,
+  halving: true,
+  style: "solid"
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    ...MODEL
+  };
+};
+
+export { MODEL, getModel };

+ 7 - 33
src/modules/card/components/elementPropEdit/EditImage.vue → card/elements/image/EditImage.vue

@@ -1,16 +1,5 @@
 <template>
-  <el-dialog
-    class="edit-image edit-dialog"
-    :visible.sync="dialogIsShow"
-    title="图片编辑"
-    top="10vh"
-    width="640px"
-    :close-on-click-modal="false"
-    :close-on-press-escape="false"
-    append-to-body
-    @open="opened"
-    @close="closed"
-  >
+  <div class="edit-image">
     <el-form
       ref="modalFormComp"
       :model="modalForm"
@@ -37,16 +26,12 @@
         <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>
+  </div>
 </template>
 
 <script>
-import ColorSelect from "../common/ColorSelect";
-import LineStyleSelect from "../common/LineStyleSelect";
+import ColorSelect from "../../components/common/ColorSelect";
+import LineStyleSelect from "../../components/common/LineStyleSelect";
 
 const initModalForm = {
   id: "",
@@ -68,11 +53,13 @@ export default {
   },
   data() {
     return {
-      dialogIsShow: false,
       imageSrc: "",
       modalForm: { ...initModalForm }
     };
   },
+  mounted() {
+    this.initData(this.instance);
+  },
   methods: {
     initData(val) {
       this.modalForm = { ...val };
@@ -102,21 +89,8 @@ export default {
         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();
     }
   }
 };

+ 0 - 0
src/modules/card/components/elementPreview/ElemImage.vue → card/elements/image/ElemImage.vue


+ 22 - 0
card/elements/image/model.js

@@ -0,0 +1,22 @@
+import { getElementId } from "../../plugins/utils";
+
+const MODEL = {
+  type: "IMAGE",
+  x: 0,
+  y: 0,
+  w: 150,
+  h: 100,
+  sign: "",
+  borderColor: "",
+  borderStyle: "",
+  content: []
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    ...MODEL
+  };
+};
+
+export { MODEL, getModel };

+ 8 - 34
src/modules/card/components/elementPropEdit/EditLine.vue → card/elements/line/EditLine.vue

@@ -1,16 +1,5 @@
 <template>
-  <el-dialog
-    class="edit-line edit-dialog"
-    :visible.sync="dialogIsShow"
-    title="线条编辑"
-    top="10vh"
-    width="640px"
-    :close-on-click-modal="false"
-    :close-on-press-escape="false"
-    append-to-body
-    @open="opened"
-    @close="closed"
-  >
+  <div class="edit-line">
     <el-form
       ref="modalFormComp"
       :model="modalForm"
@@ -27,17 +16,13 @@
         <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>
+  </div>
 </template>
 
 <script>
-import ColorSelect from "../common/ColorSelect";
-import LineStyleSelect from "../common/LineStyleSelect";
-import LineWidthSelect from "../common/LineWidthSelect";
+import ColorSelect from "../../components/common/ColorSelect";
+import LineStyleSelect from "../../components/common/LineStyleSelect";
+import LineWidthSelect from "../../components/common/LineWidthSelect";
 
 const initModalForm = {
   id: "",
@@ -59,29 +44,18 @@ export default {
   },
   data() {
     return {
-      dialogIsShow: false,
       modalForm: { ...initModalForm }
     };
   },
+  mounted() {
+    this.initData(this.instance);
+  },
   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();
     }
   }
 };

+ 43 - 0
card/elements/line/ElemLine.vue

@@ -0,0 +1,43 @@
+<template>
+  <div :class="classes">
+    <div class="line-body" :style="styles"></div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "elem-line-horizontal",
+  props: {
+    data: {
+      type: Object
+    }
+  },
+  computed: {
+    classes() {
+      return [
+        "elem-line",
+        this.data.type === "LINE_HORIZONTAL"
+          ? "elem-line-horizontal"
+          : "elem-line-vertical"
+      ];
+    },
+    styles() {
+      return this.data.type === "LINE_HORIZONTAL"
+        ? {
+            borderBottomStyle: this.data.style,
+            borderBottomWidth: this.data.bold,
+            borderBottomColor: this.data.color
+          }
+        : {
+            borderLeftStyle: this.data.style,
+            borderLeftWidth: this.data.bold,
+            borderLeftColor: this.data.color
+          };
+    }
+  },
+  data() {
+    return {};
+  },
+  methods: {}
+};
+</script>

+ 35 - 0
card/elements/line/model.js

@@ -0,0 +1,35 @@
+import { getElementId } from "../../plugins/utils";
+
+const LINE_HORIZONTAL = {
+  type: "LINE_HORIZONTAL",
+  x: 0,
+  y: 0,
+  w: 300,
+  h: 30,
+  sign: "",
+  bold: "1px",
+  color: "#000000",
+  style: "solid"
+};
+// 竖线
+const LINE_VERTICAL = {
+  type: "LINE_VERTICAL",
+  x: 0,
+  y: 0,
+  w: 30,
+  h: 300,
+  sign: "",
+  bold: "1px",
+  color: "#000000",
+  style: "solid"
+};
+
+const getModel = type => {
+  const model = type === "HORIZONTAL" ? LINE_HORIZONTAL : LINE_VERTICAL;
+  return {
+    id: getElementId(),
+    ...model
+  };
+};
+
+export { LINE_HORIZONTAL, LINE_VERTICAL, getModel };

+ 98 - 0
card/elements/lines/EditLines.vue

@@ -0,0 +1,98 @@
+<template>
+  <div class="edit-lines">
+    <el-form
+      ref="modalFormComp"
+      :model="modalForm"
+      :key="modalForm.id"
+      label-width="100px"
+    >
+      <el-form-item prop="lineCount" label="线条数量:">
+        <el-input-number
+          style="width:125px;"
+          v-model.number="modalForm.lineCount"
+          :min="2"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item prop="lineSpacing" label="线条间距:">
+        <el-input-number
+          style="width:125px;"
+          v-model.number="modalForm.lineSpacing"
+          :min="2"
+          :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.number="modalForm.margin"
+          :min="0"
+          :max="100"
+          :step="1"
+          step-strictly
+          :controls="false"
+        ></el-input-number>
+      </el-form-item>
+      <el-form-item label="线条颜色:">
+        <color-select v-model="modalForm.color"></color-select>
+      </el-form-item>
+      <el-form-item label="线条粗细:">
+        <line-width-select v-model="modalForm.bold"></line-width-select>
+      </el-form-item>
+      <el-form-item label="线条形状:">
+        <line-style-select v-model="modalForm.style"></line-style-select>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import ColorSelect from "../../components/common/ColorSelect";
+import LineStyleSelect from "../../components/common/LineStyleSelect";
+import LineWidthSelect from "../../components/common/LineWidthSelect";
+
+const initModalForm = {
+  id: "",
+  lineCount: 2,
+  lineSpacing: 10,
+  margin: 0,
+  bold: "1px",
+  color: "#000000",
+  style: "solid"
+};
+
+export default {
+  name: "edit-lines",
+  components: { ColorSelect, LineStyleSelect, LineWidthSelect },
+  props: {
+    instance: {
+      type: Object,
+      default() {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      modalForm: { ...initModalForm }
+    };
+  },
+  mounted() {
+    this.initData(this.instance);
+  },
+  methods: {
+    initData(val) {
+      this.modalForm = { ...val };
+    },
+    submit() {
+      this.$emit("modified", this.modalForm);
+    }
+  }
+};
+</script>

+ 14 - 3
src/modules/card/components/elementPreview/ElemLineHorizontal.vue → card/elements/lines/ElemLines.vue

@@ -1,6 +1,13 @@
 <template>
-  <div class="elem-line-horizontal">
-    <div class="line-body" :style="styles"></div>
+  <div :class="classes">
+    <div class="lines-body">
+      <div
+        class="lines-item"
+        v-for="n in this.data.lineCount"
+        :key="n"
+        :style="styles"
+      ></div>
+    </div>
   </div>
 </template>
 
@@ -13,12 +20,16 @@ export default {
     }
   },
   computed: {
+    classes() {
+      return ["elem-lines"];
+    },
     styles() {
       return {
         borderBottomStyle: this.data.style,
         borderBottomWidth: this.data.bold,
         borderBottomColor: this.data.color,
-        width: this.w + "px"
+        paddingTop: this.data.lineSpacing + "px",
+        margin: this.data.margin ? `0 ${this.data.margin}px` : "0"
       };
     }
   },

+ 25 - 0
card/elements/lines/model.js

@@ -0,0 +1,25 @@
+import { getElementId } from "../../plugins/utils";
+
+const MODEL = {
+  type: "LINES",
+  x: 0,
+  y: 0,
+  w: 300,
+  h: 100,
+  sign: "",
+  lineCount: 2,
+  lineSpacing: 40,
+  margin: 0, // 左右边距
+  bold: "1px",
+  color: "#000000",
+  style: "solid"
+};
+
+const getModel = () => {
+  return {
+    id: getElementId(),
+    ...MODEL
+  };
+};
+
+export { MODEL, getModel };

+ 8 - 35
src/modules/card/components/elementPropEdit/EditText.vue → card/elements/text/EditText.vue

@@ -1,16 +1,5 @@
 <template>
-  <el-dialog
-    class="edit-text edit-dialog"
-    :visible.sync="dialogIsShow"
-    title="文本编辑"
-    top="10vh"
-    width="640px"
-    :close-on-click-modal="false"
-    :close-on-press-escape="false"
-    append-to-body
-    @open="opened"
-    @close="closed"
-  >
+  <div class="edit-text">
     <el-form
       ref="modalFormComp"
       :model="modalForm"
@@ -50,17 +39,13 @@
         </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>
+  </div>
 </template>
 
 <script>
-import SizeSelect from "../common/SizeSelect";
-import ColorSelect from "../common/ColorSelect";
-import FontFamilySelect from "../common/FontFamilySelect";
+import SizeSelect from "../../components/common/SizeSelect";
+import ColorSelect from "../../components/common/ColorSelect";
+import FontFamilySelect from "../../components/common/FontFamilySelect";
 
 const initModalForm = {
   id: "",
@@ -90,7 +75,6 @@ export default {
   },
   data() {
     return {
-      dialogIsShow: false,
       modalForm: { ...initModalForm },
       isBold: false,
       rules: {
@@ -104,6 +88,9 @@ export default {
       }
     };
   },
+  mounted() {
+    this.initData(this.instance);
+  },
   methods: {
     initData(val) {
       const contentStr = val.content
@@ -141,24 +128,10 @@ export default {
       });
       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().catch(() => {});
       if (!valid) return;
-
       this.$emit("modified", this.modalForm);
-      this.cancel();
     }
   }
 };

+ 0 - 0
src/modules/card/components/elementPreview/ElemText.vue → card/elements/text/ElemText.vue


+ 29 - 0
card/elements/text/model.js

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

+ 0 - 0
src/modules/card/components/elementPreview/TopicHead.vue → card/elements/topic-head/TopicHead.vue


+ 27 - 0
card/elements/topic-head/model.js

@@ -0,0 +1,27 @@
+import { getElementId } from "../../plugins/utils";
+
+const MODEL = {
+  type: "TOPIC_HEAD",
+  x: 0,
+  y: 0,
+  w: 0,
+  h: 60,
+  content: "",
+  typeName: "",
+  isColumnFirst: false,
+  sign: "objective" // objective:客观题,subjective:主观题
+};
+
+const getModel = (content, type, isColumnFirst) => {
+  const typeName = type === "objective" ? "客观题" : "主观题";
+  const element = { ...MODEL };
+  element.sign = type;
+  element.typeName = typeName;
+  element.content = content;
+  element.isColumnFirst = isColumnFirst;
+  element.h = isColumnFirst ? element.h : element.h + 10;
+  element.id = getElementId();
+  return element;
+};
+
+export { MODEL, getModel };

+ 63 - 0
card/enumerate.js

@@ -0,0 +1,63 @@
+export const CARD_VERSION = "1.0.0";
+
+export const EXAM_NUMBER_STYLE = {
+  0: "印刷条码",
+  1: "粘贴条码",
+  2: "考号填涂"
+};
+export const EXAM_NUMBER_STYLE_MAP = {
+  0: "auto",
+  1: "empty",
+  2: "fill"
+};
+
+export const PAPER_TYPE = {
+  0: "印刷",
+  1: "填涂"
+};
+
+export const PAPER_TYPE_MAP = {
+  0: "auto",
+  1: "fill"
+};
+
+export const BOOLEAN_TYPE = ["√,×", "是,否", "对,错"];
+
+export const DIRECTION_TYPE = {
+  horizontal: "横向",
+  vertical: "纵向"
+};
+
+export const transformField = data => {
+  const businessParams = [
+    ...JSON.parse(data.examMustColumn),
+    ...JSON.parse(data.examExtendColumn)
+  ]
+    .filter(item => item.select)
+    .map(item => {
+      return {
+        name: item.name,
+        field: item.code
+      };
+    });
+
+  const config = {
+    missAndFill: !!data.examAbsent,
+    writeSign: !!data.writeSign,
+    examNumberStyle: EXAM_NUMBER_STYLE_MAP[data.examNumberStyle], // auto:自动条码, empty:手动条码, fill:手动涂填
+    // aOrBSystem: true, // 后台附带的aOrB设置,如果有则使用这个值,如果没有则前台自动设置
+    aOrBType: PAPER_TYPE_MAP[data.paperType], // fill:手动涂填,auto:自动条码
+    schoolName: data.schoolName,
+    businessParams,
+    noticeHead: data.attention.split("\n") || [],
+    objectiveNotice: data.objectiveAttention,
+    subjectiveNotice: data.subjectiveAttention
+  };
+  return config;
+};
+
+export const getAOrBSystem = data => {
+  return data["enablePaperType"]
+    ? data.enablePaperType.split(",").length > 1
+    : null;
+};

+ 20 - 0
card/main.js

@@ -0,0 +1,20 @@
+import Vue from "vue";
+import App from "./App.vue";
+import router from "./router";
+import store from "./store";
+
+import ElementUI from "element-ui";
+import "element-ui/lib/theme-chalk/index.css";
+import "./assets/styles/index.scss";
+import VueLocalStorage from "vue-ls";
+
+Vue.use(VueLocalStorage);
+
+Vue.config.productionTip = false;
+Vue.use(ElementUI);
+
+new Vue({
+  router,
+  store,
+  render: h => h(App)
+}).$mount("#app");

+ 21 - 80
src/modules/card/components/SavePage.vue → card/mixins/exchange.js

@@ -1,13 +1,5 @@
-<template>
-  <el-button type="primary" @click="save" :disabled="disabled || !pages.length"
-    >暂存</el-button
-  >
-</template>
-
-<script>
-import { mapState } from "vuex";
 import { CARD_VERSION } from "../enumerate";
-import { deepCopy } from "@/plugins/utils";
+import { deepCopy } from "../plugins/utils";
 
 const initIndex = {
   question: 1,
@@ -19,13 +11,6 @@ const initIndex = {
 };
 
 export default {
-  name: "save-page",
-  props: {
-    disabled: {
-      type: Boolean,
-      default: false
-    }
-  },
   data() {
     return {
       fillAreaIndex: {
@@ -37,14 +22,11 @@ export default {
         "CARD_HEAD",
         "FILL_QUESTION",
         "FILL_LINE",
-        "EXPLAIN_CHILDREN",
+        "EXPLAIN",
         "COMPOSITION"
       ]
     };
   },
-  computed: {
-    ...mapState("card", ["pages", "cardConfig", "paperParams"])
-  },
   methods: {
     getFillAreaIndex(type) {
       return this.fillAreaIndex[type]++;
@@ -55,10 +37,13 @@ export default {
         .map(item => item[0] + item.substr(1).toLowerCase())
         .join("");
     },
-    parsePageExchange() {
-      const pages = deepCopy(this.pages);
+    getPreviewElementById(id) {
+      return document.getElementById(`preview-${id}`);
+    },
+    parsePageExchange(pages) {
+      const npages = deepCopy(pages);
       const pageNumberInfo = this.getPageNumberInfo();
-      pages.forEach((page, pindex) => {
+      npages.forEach((page, pindex) => {
         let exchange = {
           locator: this.getLocatorInfo(page.locators),
           barcode: [],
@@ -75,6 +60,7 @@ export default {
           elemGroup.forEach(element => {
             if (this.VALID_ELEMENTS_FOR_EXTERNAL.includes(element.type)) {
               const funcName = this.getElementHumpName(element.type);
+              console.log(funcName);
               const info = this[`get${funcName}Info`](element);
               Object.entries(info).forEach(([key, vals]) => {
                 exchange[key] = exchange[key].concat(vals);
@@ -94,7 +80,7 @@ export default {
 
       this.fillAreaIndex = { ...initIndex };
 
-      return pages;
+      return npages;
     },
     getPageNumberInfo() {
       const dom = document.querySelector(".page-box-0");
@@ -131,23 +117,8 @@ export default {
         };
       });
     },
-    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 dom = this.getPreviewElementById(element.id);
       const headArea = this.getOffsetInfo(dom);
       let fill_area = [];
       let barcode = [];
@@ -247,7 +218,7 @@ export default {
       };
     },
     getFillQuestionInfo(element) {
-      const dom = document.getElementById(element.id);
+      const dom = this.getPreviewElementById(element.id);
       const single = !element.isMultiply;
       const horizontal = element.optionDirection === "horizontal";
 
@@ -283,7 +254,7 @@ export default {
       };
     },
     getFillLineInfo(element) {
-      const dom = document.getElementById(element.id);
+      const dom = this.getPreviewElementById(element.id);
 
       return {
         answer_area: [
@@ -298,47 +269,21 @@ export default {
         ]
       };
     },
-    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("question"),
-            single: true,
-            horizontal: element.optionDirection === "horizontal",
-            items: listInfos
-          }
-        ]
-      };
-    },
-    getExplainChildrenInfo(element) {
-      const dom = document.getElementById(element.id);
+    getExplainInfo(element) {
+      const dom = this.getPreviewElementById(element.id);
 
       return {
         answer_area: [
           {
             main_number: element.topicNo,
-            sub_numbers: [element.explainNumber],
+            sub_numbers: [element.serialNumber],
             area: this.getOffsetInfo(dom)
           }
         ]
       };
     },
     getCompositionInfo(element) {
-      const dom = document.getElementById(element.id);
+      const dom = this.getPreviewElementById(element.id);
 
       return {
         answer_area: [
@@ -370,20 +315,16 @@ export default {
 
       return infos.map(num => num.toFixed(10) * 1);
     },
-    getPageModel() {
+    getPageModel({ cardConfig, paperParams, pages }) {
       return JSON.stringify(
         {
           version: CARD_VERSION,
-          cardConfig: this.cardConfig,
-          paperParams: this.paperParams,
-          pages: this.parsePageExchange()
+          cardConfig,
+          paperParams,
+          pages: this.parsePageExchange(pages)
         },
         (k, v) => (k.startsWith("_") ? undefined : v)
       );
-    },
-    save() {
-      this.$emit("confirm");
     }
   }
 };
-</script>

+ 192 - 0
card/mixins/guideLines.js

@@ -0,0 +1,192 @@
+export default {
+  data() {
+    return {
+      deviation: 5, // 对齐误差范围
+      xLines: [],
+      yLines: []
+    };
+  },
+  methods: {
+    getMax(arr) {
+      return Math.max.apply(null, arr);
+    },
+    getMin(arr) {
+      return Math.min.apply(null, arr);
+    },
+    getPoints(item) {
+      // 权重关系:先左后右
+      // 具体:左上 > 左下 > 中心 > 右上 > 右下
+      return [
+        { x: item.x, y: item.y },
+        { x: item.x, y: item.y + item.h },
+        { x: item.x + item.w / 2, y: item.y + item.h / 2 },
+        { x: item.x + item.w, y: item.y },
+        { x: item.x + item.w, y: item.y + item.h }
+      ];
+    },
+    rebuild(elements, curElement, actionType = "move") {
+      this.clear();
+      const curPoints = this.getPoints(curElement);
+      const envPoints = elements
+        .filter(item => item.id !== curElement.id)
+        .map(item => this.getPoints(item));
+      let fitPoints = [];
+      // 提取可能的辅助线
+      // y线分三种:左中右
+      // x线分三种:上中下
+      let xMaybeLines = {},
+        yMaybeLines = {};
+      const yPointTypes = {
+        0: "left",
+        1: "left",
+        2: "center",
+        3: "right",
+        4: "right"
+      };
+      const xPointTypes = {
+        0: "top",
+        1: "bottom",
+        2: "center",
+        3: "top",
+        4: "bottom"
+      };
+      envPoints.forEach(points => {
+        points.forEach((point, pindex) => {
+          // 中心点的对齐误差值取半数误差值
+          const deviation = pindex === 2 ? this.deviation / 2 : this.deviation;
+          const curPoint = curPoints[pindex];
+          if (
+            curPoint.x <= point.x + deviation &&
+            curPoint.x >= point.x - deviation
+          ) {
+            if (!yMaybeLines[yPointTypes[pindex]])
+              yMaybeLines[yPointTypes[pindex]] = [];
+            yMaybeLines[yPointTypes[pindex]].push({
+              pindex,
+              x: point.x,
+              y: Math.min(curPoint.y, point.y),
+              h: Math.abs(curPoint.y - point.y),
+              offset: Math.abs(curPoint.x - point.x)
+            });
+          }
+          if (
+            curPoint.y <= point.y + deviation &&
+            curPoint.y >= point.y - deviation
+          ) {
+            if (!xMaybeLines[xPointTypes[pindex]])
+              xMaybeLines[xPointTypes[pindex]] = [];
+            xMaybeLines[xPointTypes[pindex]].push({
+              pindex,
+              x: Math.min(curPoint.x, point.x),
+              y: point.y,
+              w: Math.abs(curPoint.x - point.x),
+              offset: Math.abs(curPoint.y - point.y)
+            });
+          }
+        });
+      });
+      // 过滤误差过大的辅助线,即取误差最小的辅助线
+      let yLines = [];
+      Object.values(yMaybeLines).forEach(lines => {
+        lines.sort((a, b) => a.offset - b.offset);
+        const validX = lines[0].x;
+        const validLines = lines.filter(line => line.x === validX);
+        validLines.forEach(line => {
+          const pindex = line.pindex;
+          if (!fitPoints[pindex]) fitPoints[pindex] = {};
+          fitPoints[pindex].x = validX;
+        });
+        yLines = [...yLines, ...validLines];
+      });
+      let xLines = [];
+      Object.values(xMaybeLines).forEach(lines => {
+        lines.sort((a, b) => a.offset - b.offset);
+        const validY = lines[0].y;
+        const validLines = lines.filter(line => line.y === validY);
+        validLines.forEach(line => {
+          const pindex = line.pindex;
+          if (!fitPoints[pindex]) fitPoints[pindex] = {};
+          fitPoints[pindex].y = validY;
+        });
+        xLines = [...xLines, ...validLines];
+      });
+
+      // 合并辅助线
+      let yLinesMap = {};
+      yLines.forEach(line => {
+        if (!yLinesMap[line.x]) yLinesMap[line.x] = [];
+        yLinesMap[line.x].push(line);
+      });
+      this.yLines = Object.values(yLinesMap).map(lines => {
+        const lowH = this.getMax(lines.map(l => l.y + l.h));
+        const topY = this.getMin(lines.map(l => l.y));
+        return {
+          left: lines[0].x + "px",
+          top: topY + "px",
+          height: lowH - topY + "px"
+        };
+      });
+      let xLinesMap = {};
+      xLines.forEach(line => {
+        if (!xLinesMap[line.y]) xLinesMap[line.y] = [];
+        xLinesMap[line.y].push(line);
+      });
+      this.xLines = Object.values(xLinesMap).map(lines => {
+        const rightW = this.getMax(lines.map(l => l.x + l.w));
+        const leftX = this.getMin(lines.map(l => l.x));
+        return {
+          left: leftX + "px",
+          top: lines[0].y + "px",
+          width: rightW - leftX + "px"
+        };
+      });
+
+      // 计算自动贴合后的坐标点
+      if (actionType === "move") {
+        // 移动模式时,算偏移量
+        // 按照getPoints中设置的点顺序权重进行计算
+        // 只要获得结果,便停止计算
+        let xOffset = null,
+          yOffset = null;
+        curPoints.forEach((point, pindex) => {
+          const fitPoint = fitPoints[pindex] || {};
+          if (xOffset === null && fitPoint.x) {
+            xOffset = fitPoint.x - point.x;
+          }
+          if (yOffset === null && fitPoint.y) {
+            yOffset = fitPoint.y - point.y;
+          }
+        });
+        return {
+          x: curElement.x + xOffset,
+          y: curElement.y + yOffset,
+          w: curElement.w,
+          h: curElement.h
+        };
+      } else {
+        // 变形模式时,算贴合点
+        const newPoints = curPoints.map((point, pindex) => {
+          const fitPoint = fitPoints[pindex] || {};
+          return {
+            x: Object.prototype.hasOwnProperty.call(fitPoint, "x")
+              ? fitPoint.x
+              : point.x,
+            y: Object.prototype.hasOwnProperty.call(fitPoint, "y")
+              ? fitPoint.y
+              : point.y
+          };
+        });
+        return {
+          x: newPoints[0].x,
+          y: newPoints[0].y,
+          w: newPoints[4].x - newPoints[0].x,
+          h: newPoints[4].y - newPoints[0].y
+        };
+      }
+    },
+    clear() {
+      this.xLines = [];
+      this.yLines = [];
+    }
+  }
+};

+ 121 - 0
card/plugins/ajax.js

@@ -0,0 +1,121 @@
+/* eslint-disable no-prototype-builtins */
+// 新鉴权 to open
+// import { getAuthorisation } from "./crypto";
+
+// function getStorage(key) {
+//   const content = window.sessionStorage.getItem(key);
+//   return content ? JSON.parse(content).value : null;
+// }
+
+// function getAuthorHeader(uri, method) {
+//   const user = getStorage("user");
+
+//   const infos = getAuthorisation(
+//     {
+//       token: getStorage("token"),
+//       account: user && user["id"],
+//       uri,
+//       method
+//     },
+//     "token"
+//   );
+
+//   return {
+//     Authorization: infos.Authorization,
+//     time: infos.timestamp,
+//     deviceId: user && user["id"],
+//     domain: window.location.origin,
+//     platform: "print-web"
+//   };
+// }
+
+function getError(action, option, xhr) {
+  let msg;
+  if (xhr.response) {
+    msg = `${xhr.response.error || xhr.response}`;
+  } else if (xhr.responseText) {
+    msg = `${xhr.responseText}`;
+  } else {
+    msg = `fail to post ${action} ${xhr.status}`;
+  }
+
+  const err = new Error(msg);
+  err.status = xhr.status;
+  err.method = "post";
+  err.url = action;
+  return err;
+}
+
+function getBody(xhr) {
+  const text = xhr.responseText || xhr.response;
+  if (!text) {
+    return text;
+  }
+
+  try {
+    return JSON.parse(text);
+  } catch (e) {
+    return text;
+  }
+}
+
+export default function upload(option) {
+  if (typeof XMLHttpRequest === "undefined") {
+    return;
+  }
+
+  const xhr = new XMLHttpRequest();
+  const action = option.action;
+
+  if (xhr.upload) {
+    xhr.upload.onprogress = function progress(e) {
+      if (e.total > 0) {
+        e.percent = (e.loaded / e.total) * 100;
+      }
+      option.onProgress(e);
+    };
+  }
+
+  const formData = new FormData();
+
+  if (option.data) {
+    Object.keys(option.data).forEach(key => {
+      formData.append(key, option.data[key]);
+    });
+  }
+
+  formData.append(option.filename, option.file, option.file.name);
+
+  xhr.onerror = function error(e) {
+    option.onError(e);
+  };
+
+  xhr.onload = function onload() {
+    if (xhr.status < 200 || xhr.status >= 300) {
+      return option.onError(getError(action, option, xhr));
+    }
+
+    option.onSuccess(getBody(xhr));
+  };
+
+  xhr.open("post", action, true);
+
+  if (option.withCredentials && "withCredentials" in xhr) {
+    xhr.withCredentials = true;
+  }
+
+  let headers = option.headers || {};
+  // const authorHeader = getAuthorHeader(action, "post");
+
+  // for (let item in authorHeader) {
+  //   headers[item] = authorHeader[item];
+  // }
+
+  for (let item in headers) {
+    if (headers.hasOwnProperty(item) && headers[item] !== null) {
+      xhr.setRequestHeader(item, headers[item]);
+    }
+  }
+  xhr.send(formData);
+  return xhr;
+}

+ 23 - 0
card/plugins/md5.js

@@ -0,0 +1,23 @@
+const md5 = require("js-md5");
+
+/**
+ *
+ * @param {any} str 字符串
+ */
+export const MD5 = content => {
+  return md5(content);
+};
+
+export const fileMD5 = file => {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onloadend = function() {
+      const arrayBuffer = reader.result;
+      resolve(md5(arrayBuffer));
+    };
+    reader.onerror = function(err) {
+      reject(err);
+    };
+    reader.readAsArrayBuffer(file);
+  });
+};

+ 145 - 0
card/plugins/utils.js

@@ -0,0 +1,145 @@
+const deepmerge = require("deepmerge");
+
+/**
+ * 判断对象类型
+ * @param {*} obj 对象
+ */
+function objTypeOf(obj) {
+  const toString = Object.prototype.toString;
+  const map = {
+    "[object Boolean]": "boolean",
+    "[object Number]": "number",
+    "[object String]": "string",
+    "[object Function]": "function",
+    "[object Array]": "array",
+    "[object Date]": "date",
+    "[object RegExp]": "regExp",
+    "[object Undefined]": "undefined",
+    "[object Null]": "null",
+    "[object Object]": "object"
+  };
+  return map[toString.call(obj)];
+}
+
+/**
+ * 深拷贝
+ * @param {Object/Array} data 需要拷贝的数据
+ */
+function deepCopy(data, options) {
+  const defObj = objTypeOf(data) === "array" ? [] : {};
+  return deepmerge(defObj, data, options || {});
+}
+
+/**
+ * 将目标对象中有的属性值与源对象中的属性值合并
+ * @param {Object} target 目标对象
+ * @param {Object} sources 源对象
+ */
+function objAssign(target, sources) {
+  let targ = { ...target };
+  for (let k in targ) {
+    targ[k] = Object.prototype.hasOwnProperty.call(sources, k)
+      ? sources[k]
+      : targ[k];
+  }
+  return targ;
+}
+
+/**
+ * 获取随机code,默认获取16位
+ * @param {Number} len 推荐8的倍数
+ *
+ */
+function randomCode(len = 16) {
+  if (len <= 0) return;
+  let steps = Math.ceil(len / 8);
+  let stepNums = [];
+  for (let i = 0; i < steps; i++) {
+    let ranNum = Math.random()
+      .toString(32)
+      .slice(-8);
+    stepNums.push(ranNum);
+  }
+
+  return stepNums.join("");
+}
+
+/**
+ *
+ * @param {String} format 时间格式
+ * @param {Date} date 需要格式化的时间对象
+ */
+function formatDate(format = "YYYY/MM/DD HH:mm:ss", date = new Date()) {
+  if (objTypeOf(date) !== "date") return;
+  const options = {
+    "Y+": date.getFullYear(),
+    "M+": date.getMonth() + 1,
+    "D+": date.getDate(),
+    "H+": date.getHours(),
+    "m+": date.getMinutes(),
+    "s+": date.getSeconds()
+  };
+  Object.entries(options).map(([key, val]) => {
+    if (new RegExp("(" + key + ")").test(format)) {
+      const zeros = key === "Y+" ? "0000" : "00";
+      const value = (zeros + val).substr(("" + val).length);
+      format = format.replace(RegExp.$1, value);
+    }
+  });
+  return format;
+}
+
+/**
+ * 获取本地时间,格式:年月日时分秒
+ */
+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字符串
+ */
+function removeHtmlTag(str) {
+  return str.replace(/<[^>]+>/g, "");
+}
+
+/**
+ * 计算总数
+ * @param {Array} dataList 需要统计的数组
+ */
+function calcSum(dataList) {
+  return dataList.reduce(function(total, item) {
+    return total + item;
+  }, 0);
+}
+
+function isEmptyObject(obj) {
+  return !Object.keys(obj).length;
+}
+
+function getElementId() {
+  return `element-${randomCode()}`;
+}
+
+export {
+  objTypeOf,
+  deepCopy,
+  objAssign,
+  randomCode,
+  formatDate,
+  localNowDateTime,
+  getNumList,
+  removeHtmlTag,
+  calcSum,
+  isEmptyObject,
+  getElementId
+};

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff