Przeglądaj źródła

Merge branch 'master' of http://60.205.254.193:3000/GCLS/GCLS_Page_Textbook

# Conflicts:
#	src/views/book/courseware/data/bookType.js
zq 1 rok temu
rodzic
commit
5436888539
25 zmienionych plików z 1326 dodań i 460 usunięć
  1. 1 1
      .eslintrc.js
  2. 22 16
      package-lock.json
  3. 7 7
      package.json
  4. 21 0
      src/styles/mixin.scss
  5. 90 42
      src/views/book/chapter.vue
  6. 239 108
      src/views/book/courseware/create/components/CreateCanvas.vue
  7. 355 0
      src/views/book/courseware/create/components/PreviewEdit.vue
  8. 69 0
      src/views/book/courseware/create/components/WarnSave.vue
  9. 8 110
      src/views/book/courseware/create/components/base/audio/AudioSetting.vue
  10. 0 1
      src/views/book/courseware/create/components/base/picture/Picture.vue
  11. 7 104
      src/views/book/courseware/create/components/base/picture/PictureSetting.vue
  12. 9 2
      src/views/book/courseware/create/components/common/ModuleMixin.js
  13. 72 0
      src/views/book/courseware/create/components/common/SerialNumberPosition.vue
  14. 13 2
      src/views/book/courseware/create/components/common/SettingMixin.js
  15. 22 0
      src/views/book/courseware/create/components/question/select/Select.vue
  16. 66 0
      src/views/book/courseware/create/components/question/select/SelectSetting.vue
  17. 49 7
      src/views/book/courseware/create/index.vue
  18. 7 1
      src/views/book/courseware/data/audio.js
  19. 4 2
      src/views/book/courseware/data/bookType.js
  20. 0 7
      src/views/book/courseware/data/common.js
  21. 0 1
      src/views/book/courseware/data/picture.js
  22. 25 0
      src/views/book/courseware/data/select.js
  23. 205 0
      src/views/book/courseware/preview/CoursewarePreview.vue
  24. 2 2
      src/views/book/courseware/preview/components/common/PreviewMixin.js
  25. 33 47
      src/views/book/courseware/preview/index.vue

+ 1 - 1
.eslintrc.js

@@ -278,7 +278,7 @@ module.exports = {
     'max-len': [1, { code: 120, ignoreUrls: true, ignoreTemplateLiterals: true, ignoreRegExpLiterals: true }],
     'max-nested-callbacks': 1,
     'max-params': [1, 6],
-    'max-statements': [1, 40],
+    'max-statements': 0,
     'new-parens': 2,
     'object-shorthand': 1,
     'operator-assignment': 1,

+ 22 - 16
package-lock.json

@@ -10,7 +10,7 @@
       "dependencies": {
         "@tinymce/tinymce-vue": "^3.2.8",
         "axios": "^1.6.8",
-        "core-js": "^3.36.1",
+        "core-js": "^3.37.0",
         "element-ui": "^2.15.14",
         "js-cookie": "^3.0.5",
         "md5": "^2.3.0",
@@ -24,7 +24,7 @@
         "@babel/core": "^7.24.4",
         "@babel/eslint-parser": "^7.24.1",
         "@electron/fuses": "^1.8.0",
-        "@rushstack/eslint-patch": "^1.10.1",
+        "@rushstack/eslint-patch": "^1.10.2",
         "@types/md5": "^2.3.5",
         "@vue/cli-plugin-babel": "~5.0.8",
         "@vue/cli-plugin-eslint": "~5.0.8",
@@ -32,18 +32,18 @@
         "@vue/eslint-config-prettier": "^9.0.0",
         "@vue/preload-webpack-plugin": "^2.0.0",
         "compression-webpack-plugin": "^6.1.2",
-        "electron": "^29.2.0",
+        "electron": "^29.3.1",
         "electron-builder": "^24.13.3",
         "eslint": "^8.57.0",
         "eslint-plugin-prettier": "^5.1.3",
-        "eslint-plugin-vue": "^9.24.0",
+        "eslint-plugin-vue": "^9.25.0",
         "nodemon": "^3.1.0",
         "patch-package": "^8.0.0",
         "postcss-html": "^1.6.0",
         "prettier": "^3.2.5",
-        "sass": "^1.74.1",
-        "sass-loader": "^14.1.1",
-        "stylelint": "^16.0.2",
+        "sass": "^1.75.0",
+        "sass-loader": "^14.2.1",
+        "stylelint": "^15.11.0",
         "stylelint-config-recess-order": "^4.6.0",
         "stylelint-config-recommended-scss": "^14.0.0",
         "stylelint-config-recommended-vue": "^1.5.0",
@@ -2693,8 +2693,9 @@
     },
     "node_modules/@rushstack/eslint-patch": {
       "version": "1.10.2",
-      "dev": true,
-      "license": "MIT"
+      "resolved": "https://registry.npmmirror.com/@rushstack/eslint-patch/-/eslint-patch-1.10.2.tgz",
+      "integrity": "sha512-hw437iINopmQuxWPSUEvqE56NCPsiU8N4AYtfHmJFckclktzK9YQJieD3XkDCDH4OjL+C7zgPUh73R/nrcHrqw==",
+      "dev": true
     },
     "node_modules/@sideway/address": {
       "version": "4.1.5",
@@ -6040,13 +6041,10 @@
     },
     "node_modules/core-js": {
       "version": "3.37.0",
+      "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.37.0.tgz",
+      "integrity": "sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug==",
       "hasInstallScript": true,
-      "license": "MIT",
       "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/core-js"
-      }
-    },
     "node_modules/core-js-compat": {
       "version": "3.37.0",
       "dev": true,
@@ -7064,6 +7062,8 @@
     },
     "node_modules/electron": {
       "version": "29.3.1",
+      "resolved": "https://registry.npmmirror.com/electron/-/electron-29.3.1.tgz",
+      "integrity": "sha512-auge1/6RVqgUd6TgIq88wKdUCJi2cjESi3jy7d+6X4JzvBGprKBqMJ8JSSFpu/Px1YJrFUKAxfy6SC+TQf1uLw==",
       "dev": true,
       "hasInstallScript": true,
       "license": "MIT",
@@ -7683,6 +7683,8 @@
     },
     "node_modules/eslint-plugin-vue": {
       "version": "9.25.0",
+      "resolved": "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.25.0.tgz",
+      "integrity": "sha512-tDWlx14bVe6Bs+Nnh3IGrD+hb11kf2nukfm6jLsmJIhmiRQ1SUaksvwY9U5MvPB0pcrg0QK0xapQkfITs3RKOA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -14344,6 +14346,8 @@
     },
     "node_modules/sass": {
       "version": "1.75.0",
+      "resolved": "https://registry.npmmirror.com/sass/-/sass-1.75.0.tgz",
+      "integrity": "sha512-ShMYi3WkrDWxExyxSZPst4/okE9ts46xZmJDSawJQrnte7M1V9fScVB+uNXOVKRBt0PggHOwoZcn8mYX4trnBw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -14360,9 +14364,11 @@
     },
     "node_modules/sass-loader": {
       "version": "14.2.1",
+<<<<<<< HEAD
+      "resolved": "https://registry.npmmirror.com/sass-loader/-/sass-loader-14.2.1.tgz",
+      "integrity": "sha512-G0VcnMYU18a4N7VoNDegg2OuMjYtxnqzQWARVWCIVSZwJeiL9kg8QMsuIZOplsJgTzZLF6jGxI3AClj8I9nRdQ==",
+>>>>>>> 10d8fce36805905e73342239e50b7d7f8653bd55
       "dev": true,
-      "license": "MIT",
-      "dependencies": {
         "neo-async": "^2.6.2"
       },
       "engines": {

+ 7 - 7
package.json

@@ -16,7 +16,7 @@
   "dependencies": {
     "@tinymce/tinymce-vue": "^3.2.8",
     "axios": "^1.6.8",
-    "core-js": "^3.36.1",
+    "core-js": "^3.37.0",
     "element-ui": "^2.15.14",
     "js-cookie": "^3.0.5",
     "md5": "^2.3.0",
@@ -30,7 +30,7 @@
     "@babel/core": "^7.24.4",
     "@babel/eslint-parser": "^7.24.1",
     "@electron/fuses": "^1.8.0",
-    "@rushstack/eslint-patch": "^1.10.1",
+    "@rushstack/eslint-patch": "^1.10.2",
     "@types/md5": "^2.3.5",
     "@vue/cli-plugin-babel": "~5.0.8",
     "@vue/cli-plugin-eslint": "~5.0.8",
@@ -38,18 +38,18 @@
     "@vue/eslint-config-prettier": "^9.0.0",
     "@vue/preload-webpack-plugin": "^2.0.0",
     "compression-webpack-plugin": "^6.1.2",
-    "electron": "^29.2.0",
+    "electron": "^29.3.1",
     "electron-builder": "^24.13.3",
     "eslint": "^8.57.0",
     "eslint-plugin-prettier": "^5.1.3",
-    "eslint-plugin-vue": "^9.24.0",
+    "eslint-plugin-vue": "^9.25.0",
     "nodemon": "^3.1.0",
     "patch-package": "^8.0.0",
     "postcss-html": "^1.6.0",
     "prettier": "^3.2.5",
-    "sass": "^1.74.1",
-    "sass-loader": "^14.1.1",
-    "stylelint": "^16.0.2",
+    "sass": "^1.75.0",
+    "sass-loader": "^14.2.1",
+    "stylelint": "^15.11.0",
     "stylelint-config-recess-order": "^4.6.0",
     "stylelint-config-recommended-scss": "^14.0.0",
     "stylelint-config-recommended-vue": "^1.5.0",

+ 21 - 0
src/styles/mixin.scss

@@ -0,0 +1,21 @@
+@mixin setting-base {
+  .serial-number {
+    :deep .el-form-item__content {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    .svg-icon {
+      cursor: pointer;
+    }
+  }
+
+  .el-input {
+    margin-right: 16px;
+  }
+
+  .el-divider {
+    margin: 16px 0;
+  }
+}

+ 90 - 42
src/views/book/chapter.vue

@@ -23,33 +23,46 @@
       </div>
     </div>
 
-    <div
-      v-for="({ courseware_id }, i) in courseware_list"
-      :key="courseware_id"
-      class="book-content"
-      @dblclick="enterCourseware(courseware_id)"
-    >
-      <div class="book-content-top">
-        <span class="book-content-title">教材内容 {{ i + 1 }}</span>
-        <SvgIcon icon-class="delete" @click="deleteCourseware(courseware_id)" />
-        <SvgIcon icon-class="setup" />
-      </div>
-      <div class="tip">
-        <span>双击开始编辑教材内容 {{ i + 1 }}</span>
+    <div class="content-wrapper">
+      <div
+        v-for="(data, i) in coursewareDataList"
+        :key="data.courseware_id"
+        class="book-content"
+        @dblclick="enterCourseware(data.courseware_id, i)"
+      >
+        <div class="book-content-top">
+          <span class="book-content-title">教材内容 {{ i + 1 }}</span>
+          <SvgIcon icon-class="delete" @click="deleteCourseware(data.courseware_id)" />
+          <SvgIcon icon-class="setup" />
+        </div>
+        <div v-if="data.hasOwnProperty('row_list')" class="content">
+          <CoursewarePreview :data="data" />
+        </div>
+        <div v-else class="tip">
+          <span>双击开始编辑教材内容 {{ i + 1 }}</span>
+        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script>
-import { GetBookChapterStruct, GetCoursewareList_Chapter, AddCoursewareToBook, DeleteCourseware } from '@/api/book';
+import {
+  GetBookChapterStruct,
+  GetCoursewareList_Chapter,
+  AddCoursewareToBook,
+  DeleteCourseware,
+  GetCoursewareContent,
+} from '@/api/book';
 
 import CatalogueTree from './components/catalogueTree.vue';
+import CoursewarePreview from './courseware/preview/CoursewarePreview.vue';
 
 export default {
   name: 'ChapterPage',
   components: {
     CatalogueTree,
+    CoursewarePreview,
   },
   provide() {
     return {
@@ -67,6 +80,7 @@ export default {
       curPosition: [], // 当前位置
       nodes: [],
       courseware_list: [],
+      coursewareDataList: [],
     };
   },
   created() {
@@ -83,10 +97,15 @@ export default {
     enterPreview() {
       this.$router.push(`/preview/courseware/${this.book_id}?chapter_id=${this.curChapterId}`);
     },
-    enterCourseware(courseware_id) {
+    /**
+     * 进入教材内容编辑页
+     * @param {string} courseware_id 教材内容id
+     * @param {number} index 教材内容索引
+     */
+    enterCourseware(courseware_id, index) {
       this.$router.push({
         path: `/courseware/create/${courseware_id}`,
-        query: { book_id: this.book_id, chapter_id: this.chapter_id },
+        query: { book_id: this.book_id, chapter_id: this.chapter_id, index },
       });
     },
     getNodeName(index) {
@@ -102,8 +121,24 @@ export default {
     getCoursewareList_Chapter(chapter_id) {
       GetCoursewareList_Chapter({ chapter_id }).then(({ courseware_list }) => {
         this.courseware_list = courseware_list ?? [];
+        this.getCoursewareContent();
       });
     },
+    async getCoursewareContent() {
+      this.coursewareDataList = await Promise.all(
+        this.courseware_list.map(async ({ courseware_id }) => {
+          const { content } = await GetCoursewareContent({ id: courseware_id });
+          if (content) {
+            let _content = JSON.parse(content);
+            _content.courseware_id = courseware_id;
+            return _content;
+          }
+          return {
+            courseware_id,
+          };
+        }),
+      );
+    },
     /**
      * 根据节点id查找节点在nodes中的位置
      * @param {array} nodes 节点数组
@@ -228,39 +263,52 @@ export default {
     }
   }
 
-  .book-content {
-    height: 550px;
-    padding: 8px 12px;
-    cursor: pointer;
-    background: rgba(241, 246, 255, 44%);
-    border: 1px solid #005aff;
-    border-radius: 4px;
+  .content-wrapper {
+    overflow: auto;
 
-    &-top {
+    .book-content {
       display: flex;
-      column-gap: 8px;
-      align-items: center;
+      flex-direction: column;
+      min-height: 550px;
+      padding: 8px 12px;
+      cursor: pointer;
+      background: rgba(241, 246, 255, 44%);
+      border: 1px solid #005aff;
+      border-radius: 4px;
 
-      .book-content-title {
-        font-size: 14px;
-        font-weight: bold;
-        color: #1c6cff;
+      & + .book-content {
+        margin-top: 16px;
+      }
+
+      &-top {
+        display: flex;
+        column-gap: 8px;
+        align-items: center;
+
+        .book-content-title {
+          font-size: 14px;
+          font-weight: bold;
+          color: #1c6cff;
+        }
+
+        .svg-icon {
+          color: #babbbe;
+        }
       }
 
-      .svg-icon {
-        color: #babbbe;
+      .content {
+        margin: 24px;
       }
-    }
 
-    .tip {
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      width: 100%;
-      height: calc(100% - 42px);
-      font-size: 14px;
-      font-weight: bold;
-      color: #000;
+      .tip {
+        display: flex;
+        flex: 1;
+        align-items: center;
+        justify-content: center;
+        font-size: 14px;
+        font-weight: bold;
+        color: #000;
+      }
     }
   }
 }

+ 239 - 108
src/views/book/courseware/create/components/createCanvas.vue → src/views/book/courseware/create/components/CreateCanvas.vue

@@ -14,79 +14,84 @@
       },
     ]"
   >
-    <span class="drag-line" data-row="-1"></span>
-    <!-- 行 -->
-    <template v-for="(row, i) in data.row_list">
-      <div :key="i" class="row" :style="getMultipleColStyle(i)">
-        <!-- 列 -->
-        <template v-for="(col, j) in row.col_list">
-          <span
-            v-if="j === 0"
-            :key="`start-${i}-${j}`"
-            class="drag-vertical-line col-start"
-            :data-row="i"
-            :data-col="j"
-          ></span>
-          <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
-            <!-- 网格 -->
-            <template v-for="(grid, k) in col.grid_list">
-              <span
-                v-if="k === 0"
-                :key="`start-${i}-${j}-${k}`"
-                class="drag-line grid-line drag-row"
-                :style="{ gridArea: 'grid-top' }"
-                :data-row="i"
-                :data-col="j"
-                :data-grid="k"
-                data-type="row"
-              ></span>
-              <span
-                :key="`left-${i}-${j}-${k}`"
-                :style="{ gridArea: `left-${grid.grid_area}` }"
-                :data-row="i"
-                :data-col="j"
-                :data-grid="k"
-                data-type="col-left"
-                class="drag-vertical-line grid-line grid-line-left"
-              ></span>
-              <component
-                :is="componentList[grid.type]"
-                :id="grid.id"
-                ref="component"
-                :key="`grid-${i}-${j}-${k}-${grid.id}`"
-                :class="[grid.id]"
-                :style="{ gridArea: grid.grid_area, height: grid.height, marginTop: grid.row !== 1 ? '16px' : '0' }"
-                :delete-component="deleteComponent(i, j, k)"
-                :component-move="componentMove(i, j, k)"
-                @showSetting="showSetting"
-              />
-              <span
-                :key="`right-${i}-${j}-${k}`"
-                :style="{ gridArea: `right-${grid.grid_area}` }"
-                :data-row="i"
-                :data-col="j"
-                :data-grid="k + 1"
-                data-type="col-right"
-                class="drag-vertical-line grid-line grid-line-right"
-              ></span>
-              <span
-                v-if="k === col.grid_list.length - 1"
-                :key="`end-${i}-${j}-${k}`"
-                class="drag-line grid-line drag-row"
-                :style="{ gridArea: `grid-bottom` }"
-                :data-row="i"
-                :data-col="j"
-                :data-grid="k + 1"
-                data-type="row"
-              ></span>
-            </template>
-          </div>
-          <span :key="`end-${i}-${j}`" class="drag-vertical-line col-end" :data-row="i" :data-col="j + 1"></span>
-        </template>
-      </div>
-      <span v-if="i < data.row_list.length - 1" :key="`row-${i}`" class="drag-line" :data-row="i"></span>
+    <template v-if="isEdit">
+      <span class="drag-line" data-row="-1"></span>
+      <!-- 行 -->
+      <template v-for="(row, i) in data.row_list">
+        <div :key="i" class="row" :style="getMultipleColStyle(i)">
+          <!-- 列 -->
+          <template v-for="(col, j) in row.col_list">
+            <span
+              v-if="j === 0"
+              :key="`start-${i}-${j}`"
+              class="drag-vertical-line col-start"
+              :data-row="i"
+              :data-col="j"
+            ></span>
+            <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
+              <!-- 网格 -->
+              <template v-for="(grid, k) in col.grid_list">
+                <span
+                  v-if="k === 0"
+                  :key="`start-${i}-${j}-${k}`"
+                  class="drag-line grid-line drag-row"
+                  :style="{ gridArea: 'grid-top' }"
+                  :data-row="i"
+                  :data-col="j"
+                  :data-grid="k"
+                  data-type="row"
+                ></span>
+                <span
+                  :key="`left-${i}-${j}-${k}`"
+                  :style="{ gridArea: `left-${grid.grid_area}` }"
+                  :data-row="i"
+                  :data-col="j"
+                  :data-grid="k"
+                  data-type="col-left"
+                  class="drag-vertical-line grid-line grid-line-left"
+                ></span>
+                <component
+                  :is="componentList[grid.type]"
+                  :id="grid.id"
+                  ref="component"
+                  :key="`grid-${i}-${j}-${k}-${grid.id}`"
+                  :class="[grid.id]"
+                  :style="{ gridArea: grid.grid_area, height: grid.height, marginTop: grid.row !== 1 ? '16px' : '0' }"
+                  :delete-component="deleteComponent(i, j, k)"
+                  :component-move="componentMove(i, j, k)"
+                  @showSetting="showSetting"
+                  @changeData="changeData"
+                />
+                <span
+                  :key="`right-${i}-${j}-${k}`"
+                  :style="{ gridArea: `right-${grid.grid_area}` }"
+                  :data-row="i"
+                  :data-col="j"
+                  :data-grid="k + 1"
+                  data-type="col-right"
+                  class="drag-vertical-line grid-line grid-line-right"
+                ></span>
+                <span
+                  v-if="k === col.grid_list.length - 1"
+                  :key="`end-${i}-${j}-${k}`"
+                  class="drag-line grid-line drag-row"
+                  :style="{ gridArea: `grid-bottom` }"
+                  :data-row="i"
+                  :data-col="j"
+                  :data-grid="k + 1"
+                  data-type="row"
+                ></span>
+              </template>
+            </div>
+            <span :key="`end-${i}-${j}`" class="drag-vertical-line col-end" :data-row="i" :data-col="j + 1"></span>
+          </template>
+        </div>
+        <span v-if="i < data.row_list.length - 1" :key="`row-${i}`" class="drag-line" :data-row="i"></span>
+      </template>
+      <span class="drag-line" :data-row="data.row_list.length - 1"></span>
     </template>
-    <span class="drag-line" :data-row="data.row_list.length - 1"></span>
+
+    <PreviewEdit v-else :courseware-id="courseware_id" :row-list="data.row_list" @computedMoveData="computedMoveData" />
   </main>
 </template>
 
@@ -95,8 +100,19 @@ import { getRandomNumber } from '@/utils/index';
 import { componentList } from '../../data/bookType';
 import { SaveCoursewareContent, GetCoursewareContent } from '@/api/book';
 
+import PreviewEdit from './PreviewEdit.vue';
+
 export default {
   name: 'CreateCanvas',
+  components: {
+    PreviewEdit,
+  },
+  props: {
+    isEdit: {
+      type: Boolean,
+      required: true,
+    },
+  },
   data() {
     const { book_id, chapter_id } = this.$route.query;
 
@@ -161,7 +177,18 @@ export default {
   },
   created() {
     GetCoursewareContent({ id: this.courseware_id }).then(({ content }) => {
-      if (content) this.data = JSON.parse(content);
+      if (content) {
+        this.data = JSON.parse(content);
+      }
+      this.$watch(
+        'data',
+        () => {
+          this.changeData();
+        },
+        {
+          deep: true,
+        },
+      );
     });
   },
   mounted() {
@@ -173,15 +200,28 @@ export default {
     document.removeEventListener('mouseup', this.dragEnd);
   },
   methods: {
-    saveCoursewareContent() {
+    changeData() {
+      this.$emit('changeData');
+    },
+    /**
+     * 保存课件内容
+     * @param {string} type 类型
+     */
+    saveCoursewareContent(type) {
       let component_id_list = this.data.row_list.flatMap((row) =>
         row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
       );
 
-      this.$refs.component.forEach((item) => {
+      this.$refs?.component.forEach((item) => {
         item.saveCoursewareComponentContent();
       });
 
+      const loading = this.$loading({
+        lock: true,
+        text: '保存中...',
+        spinner: 'el-icon-loading',
+        background: 'rgba(0, 0, 0, 0.7)',
+      });
       SaveCoursewareContent({
         id: this.courseware_id,
         category: 'NEW',
@@ -189,6 +229,13 @@ export default {
         component_id_list,
       }).then(() => {
         this.$message.success('保存成功');
+        loading.close();
+        if (type === 'quit') {
+          this.$emit('back');
+        }
+        if (type === 'edit') {
+          this.$emit('changeEditStatus');
+        }
       });
     },
     setBackgroundImage(url, position) {
@@ -205,54 +252,110 @@ export default {
     showSetting(setting, type, id) {
       this.$emit('showSetting', setting, type, id);
     },
+    /**
+     * 计算组件移动
+     * @param {number} i 行
+     * @param {number} j 列
+     * @param {number} k 格子
+     */
     componentMove(i, j, k) {
       return ({ type, offsetX, offsetY, id }) => {
-        const row = this.data.row_list[i];
-        const col = row.col_list[j];
-        const grid = col.grid_list[k];
-
-        if (['top', 'bottom'].includes(type)) {
-          if (grid.height === 'auto') {
-            const gridHeight = document.querySelector(`.${id}`).offsetHeight;
-            grid.height = `${gridHeight + offsetY}px`;
-            col.grid_template_rows = `0 ${col.grid_list.map(({ height }) => height).join(' 16px ')} 0`;
-            return;
-          }
-          const height = Number(grid.height.replace('px', ''));
-          if (height + offsetY >= 50) {
-            grid.height = `${height + offsetY}px`;
-            col.grid_template_rows = `0 ${col.grid_list.map(({ height }) => height).join(' 16px ')} 0`;
-          }
+        this.computedMoveData({ i, j, k, type, offsetX, offsetY, id });
+      };
+    },
+    /**
+     * 计算移动数据
+     * @param {number} i 行
+     * @param {number} j 列
+     * @param {number} k 格子
+     * @param {string} type 移动类型
+     * @param {number} offsetX x 轴偏移量
+     * @param {number} offsetY y 轴偏移量
+     * @param {string} id 组件 id
+     */
+    computedMoveData({ i, j, k, type, offsetX, offsetY, id }) {
+      const row = this.data.row_list[i];
+      const col = row.col_list[j];
+      const grid = col.grid_list[k];
+
+      if (['top', 'bottom'].includes(type)) {
+        if (grid.height === 'auto') {
+          const gridHeight = document.querySelector(`.${id}`).offsetHeight;
+          grid.height = `${gridHeight + offsetY}px`;
+          col.grid_template_rows = `0 ${col.grid_list.map(({ height }) => height).join(' 16px ')} 0`;
+          return;
         }
+        const height = Number(grid.height.replace('px', ''));
+        if (height + offsetY >= 50) {
+          grid.height = `${height + offsetY}px`;
+          col.grid_template_rows = `0 ${col.grid_list.map(({ height }) => height).join(' 16px ')} 0`;
+        }
+      }
 
-        if (type === 'left' && j > 0) {
-          const prevGrid = row.width_list[j - 1];
-          const prevWidth = Number(prevGrid.replace('fr', ''));
-          const width = Number(row.width_list[j].replace('fr', ''));
+      let gridList = col.grid_list.filter((item) => item.row === grid.row);
+      // 一行中有多个格子
+      if (gridList.length > 1) {
+        let find = gridList.findIndex((item) => item.id === id);
+        if (type === 'left' && find > 0) {
+          const prevGrid = gridList[find - 1];
+          const prevWidth = Number(prevGrid.width.replace('fr', ''));
+          const width = Number(grid.width.replace('fr', ''));
           const max = prevWidth + width - 10;
           if (prevWidth + offsetX < 10 || prevWidth + offsetX > max || width - offsetX > max || width - offsetX < 10) {
             return;
           }
           // 计算拖动的距离与总宽度的比例
           const ratio = (offsetX / document.querySelector('.row').offsetWidth) * 100;
-          row.width_list[j + 1] = `${prevWidth + ratio}fr`;
-          row.width_list[j] = `${width - ratio}fr`;
-          this.$forceUpdate();
+
+          col.grid_list[k - 1].width = `${prevWidth + ratio}fr`;
+          grid.width = `${width - ratio}fr`;
         }
-        if (type === 'right' && j < row.col_list.length - 1) {
-          let nextGrid = row.width_list[j + 1];
-          const nextWidth = Number(nextGrid.replace('fr', ''));
-          const width = Number(row.width_list[j].replace('fr', ''));
+
+        if (type === 'right' && find < gridList.length - 1) {
+          let nextGrid = gridList[find + 1];
+          const nextWidth = Number(nextGrid.width.replace('fr', ''));
+          const width = Number(grid.width.replace('fr', ''));
           const max = nextWidth + width - 10;
           if (nextWidth - offsetX < 10 || nextWidth - offsetX > max || width + offsetX > max || width + offsetX < 10) {
             return;
           }
           const ratio = (offsetX / document.querySelector('.row').offsetWidth) * 100;
-          row.width_list[j + 1] = `${nextWidth - ratio}fr`;
-          row.width_list[j] = `${width + ratio}fr`;
-          this.$forceUpdate();
+
+          col.grid_list[k + 1].width = `${nextWidth - ratio}fr`;
+          grid.width = `${width + ratio}fr`;
         }
-      };
+        return;
+      }
+
+      if (type === 'left' && j > 0) {
+        const prevGrid = row.width_list[j - 1];
+        const prevWidth = Number(prevGrid.replace('fr', ''));
+        const width = Number(row.width_list[j].replace('fr', ''));
+        const max = prevWidth + width - 10;
+        if (prevWidth + offsetX < 10 || prevWidth + offsetX > max || width - offsetX > max || width - offsetX < 10) {
+          return;
+        }
+        // 计算拖动的距离与总宽度的比例
+        const ratio = (offsetX / document.querySelector('.row').offsetWidth) * 100;
+
+        row.width_list[j - 1] = `${prevWidth + ratio}fr`;
+        row.width_list[j] = `${width - ratio}fr`;
+      }
+
+      if (type === 'right' && j < row.col_list.length - 1) {
+        let nextGrid = row.width_list[j + 1];
+        const nextWidth = Number(nextGrid.replace('fr', ''));
+        const width = Number(row.width_list[j].replace('fr', ''));
+        const max = nextWidth + width - 10;
+        if (nextWidth - offsetX < 10 || nextWidth - offsetX > max || width + offsetX > max || width + offsetX < 10) {
+          return;
+        }
+        const ratio = (offsetX / document.querySelector('.row').offsetWidth) * 100;
+
+        row.width_list[j + 1] = `${nextWidth - ratio}fr`;
+        row.width_list[j] = `${width + ratio}fr`;
+      }
+      this.$forceUpdate();
     },
     /**
      * 重新计算格子宽度
@@ -368,17 +471,45 @@ export default {
 
       // 计算 grid_template_columns
       let gridTemCols = '';
+      let max = { row: 0, num: 0 };
+      grid.forEach(({ row }) => {
+        // 计算出 row 的哪个值最多
+        let len = grid.filter((item) => item.row === row).length;
+        if (max.num < len) {
+          max.num = len;
+          max.row = row;
+        }
+      });
       grid.forEach((item) => {
-        if (item.row === 1) {
+        if (item.row === max.row) {
           gridTemCols += `${item.width} 8px 8px `;
         }
       });
 
+      // 计算 grid_template_rows
+      let gridTemplateRows = '';
+      // 将 grid 按照 row 分组
+      let gridMap = new Map();
+      grid.forEach((item) => {
+        if (!gridMap.has(item.row)) {
+          gridMap.set(item.row, []);
+        }
+        gridMap.get(item.row).push(item.height);
+      });
+      gridMap.forEach((value) => {
+        if (value.length === 1) {
+          gridTemplateRows += `${value[0]} `;
+        } else {
+          let isAllAuto = value.every((item) => item === 'auto'); // 是否全是 auto
+          gridTemplateRows += isAllAuto ? 'auto ' : `max(${value.join(', ')}) `;
+        }
+      });
+
       return {
         width: col.width,
         gridTemplateAreas: `'${'grid-top '.repeat(maxCol * 3)}' ${gridStr} '${'grid-bottom '.repeat(maxCol * 3)}'`,
         gridTemplateColumns: `0 ${gridTemCols.slice(0, gridTemCols.length - 8)} 0`,
-        gridTemplateRows: `0 ${grid.map(({ height }) => height).join(' ')} 0`,
+        gridTemplateRows: `0 ${gridTemplateRows} 0`,
       };
     },
     /**

+ 355 - 0
src/views/book/courseware/create/components/PreviewEdit.vue

@@ -0,0 +1,355 @@
+<template>
+  <div class="preview">
+    <template v-for="(row, i) in rowList">
+      <!-- 行 -->
+      <div :key="i" class="row" :style="getMultipleColStyle(i)">
+        <!-- 列 -->
+        <template v-for="(col, j) in row.col_list">
+          <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
+            <!-- 网格 -->
+            <div
+              v-for="(grid, k) in col.grid_list"
+              :key="`grid-${i}-${j}-${k}`"
+              :style="{ gridArea: grid.grid_area, height: grid.height }"
+              :class="[!noMoveComponent.includes(grid.type) ? 'grid' : 'no-grid']"
+            >
+              <template v-for="{ type, cursor, lineClass } in moveLineList">
+                <span
+                  v-if="!noMoveComponent.includes(grid.type)"
+                  :key="`${type}-${i}-${j}-${k}`"
+                  class="drag-line"
+                  :class="[type, ...lineClass]"
+                  :style="{ gridArea: type }"
+                  :data-type="type"
+                  @mousedown="dragStart($event, { cursor, type: type, i, j, k, id: grid.id })"
+                ></span>
+              </template>
+              <component
+                :is="previewComponentMap[grid.type]"
+                :id="grid.id"
+                ref="preview"
+                :key="k"
+                :courseware-id="coursewareId"
+                :class="[grid.id]"
+                :style="{
+                  gridArea: 'preview',
+                  height: grid.height,
+                  overflow: 'auto',
+                }"
+              />
+            </div>
+          </div>
+        </template>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+import { previewComponentMap } from '@/views/book/courseware/preview/components/common/data';
+
+export default {
+  name: 'PreviewEdit',
+  props: {
+    rowList: {
+      type: Array,
+      required: true,
+    },
+    coursewareId: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      previewComponentMap,
+      drag: {
+        dragging: false,
+        i: -1,
+        j: -1,
+        k: -1,
+        startX: 0,
+        startY: 0,
+        type: '',
+        id: '',
+      },
+      moveLineList: [
+        {
+          type: 'top',
+          cursor: 'ns-resize',
+          lineClass: ['drag-line'],
+        },
+        {
+          type: 'left',
+          cursor: 'ew-resize',
+          lineClass: ['drag-vertical-line'],
+        },
+        {
+          type: 'right',
+          cursor: 'ew-resize',
+          lineClass: ['drag-vertical-line'],
+        },
+        {
+          type: 'bottom',
+          cursor: 'ns-resize',
+          lineClass: ['drag-line'],
+        },
+      ],
+      // 不需要移动的组件
+      noMoveComponent: ['divider', 'spacing'],
+    };
+  },
+  created() {
+    document.addEventListener('mousemove', this.dragMove);
+    document.addEventListener('mouseup', this.dragEnd);
+  },
+  beforeDestroy() {
+    document.removeEventListener('mousemove', this.dragMove);
+    document.removeEventListener('mouseup', this.dragEnd);
+  },
+  methods: {
+    getMultipleColStyle(i) {
+      let row = this.rowList[i];
+      let col = row.col_list;
+      if (col.length <= 1) {
+        return {
+          gridTemplateColumns: '100fr',
+        };
+      }
+      let gridTemplateColumns = row.width_list.join(' ');
+
+      return {
+        gridAutoFlow: 'column',
+        gridTemplateColumns,
+        gridTemplateRows: 'auto',
+      };
+    },
+    /**
+     * 分割整数为多个 1的倍数
+     * @param {number} num
+     * @param {number} parts
+     */
+    splitInteger(num, parts) {
+      let base = Math.floor(num / parts);
+      let arr = Array(parts).fill(base);
+      let remainder = num - base * parts;
+      for (let i = 0; remainder > 0; i = (i + 1) % parts) {
+        arr[i] += 1;
+        remainder -= 1;
+      }
+      return arr;
+    },
+    computedColStyle(col) {
+      const grid = col.grid_list;
+
+      let maxCol = 0; // 最大列数
+      let rowList = new Map();
+      grid.forEach(({ row }) => {
+        rowList.set(row, (rowList.get(row) || 0) + 1);
+      });
+      let curMaxRow = 0; // 当前数量最大 row 的值
+      rowList.forEach((value, key) => {
+        if (value > maxCol) {
+          maxCol = value;
+          curMaxRow = key;
+        }
+      });
+      // 计算 grid_template_areas
+      let gridTemplateAreas = '';
+      let gridArr = [];
+      grid.forEach(({ grid_area, row }) => {
+        if (!gridArr[row - 1]) {
+          gridArr[row - 1] = [];
+        }
+        if (curMaxRow === row) {
+          gridArr[row - 1].push(`${grid_area}`);
+        } else {
+          let filter = grid.filter((item) => item.row === row);
+          let find = filter.findIndex((item) => item.grid_area === grid_area);
+          let needNum = maxCol - filter.length; // 需要的数量
+
+          let str = '';
+          if (filter.length === 1) {
+            str = ` ${grid_area} `.repeat(needNum + 1);
+          } else {
+            let arr = this.splitInteger(needNum, filter.length);
+            str = arr[find] === 0 ? ` ${grid_area} ` : ` ${grid_area} `.repeat(arr[find] + 1);
+          }
+          gridArr[row - 1].push(`${str}`);
+        }
+      });
+      gridArr.forEach((item) => {
+        gridTemplateAreas += `'${item.join(' ')}' `;
+      });
+
+      // 计算 grid_template_columns
+      let gridTemplateColumns = '';
+      let max = { row: 0, num: 0 };
+      grid.forEach(({ row }) => {
+        // 计算出 row 的哪个值最多
+        let len = grid.filter((item) => item.row === row).length;
+        if (max.num < len) {
+          max.num = len;
+          max.row = row;
+        }
+      });
+      grid.forEach((item) => {
+        if (item.row === max.row) {
+          gridTemplateColumns += `${item.width} `;
+        }
+      });
+
+      // 计算 grid_template_rows
+      let gridTemplateRows = '';
+      // 将 grid 按照 row 分组
+      let gridMap = new Map();
+      grid.forEach((item) => {
+        if (!gridMap.has(item.row)) {
+          gridMap.set(item.row, []);
+        }
+        gridMap.get(item.row).push(item.height);
+      });
+      gridMap.forEach((value) => {
+        if (value.length === 1) {
+          gridTemplateRows += `${value[0]} `;
+        } else {
+          let isAllAuto = value.every((item) => item === 'auto'); // 是否全是 auto
+          gridTemplateRows += isAllAuto ? 'auto ' : `max(${value.join(', ')}) `;
+        }
+      });
+
+      return {
+        width: col.width,
+        gridTemplateAreas,
+        gridTemplateColumns,
+        gridTemplateRows,
+      };
+    },
+    /**
+     * 拖拽开始
+     * @param {MouseEvent} event
+     * @param {string} cursor
+     * @param {string} type
+     * @param {string} id
+     * @param {number} i
+     * @param {number} j
+     * @param {number} k
+     */
+    dragStart(event, { cursor, type, id, i, j, k }) {
+      const { clientX, clientY } = event;
+      this.drag = {
+        dragging: true,
+        startX: clientX,
+        startY: clientY,
+        type,
+        id,
+        i,
+        j,
+        k,
+      };
+
+      this.bgColor = '#272727';
+      document.body.style.cursor = cursor;
+    },
+    /**
+     * 拖拽移动
+     * @param {MouseEvent} event
+     */
+    dragMove(event) {
+      if (!this.drag.dragging) return;
+
+      const { clientX, clientY } = event;
+      const { i, j, k, id, startX, startY, type } = this.drag;
+      const offsetX = clientX - startX;
+      const offsetY = clientY - startY;
+
+      this.$emit('computedMoveData', {
+        i,
+        j,
+        k,
+        offsetX,
+        offsetY,
+        type,
+        id,
+      });
+
+      this.drag.startX = clientX;
+      this.drag.startY = clientY;
+
+      this.$forceUpdate();
+    },
+    /**
+     * 拖拽结束
+     */
+    dragEnd() {
+      this.drag = {
+        dragging: false,
+        startX: 0,
+        startY: 0,
+        type: '',
+      };
+
+      this.bgColor = '#ebebeb';
+      document.body.style.cursor = 'auto';
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.preview {
+  display: flex;
+  flex-direction: column;
+  row-gap: 12px;
+
+  .row {
+    display: grid;
+    gap: 16px;
+
+    .col {
+      display: grid;
+      gap: 16px;
+      align-items: flex-start;
+
+      .grid {
+        display: grid;
+        grid-template:
+          'top top top' 3px
+          'left preview right' 1fr
+          'bottom bottom bottom' 3px / 3px 1fr 3px;
+      }
+
+      .drag-line {
+        z-index: 9;
+        width: 100%;
+        height: 6px;
+        cursor: ns-resize;
+        background: linear-gradient(to bottom, transparent, transparent 40%, #e5e6eb 40%, #e5e6eb 60%, transparent 60%);
+
+        &.bottom {
+          background: linear-gradient(
+            to bottom,
+            transparent,
+            transparent 20%,
+            #e5e6eb 20%,
+            #e5e6eb 50%,
+            transparent 50%
+          );
+        }
+      }
+
+      .drag-vertical-line {
+        z-index: 9;
+        width: 6px;
+        height: 100%;
+        cursor: ew-resize;
+        background: linear-gradient(to right, transparent, transparent 40%, #e5e6eb 40%, #e5e6eb 60%, transparent 60%);
+
+        &.left {
+          background: linear-gradient(to left, transparent, transparent 60%, #e5e6eb 60%, #e5e6eb);
+        }
+      }
+    }
+  }
+}
+</style>

+ 69 - 0
src/views/book/courseware/create/components/WarnSave.vue

@@ -0,0 +1,69 @@
+<template>
+  <el-dialog custom-class="warn-save" width="230px" :visible="visible" :before-close="handleClose">
+    <span class="warning">警告</span>
+    <span>当前有内容未保存</span>
+
+    <template slot="footer">
+      <el-button type="info" @click="directQuit">直接退出</el-button>
+      <el-button type="primary" @click="saveAndClose">保存并退出</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+export default {
+  name: 'WarnSave',
+  props: {
+    visible: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  data() {
+    return {};
+  },
+  methods: {
+    handleClose() {
+      this.$emit('update:visible', false);
+    },
+    // 直接退出
+    directQuit() {
+      this.$emit('update:visible', false);
+      this.$emit('directQuit');
+    },
+    // 保存并退出
+    saveAndClose() {
+      this.$emit('update:visible', false);
+      this.$emit('saveAndClose', 'quit');
+    },
+  },
+};
+</script>
+
+<style lang="scss">
+.warn-save {
+  padding: 0;
+
+  .el-dialog {
+    &__header {
+      display: none;
+    }
+
+    &__body {
+      display: flex;
+      flex-direction: column;
+      row-gap: 8px;
+      align-items: center;
+      padding-bottom: 10px;
+
+      .warning {
+        color: #e22b2b;
+      }
+    }
+
+    &__footer {
+      display: flex;
+    }
+  }
+}
+</style>

+ 8 - 110
src/views/book/courseware/create/components/base/audio/AudioSetting.vue

@@ -16,17 +16,7 @@
         </el-radio>
       </el-form-item>
       <el-form-item label="序号位置">
-        <div class="grid-container">
-          <el-button
-            v-for="{ value } in serialNumberPositionList"
-            :key="value"
-            :class="[value, { active: value === property.sn_position }]"
-            :style="{ gridArea: value }"
-            @click="changeNumberPosition(value)"
-          />
-
-          <div class="main"></div>
-        </div>
+        <SerialNumberPosition :position="property.sn_position" @changeNumberPosition="changeNumberPosition" />
       </el-form-item>
       <el-divider />
       <el-form-item label="查看方式">
@@ -45,24 +35,17 @@
 
 <script>
 import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
-import {
-  snGenerationMethodList,
-  audioViewMethodList,
-  switchSerialNumber,
-  checkString,
-  serialNumberPositionList,
-} from '@/views/book/courseware/data/common';
+import { snGenerationMethodList, checkString } from '@/views/book/courseware/data/common';
+import { audioViewMethodList } from '@/views/book/courseware/data/audio';
 
 export default {
   name: 'AudioSetting',
   mixins: [SettingMixin],
   data() {
     return {
-      switchSerialNumber,
       checkString,
-      serialNumberPositionList,
-      snGenerationMethodList,
       audioViewMethodList,
+      snGenerationMethodList,
       labelPosition: 'left',
       property: {
         serial_number: 1, // 序号
@@ -73,99 +56,14 @@ export default {
       },
     };
   },
-  watch: {
-    property: {
-      handler(val) {
-        if (this.isSet) {
-          val.sn_type = checkString(val.serial_number);
-          this.$emit('updateSetting', val);
-        }
-      },
-      deep: true,
-    },
-  },
-  methods: {
-    /**
-     * @description 设置属性
-     * @param {object} property 属性
-     */
-    setSetting(property) {
-      this.isSet = true;
-      this.property = property;
-    },
-
-    /**
-     * @description 改变序号位置
-     * @param {String} sn_position
-     */
-    changeNumberPosition(sn_position) {
-      this.property.sn_position = sn_position;
-    },
-  },
+  methods: {},
 };
 </script>
 
 <style lang="scss" scoped>
-.el-form {
-  .serial-number {
-    :deep .el-form-item__content {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-    }
+@use '@/styles/mixin.scss' as *;
 
-    .svg-icon {
-      cursor: pointer;
-    }
-  }
-
-  .el-input {
-    margin-right: 16px;
-  }
-
-  .main {
-    grid-area: main;
-  }
-
-  .grid-container {
-    display: grid;
-    grid:
-      '. top-start top top-end .'
-      'left-start main main main right-start'
-      'left main main main right'
-      'left-end main main main right-end'
-      '. bottom-start bottom bottom-end .';
-    place-items: center center;
-    width: 134px;
-    height: 80px;
-    padding: 8px;
-    line-height: 10px;
-    border: 1px solid #ebebeb;
-
-    .el-button {
-      width: 16px;
-      height: 8px;
-      padding: 0;
-      margin: 0;
-      border: 1px solid #e4e4e4;
-      border-radius: 2px;
-
-      &.active {
-        background-color: $setting-active-color;
-      }
-    }
-
-    .main {
-      width: 64px;
-      height: 32px;
-      background-color: #f8f8f8;
-      border: 1px solid #e4e4e4;
-      border-radius: 2px;
-    }
-  }
-
-  .el-divider {
-    margin: 16px 0;
-  }
+.el-form {
+  @include setting-base;
 }
 </style>

+ 0 - 1
src/views/book/courseware/create/components/base/picture/Picture.vue

@@ -38,7 +38,6 @@ export default {
       iconClass: 'picture',
     };
   },
-  computed: {},
   methods: {
     updateFileList({ file_list, file_id_list, file_info_list }) {
       this.data.file_list = file_list;

+ 7 - 104
src/views/book/courseware/create/components/base/picture/PictureSetting.vue

@@ -16,16 +16,7 @@
         </el-radio>
       </el-form-item>
       <el-form-item label="序号位置">
-        <div class="grid-container">
-          <el-button
-            v-for="{ value } in serialNumberPositionList"
-            :key="value"
-            :class="[value, { active: value === property.sn_position }]"
-            :style="{ gridArea: value }"
-            @click="changeNumberPosition(value)"
-          />
-          <div class="main"></div>
-        </div>
+        <SerialNumberPosition :position="property.sn_position" @changeNumberPosition="changeNumberPosition" />
       </el-form-item>
       <el-divider />
       <el-form-item label="查看方式">
@@ -39,22 +30,15 @@
 
 <script>
 import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
-import {
-  snGenerationMethodList,
-  viewMethodList,
-  switchSerialNumber,
-  checkString,
-  serialNumberPositionList,
-} from '@/views/book/courseware/data/common';
+
+import { viewMethodList, snGenerationMethodList, checkString } from '@/views/book/courseware/data/common';
 
 export default {
   name: 'PictureSetting',
   mixins: [SettingMixin],
   data() {
     return {
-      switchSerialNumber,
       checkString,
-      serialNumberPositionList,
       snGenerationMethodList,
       viewMethodList,
       labelPosition: 'left',
@@ -67,95 +51,14 @@ export default {
       },
     };
   },
-  watch: {
-    property: {
-      handler(val) {
-        if (this.isSet) {
-          val.sn_type = checkString(val.serial_number);
-          this.$emit('updateSetting', val);
-        }
-      },
-      deep: true,
-    },
-  },
-  methods: {
-    /**
-     * @description 设置属性
-     * @param {Object} property 属性
-     */
-    setSetting(property) {
-      this.isSet = true;
-      this.property = property;
-    },
-
-    /**
-     * @description 改变序号位置
-     * @param {String} sn_position
-     */
-    changeNumberPosition(sn_position) {
-      this.property.sn_position = sn_position;
-    },
-  },
+  methods: {},
 };
 </script>
 
 <style lang="scss" scoped>
-.el-form {
-  .serial-number {
-    :deep .el-form-item__content {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-    }
-  }
-
-  .el-input {
-    margin-right: 16px;
-  }
+@use '@/styles/mixin.scss' as *;
 
-  .main {
-    grid-area: main;
-  }
-
-  .grid-container {
-    display: grid;
-    grid:
-      '. top-start top top-end .'
-      'left-start main main main right-start'
-      'left main main main right'
-      'left-end main main main right-end'
-      '. bottom-start bottom bottom-end .';
-    place-items: center center;
-    width: 134px;
-    height: 80px;
-    padding: 8px;
-    line-height: 10px;
-    border: 1px solid #ebebeb;
-
-    .el-button {
-      width: 16px;
-      height: 8px;
-      padding: 0;
-      margin: 0;
-      border: 1px solid #e4e4e4;
-      border-radius: 2px;
-
-      &.active {
-        background-color: $setting-active-color;
-      }
-    }
-
-    .main {
-      width: 64px;
-      height: 32px;
-      background-color: #f8f8f8;
-      border: 1px solid #e4e4e4;
-      border-radius: 2px;
-    }
-  }
-
-  .el-divider {
-    margin: 16px 0;
-  }
+.el-form {
+  @include setting-base;
 }
 </style>

+ 9 - 2
src/views/book/courseware/create/components/common/ModuleMixin.js

@@ -1,6 +1,6 @@
 // 组件混入
 import ModuleBase from './ModuleBase.vue';
-import { snGenerationMethodList, viewMethodList, audioViewMethodList } from '@/views/book/courseware/data/common';
+import { snGenerationMethodList, viewMethodList } from '@/views/book/courseware/data/common';
 import { SaveCoursewareComponentContent, GetCoursewareComponentContent } from '@/api/book';
 
 const mixin = {
@@ -8,7 +8,6 @@ const mixin = {
     return {
       snGenerationMethodList,
       viewMethodList,
-      audioViewMethodList,
       property: {
         isGetContent: false, // 是否已获取内容
       },
@@ -47,6 +46,14 @@ const mixin = {
         this.data = JSON.parse(content);
         this.property.isGetContent = true;
       }
+
+      this.$watch(
+        'data',
+        () => {
+          this.$emit('changeData');
+        },
+        { deep: true },
+      );
     });
   },
   methods: {

+ 72 - 0
src/views/book/courseware/create/components/common/SerialNumberPosition.vue

@@ -0,0 +1,72 @@
+<template>
+  <div class="grid-container">
+    <el-button
+      v-for="{ value } in serialNumberPositionList"
+      :key="value"
+      :class="[value, { active: value === position }]"
+      :style="{ gridArea: value }"
+      @click="$emit('changeNumberPosition', value)"
+    />
+    <div class="main"></div>
+  </div>
+</template>
+
+<script>
+import { serialNumberPositionList } from '@/views/book/courseware/data/common';
+
+export default {
+  name: 'SerialNumberPosition',
+  props: {
+    position: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      serialNumberPositionList,
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped>
+.grid-container {
+  display: grid;
+  grid-template-areas:
+    '. top-start top top-end .'
+    'left-start main main main right-start'
+    'left main main main right'
+    'left-end main main main right-end'
+    '. bottom-start bottom bottom-end .';
+  place-items: center center;
+  width: 134px;
+  height: 80px;
+  padding: 8px;
+  line-height: 10px;
+  border: 1px solid #ebebeb;
+
+  .el-button {
+    width: 16px;
+    height: 8px;
+    padding: 0;
+    margin: 0;
+    border: 1px solid #e4e4e4;
+    border-radius: 2px;
+
+    &.active {
+      background-color: #4176ff;
+    }
+  }
+
+  .main {
+    grid-area: main;
+    width: 64px;
+    height: 32px;
+    background-color: #f8f8f8;
+    border: 1px solid #e4e4e4;
+    border-radius: 2px;
+  }
+}
+</style>

+ 13 - 2
src/views/book/courseware/create/components/common/SettingMixin.js

@@ -1,12 +1,15 @@
-import { snGenerationMethodList, computedQuestionNumber } from '../../../data/common';
+import { switchSerialNumber, computedQuestionNumber } from '../../../data/common';
+
+import SerialNumberPosition from '@/views/book/courseware/create/components/common/SerialNumberPosition.vue';
 
 const mixin = {
   data() {
     return {
-      snGenerationMethodList,
+      switchSerialNumber,
       computedQuestionNumber,
     };
   },
+  components: { SerialNumberPosition },
   methods: {
     /**
      * @description 设置属性
@@ -15,6 +18,14 @@ const mixin = {
     setSetting(property) {
       this.property = property;
     },
+
+    /**
+     * @description 改变序号位置
+     * @param {String} sn_position
+     */
+    changeNumberPosition(sn_position) {
+      this.property.sn_position = sn_position;
+    },
   },
 };
 

+ 22 - 0
src/views/book/courseware/create/components/question/select/Select.vue

@@ -0,0 +1,22 @@
+<template>
+  <ModuleBase :type="data.type" />
+</template>
+
+<script>
+import ModuleMixin from '../../common/ModuleMixin';
+
+import { getSelectData } from '@/views/book/courseware/data/select';
+
+export default {
+  name: 'SelectPage',
+  mixins: [ModuleMixin],
+  data() {
+    return {
+      data: getSelectData(),
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 66 - 0
src/views/book/courseware/create/components/question/select/SelectSetting.vue

@@ -0,0 +1,66 @@
+<template>
+  <div>
+    <el-form :model="property" :label-position="labelPosition" label-width="72px">
+      <el-form-item label="序号" class="serial-number">
+        <el-input v-model="property.serial_number" />
+        <SvgIcon icon-class="switch" size="14" @click="switchSerialNumber(property)" />
+      </el-form-item>
+      <el-form-item>
+        <el-radio
+          v-for="{ value, label } in snGenerationMethodList"
+          :key="value"
+          v-model="property.sn_generation_method"
+          :label="value"
+        >
+          {{ label }}
+        </el-radio>
+      </el-form-item>
+      <el-form-item label="序号位置">
+        <SerialNumberPosition :position="property.sn_position" @changeNumberPosition="changeNumberPosition" />
+      </el-form-item>
+      <el-divider />
+      <el-form-item label="汉字框">
+        <el-radio
+          v-for="{ value, label } in arrangeTypeList"
+          :key="value"
+          v-model="property.view_method"
+          :label="value"
+        >
+          {{ label }}
+        </el-radio>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
+
+import { snGenerationMethodList } from '@/views/book/courseware/data/common';
+import { arrangeTypeList } from '@/views/book/courseware/data/select';
+
+export default {
+  name: 'SelectSetting',
+  mixins: [SettingMixin],
+  data() {
+    return {
+      property: {
+        serial_number: 1,
+        sn_type: 'number',
+        sn_position: 'top-start',
+        sn_generation_method: snGenerationMethodList[0].value,
+        arrange_method: arrangeTypeList[0].value,
+      },
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.el-form {
+  @include setting-base;
+}
+</style>

+ 49 - 7
src/views/book/courseware/create/index.vue

@@ -2,8 +2,8 @@
   <div ref="create" class="create">
     <div class="create-left">
       <div class="back-container">
-        <el-button class="back" @click="back"><i class="el-icon-arrow-left"></i> 返回</el-button>
-        <span class="title">Frame 1</span>
+        <el-button class="back" @click="judgeIsHasChange"><i class="el-icon-arrow-left"></i> 返回</el-button>
+        <span class="title">Frame {{ Number(index) + 1 }}</span>
       </div>
       <div v-for="{ value, label, children } in bookTypeOption" :key="value" class="components">
         <div class="components-title">{{ label }}</div>
@@ -25,7 +25,10 @@
         <el-button @click="showSetBackground"><SvgIcon icon-class="background-img" />背景图</el-button>
         <el-button><SvgIcon icon-class="template" />模板</el-button>
         <el-button class="exit-edit">
-          <span @click="back"><i class="el-icon-close"></i> 退出编辑</span>
+          <span @click="saveCoursewareContent('edit')">
+            <template v-if="isEdit"> <i class="el-icon-close"></i><span> 退出编辑</span> </template>
+            <template v-else> <i class="el-icon-edit"></i><span> 编辑</span> </template>
+          </span>
           <el-button class="save" type="primary">
             <span @click="saveCoursewareContent"><SvgIcon icon-class="save" /> <span>保存</span></span>
             <el-popover placement="bottom" popper-class="save-popover" trigger="click">
@@ -36,7 +39,14 @@
         </el-button>
       </div>
 
-      <CreateCanvas ref="createCanvas" @showSetting="showSetting" />
+      <CreateCanvas
+        ref="createCanvas"
+        :is-edit="isEdit"
+        @showSetting="showSetting"
+        @changeData="changeData"
+        @back="back"
+        @changeEditStatus="changeEditStatus"
+      />
     </div>
 
     <div class="create-right">
@@ -45,6 +55,7 @@
     </div>
 
     <SelectBackground :visible.sync="visible" @setBackgroundImage="setBackgroundImage" />
+    <WarnSave :visible.sync="visibleWarn" @directQuit="back" @saveAndClose="saveCoursewareContent" />
   </div>
 </template>
 
@@ -53,12 +64,14 @@ import { bookTypeOption, componentSettingList } from '../data/bookType';
 
 import CreateCanvas from './components/createCanvas.vue';
 import SelectBackground from './components/SelectBackground.vue';
+import WarnSave from './components/WarnSave.vue';
 
 export default {
   name: 'CreatePage',
   components: {
     CreateCanvas,
     SelectBackground,
+    WarnSave,
   },
   provide() {
     return {
@@ -67,29 +80,58 @@ export default {
     };
   },
   data() {
-    const { book_id, chapter_id } = this.$route.query;
+    const { book_id, chapter_id, index } = this.$route.query;
 
     return {
       courseware_id: this.$route.params.courseware_id,
       book_id,
+      index,
       chapter_id,
       curSettingType: '',
       curSettingId: '',
       componentSettingList,
       bookTypeOption,
       visible: false,
+      visibleWarn: false,
+      isEdit: true, // 是否编辑状态
+      isChange: false, // 是否有改动
     };
   },
 
   methods: {
+    judgeIsHasChange() {
+      if (this.isChange) {
+        this.visibleWarn = true;
+        return;
+      }
+      this.back();
+    },
     back() {
       this.$router.push({ path: '/chapter', query: { chapter_id: this.chapter_id, book_id: this.book_id } });
     },
+    /**
+     * 切换编辑状态
+     */
+    changeEditStatus() {
+      this.isEdit = !this.isEdit;
+    },
     dragStart(event, type) {
       this.$refs.createCanvas.dragStart(event, type);
     },
-    saveCoursewareContent() {
-      this.$refs.createCanvas.saveCoursewareContent();
+    changeData() {
+      this.isChange = true;
+    },
+    /**
+     * 保存课件内容
+     * @param {string} type 是否退出
+     */
+    saveCoursewareContent(type = '') {
+      if (type === 'edit' && !this.isChange) {
+        this.changeEditStatus();
+        return;
+      }
+      this.$refs.createCanvas.saveCoursewareContent(type);
+      this.isChange = false;
     },
     showSetBackground() {
       this.visible = true;

+ 7 - 1
src/views/book/courseware/data/audio.js

@@ -1,10 +1,16 @@
 import {
   snGenerationMethodList,
-  audioViewMethodList,
   serialNumberTypeList,
   serialNumberPositionList,
 } from '@/views/book/courseware/data/common';
 
+// 音频查看方式
+export const audioViewMethodList = [
+  { value: 'independent', label: '独立' },
+  { value: 'list', label: '列表' },
+  { value: 'icon', label: '图标' },
+];
+
 export function getAudioData() {
   return {
     type: 'audio',

+ 4 - 2
src/views/book/courseware/data/bookType.js

@@ -6,6 +6,8 @@ import AudioPage from '../create/components/base/audio/Audio.vue';
 import AudioSetting from '../create/components/base/audio/AudioSetting.vue';
 import PicturePage from '../create/components/base/picture/Picture.vue';
 import PictureSetting from '../create/components/base/picture/PictureSetting.vue';
+import SelectPage from '../create/components/question/select/Select.vue';
+import SelectSetting from '../create/components/question/select/SelectSetting.vue';
 import VideoPage from '../create/components/base/video/Video.vue';
 import VideoSetting from '../create/components/base/video/VideoSetting.vue';
 import StemPage from '../create/components/base/stem/Stem.vue';
@@ -87,8 +89,8 @@ export const bookTypeOption = [
         value: 'select',
         label: '选择组件',
         icon: '',
-        component: '',
-        set: '',
+        component: SelectPage,
+        set: SelectSetting,
       },
     ],
   },

+ 0 - 7
src/views/book/courseware/data/common.js

@@ -10,13 +10,6 @@ export const viewMethodList = [
   { value: 'list', label: '播放列表' },
 ];
 
-// 音频查看方式
-export const audioViewMethodList = [
-  { value: 'independent', label: '独立' },
-  { value: 'list', label: '列表' },
-  { value: 'icon', label: '图标' },
-];
-
 // 序号类型
 export const serialNumberTypeList = [
   { value: 'number', label: '数字' },

+ 0 - 1
src/views/book/courseware/data/picture.js

@@ -7,7 +7,6 @@ import {
 
 export function getPictureData() {
   return {
-    id: '',
     type: 'picture',
     title: '图片',
     single_size: 2, // 单位MB

+ 25 - 0
src/views/book/courseware/data/select.js

@@ -0,0 +1,25 @@
+import {
+  snGenerationMethodList,
+  serialNumberTypeList,
+  serialNumberPositionList,
+} from '@/views/book/courseware/data/common';
+
+// 排列方式
+export const arrangeTypeList = [
+  { value: 'horizontal', label: '横排' },
+  { value: 'vertical', label: '竖屏' },
+];
+
+export function getSelectData() {
+  return {
+    type: 'select',
+    title: '选择',
+    property: {
+      serial_number: 1, // 序号
+      sn_type: serialNumberTypeList[0].value, // 序号类型:letter字母 number数字  capital大写字母 bracket_number括号数字
+      sn_position: serialNumberPositionList[0].value, // 序号位置:top-start top top-end,left-start left left-end等
+      sn_generation_method: snGenerationMethodList[0].value, // 序号生成方式:recalculate重新计算 follow跟随
+      arrange_method: arrangeTypeList[0].value, // 查看方式:independent独立 list列表 icon图标
+    },
+  };
+}

+ 205 - 0
src/views/book/courseware/preview/CoursewarePreview.vue

@@ -0,0 +1,205 @@
+<template>
+  <div
+    class="courserware"
+    :style="[
+      {
+        backgroundImage: data.background_image_url ? `url(${data.background_image_url})` : '',
+        backgroundSize: data.background_image_url
+          ? `${data.background_position.width}% ${data.background_position.height}%`
+          : '',
+        backgroundPosition: data.background_image_url
+          ? `${data.background_position.left}% ${data.background_position.top}%`
+          : '',
+      },
+    ]"
+  >
+    <template v-for="(row, i) in data.row_list">
+      <div :key="i" class="row" :style="getMultipleColStyle(i)">
+        <!-- 列 -->
+        <template v-for="(col, j) in row.col_list">
+          <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
+            <!-- 网格 -->
+            <template v-for="(grid, k) in col.grid_list">
+              <component
+                :is="previewComponentMap[grid.type]"
+                :id="grid.id"
+                ref="preview"
+                :key="k"
+                :courseware-id="data.courseware_id"
+                :class="[grid.id]"
+                :style="{
+                  gridArea: grid.grid_area,
+                  height: grid.height,
+                }"
+              />
+            </template>
+          </div>
+        </template>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+import { previewComponentMap } from './components/common/data';
+
+export default {
+  name: 'CoursewarePreview',
+  props: {
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+  },
+  data() {
+    return {
+      previewComponentMap,
+    };
+  },
+  methods: {
+    getMultipleColStyle(i) {
+      let row = this.data.row_list[i];
+      let col = row.col_list;
+      if (col.length <= 1) {
+        return {
+          gridTemplateColumns: '100fr',
+        };
+      }
+      let gridTemplateColumns = row.width_list.join(' ');
+
+      return {
+        gridAutoFlow: 'column',
+        gridTemplateColumns,
+        gridTemplateRows: 'auto',
+      };
+    },
+    /**
+     * 分割整数为多个 1的倍数
+     * @param {number} num
+     * @param {number} parts
+     */
+    splitInteger(num, parts) {
+      let base = Math.floor(num / parts);
+      let arr = Array(parts).fill(base);
+      let remainder = num - base * parts;
+      for (let i = 0; remainder > 0; i = (i + 1) % parts) {
+        arr[i] += 1;
+        remainder -= 1;
+      }
+      return arr;
+    },
+    computedColStyle(col) {
+      const grid = col.grid_list;
+
+      let maxCol = 0; // 最大列数
+      let rowList = new Map();
+      grid.forEach(({ row }) => {
+        rowList.set(row, (rowList.get(row) || 0) + 1);
+      });
+      let curMaxRow = 0; // 当前数量最大 row 的值
+      rowList.forEach((value, key) => {
+        if (value > maxCol) {
+          maxCol = value;
+          curMaxRow = key;
+        }
+      });
+      // 计算 grid_template_areas
+      let gridTemplateAreas = '';
+      let gridArr = [];
+      grid.forEach(({ grid_area, row }) => {
+        if (!gridArr[row - 1]) {
+          gridArr[row - 1] = [];
+        }
+        if (curMaxRow === row) {
+          gridArr[row - 1].push(`${grid_area}`);
+        } else {
+          let filter = grid.filter((item) => item.row === row);
+          let find = filter.findIndex((item) => item.grid_area === grid_area);
+          let needNum = maxCol - filter.length; // 需要的数量
+
+          let str = '';
+          if (filter.length === 1) {
+            str = ` ${grid_area} `.repeat(needNum + 1);
+          } else {
+            let arr = this.splitInteger(needNum, filter.length);
+            str = arr[find] === 0 ? ` ${grid_area} ` : ` ${grid_area} `.repeat(arr[find] + 1);
+          }
+          gridArr[row - 1].push(`${str}`);
+        }
+      });
+      gridArr.forEach((item) => {
+        gridTemplateAreas += `'${item.join(' ')}' `;
+      });
+
+      // 计算 grid_template_columns
+      let gridTemplateColumns = '';
+      let max = { row: 0, num: 0 };
+      grid.forEach(({ row }) => {
+        // 计算出 row 的哪个值最多
+        let len = grid.filter((item) => item.row === row).length;
+        if (max.num < len) {
+          max.num = len;
+          max.row = row;
+        }
+      });
+      grid.forEach((item) => {
+        if (item.row === max.row) {
+          gridTemplateColumns += `${item.width} `;
+        }
+      });
+
+      // 计算 grid_template_rows
+      let gridTemplateRows = '';
+      // 将 grid 按照 row 分组
+      let gridMap = new Map();
+      grid.forEach((item) => {
+        if (!gridMap.has(item.row)) {
+          gridMap.set(item.row, []);
+        }
+        gridMap.get(item.row).push(item.height);
+      });
+      gridMap.forEach((value) => {
+        if (value.length === 1) {
+          gridTemplateRows += `${value[0]} `;
+        } else {
+          let isAllAuto = value.every((item) => item === 'auto'); // 是否全是 auto
+          gridTemplateRows += isAllAuto ? 'auto ' : `max(${value.join(', ')}) `;
+        }
+      });
+
+      return {
+        width: col.width,
+        gridTemplateAreas,
+        gridTemplateColumns,
+        gridTemplateRows,
+      };
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.courserware {
+  display: flex;
+  flex-direction: column;
+  row-gap: 6px;
+  width: 100%;
+  min-height: 500px;
+  padding: 24px;
+  background-color: #fff;
+  background-repeat: no-repeat;
+  border-bottom-right-radius: 12px;
+  border-bottom-left-radius: 12px;
+
+  .row {
+    display: grid;
+    gap: 16px;
+
+    .col {
+      display: grid;
+      gap: 16px;
+      overflow: auto;
+    }
+  }
+}
+</style>

+ 2 - 2
src/views/book/courseware/preview/components/common/PreviewMixin.js

@@ -9,7 +9,7 @@ const mixin = {
       type: String,
       required: true,
     },
-    courseware_id: {
+    coursewareId: {
       type: String,
       required: true,
     },
@@ -19,7 +19,7 @@ const mixin = {
   },
   methods: {
     getCoursewareComponentContent_View() {
-      GetCoursewareComponentContent_View({ courseware_id: this.courseware_id, component_id: this.id }).then(
+      GetCoursewareComponentContent_View({ courseware_id: this.coursewareId, component_id: this.id }).then(
         ({ content }) => {
           if (content) this.data = JSON.parse(content);
         },

+ 33 - 47
src/views/book/courseware/preview/index.vue

@@ -18,46 +18,7 @@
               <span v-if="index < menuList.length - 1" :key="`separator-${m}`" class="separator">/</span>
             </template>
           </span>
-          <!-- 课件 -->
-          <div
-            class="courserware"
-            :style="[
-              {
-                backgroundImage: data.background_image_url ? `url(${data.background_image_url})` : '',
-                backgroundSize: data.background_image_url
-                  ? `${data.background_position.width}% ${data.background_position.height}%`
-                  : '',
-                backgroundPosition: data.background_image_url
-                  ? `${data.background_position.left}% ${data.background_position.top}%`
-                  : '',
-              },
-            ]"
-          >
-            <template v-for="(row, i) in data.row_list">
-              <div :key="i" class="row" :style="getMultipleColStyle(index, i)">
-                <!-- 列 -->
-                <template v-for="(col, j) in row.col_list">
-                  <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
-                    <!-- 网格 -->
-                    <template v-for="(grid, k) in col.grid_list">
-                      <component
-                        :is="previewComponentMap[grid.type]"
-                        :id="grid.id"
-                        ref="preview"
-                        :key="k"
-                        :courseware_id="data.courseware_id"
-                        :class="[grid.id]"
-                        :style="{
-                          gridArea: grid.grid_area,
-                          height: grid.height,
-                        }"
-                      />
-                    </template>
-                  </div>
-                </template>
-              </div>
-            </template>
-          </div>
+          <CoursewarePreview :data="data" />
         </div>
       </template>
     </div>
@@ -66,14 +27,15 @@
 
 <script>
 import { GetCoursewareContent, GetBookChapterStruct, GetCoursewareList_Chapter } from '@/api/book';
-import { previewComponentMap } from './components/common/data';
 
 import CatalogueTree from '@/views/book/components/catalogueTree.vue';
+import CoursewarePreview from './CoursewarePreview.vue';
 
 export default {
   name: 'PreviewPage',
   components: {
     CatalogueTree,
+    CoursewarePreview,
   },
   provide() {
     return {
@@ -87,7 +49,6 @@ export default {
       book_id: this.$route.params.book_id,
       chapter_id,
       curChapterId: chapter_id,
-      previewComponentMap,
       nodes: [],
       curPosition: [],
       courseware_list: [],
@@ -167,9 +128,6 @@ export default {
       }
       return node?.name;
     },
-    getCatalogueName() {
-      return this.curPosition.map((item, index) => this.getNodeName(index)).join(' / ');
-    },
     getMultipleColStyle(index, i) {
       let row = this.coursewareDataList[index].row_list[i];
       let col = row.col_list;
@@ -246,17 +204,45 @@ export default {
 
       // 计算 grid_template_columns
       let gridTemplateColumns = '';
+      let max = { row: 0, num: 0 };
+      grid.forEach(({ row }) => {
+        // 计算出 row 的哪个值最多
+        let len = grid.filter((item) => item.row === row).length;
+        if (max.num < len) {
+          max.num = len;
+          max.row = row;
+        }
+      });
       grid.forEach((item) => {
-        if (item.row === 1) {
+        if (item.row === max.row) {
           gridTemplateColumns += `${item.width} `;
         }
       });
 
+      // 计算 grid_template_rows
+      let gridTemplateRows = '';
+      // 将 grid 按照 row 分组
+      let gridMap = new Map();
+      grid.forEach((item) => {
+        if (!gridMap.has(item.row)) {
+          gridMap.set(item.row, []);
+        }
+        gridMap.get(item.row).push(item.height);
+      });
+      gridMap.forEach((value) => {
+        if (value.length === 1) {
+          gridTemplateRows += `${value[0]} `;
+        } else {
+          let isAllAuto = value.every((item) => item === 'auto'); // 是否全是 auto
+          gridTemplateRows += isAllAuto ? 'auto ' : `max(${value.join(', ')}) `;
+        }
+      });
+
       return {
         width: col.width,
         gridTemplateAreas,
         gridTemplateColumns,
-        gridTemplateRows: `${grid.map(({ height }) => height).join(' ')}`,
+        gridTemplateRows,
       };
     },
   },