浏览代码

创建和预览

dusenyao 1 年之前
父节点
当前提交
7cd240c41b

+ 15 - 1
src/router/modules/courseware.js

@@ -14,4 +14,18 @@ const CoursewareModulePage = {
   ],
 };
 
-export default [CoursewareModulePage];
+/**
+ * 课件预览组件
+ */
+const PreviewPage = {
+  path: '/preview',
+  component: DEFAULT,
+  children: [
+    {
+      path: 'courseware/:book_id',
+      component: () => import('@/views/book/courseware/preview/index.vue'),
+    },
+  ],
+};
+
+export default [CoursewareModulePage, PreviewPage];

+ 10 - 5
src/views/book/chapter.vue

@@ -16,8 +16,8 @@
         <el-button type="primary" class="add" @click="addCoursewareToBook">
           <SvgIcon icon-class="artboard" /> 添加教材内容
         </el-button>
-        <el-button class="preview"
-          ><SvgIcon icon-class="browse" /><span>预览</span>
+        <el-button class="preview">
+          <SvgIcon icon-class="browse" @click="enterPreview" /><span @click="enterPreview">预览</span>
           <el-button type="primary"><SvgIcon icon-class="save" />保存</el-button>
         </el-button>
       </div>
@@ -60,9 +60,10 @@ export default {
     const { book_id, chapter_id } = this.$route.query;
     return {
       chapter_id,
+      curChapterId: chapter_id, // 当前章节id
       book_id,
       visibleStatus: false,
-      curPosition: [],
+      curPosition: [], // 当前位置
       nodes: [],
       courseware_list: [],
     };
@@ -78,6 +79,9 @@ export default {
     goBack() {
       this.$router.push(`/book/setting/${this.book_id}`);
     },
+    enterPreview() {
+      this.$router.push(`/preview/courseware/${this.book_id}?chapter_id=${this.curChapterId}`);
+    },
     enterCourseware(courseware_id) {
       this.$router.push({
         path: `/courseware/create/${courseware_id}`,
@@ -127,11 +131,12 @@ export default {
     selectNode(id) {
       this.setCurPosition(id);
       this.getCoursewareList_Chapter(id);
+      this.curChapterId = id;
       this.visibleStatus = false;
     },
     addCoursewareToBook() {
-      AddCoursewareToBook({ book_id: this.book_id, chapter_id: this.chapter_id, name: '教材内容' }).then(() => {
-        this.getCoursewareList_Chapter(this.chapter_id);
+      AddCoursewareToBook({ book_id: this.book_id, chapter_id: this.curChapterId, name: '教材内容' }).then(() => {
+        this.getCoursewareList_Chapter(this.curChapterId);
         this.$message.success('添加成功');
       });
     },

+ 168 - 50
src/views/book/courseware/create/components/common/ModuleBase.vue

@@ -1,18 +1,40 @@
 <template>
-  <div class="module">
-    <div class="module-top">
-      <span class="title">{{ componentNameList[type] }}</span>
-      <div class="module-icon">
-        <span><SvgIcon icon-class="copy" size="10" /></span>
-        <span :class="[{ active: getCurSettingId() === id }]" @click="showSetting">
-          <SvgIcon icon-class="setup" size="10" />
-        </span>
-        <span @click="deleteComponent"><SvgIcon icon-class="delete" size="10" /></span>
+  <div class="module-wrapper">
+    <div
+      class="horizontal-line top"
+      :style="{ backgroundColor: bgColor }"
+      @mousedown="dragStart($event, 'ns-resize', 'top')"
+    ></div>
+    <div
+      class="vertical-line left"
+      :style="{ backgroundColor: bgColor }"
+      @mousedown="dragStart($event, 'ew-resize', 'left')"
+    ></div>
+    <div class="module" draggable="false">
+      <div class="module-top">
+        <span class="title">{{ componentNameList[type] }}</span>
+        <div class="module-icon">
+          <span><SvgIcon icon-class="copy" size="10" /></span>
+          <span :class="[{ active: getCurSettingId() === id }]" @click="showSetting">
+            <SvgIcon icon-class="setup" size="10" />
+          </span>
+          <span @click="deleteComponent"><SvgIcon icon-class="delete" size="10" /></span>
+        </div>
+      </div>
+      <div class="module-content">
+        <slot name="content"></slot>
       </div>
     </div>
-    <div class="module-content">
-      <slot name="content"></slot>
-    </div>
+    <div
+      class="vertical-line right"
+      :style="{ backgroundColor: bgColor }"
+      @mousedown="dragStart($event, 'ew-resize', 'right')"
+    ></div>
+    <div
+      class="horizontal-line bottom"
+      :style="{ backgroundColor: bgColor }"
+      @mousedown="dragStart($event, 'ns-resize', 'bottom')"
+    ></div>
   </div>
 </template>
 
@@ -21,7 +43,7 @@ import { componentNameList } from '@/views/book/courseware/data/bookType.js';
 
 export default {
   name: 'ModuleBase',
-  inject: ['id', 'showSetting', 'getCurSettingId'],
+  inject: ['id', 'showSetting', 'getCurSettingId', 'deleteComponent', 'handleComponentMove'],
   props: {
     type: {
       type: String,
@@ -31,64 +53,160 @@ export default {
   data() {
     return {
       componentNameList,
+      drag: {
+        dragging: false,
+        startX: 0,
+        startY: 0,
+        type: '',
+      },
+      bgColor: '#ebebeb',
     };
   },
+  created() {
+    document.addEventListener('mousemove', this.dragMove);
+    document.addEventListener('mouseup', this.dragEnd);
+  },
+  beforeDestroy() {
+    document.removeEventListener('mousemove', this.dragMove);
+    document.removeEventListener('mouseup', this.dragEnd);
+  },
   methods: {
-    deleteComponent() {
-      this.$emit('deleteComponent');
+    /**
+     * 拖拽开始
+     * @param {MouseEvent} event
+     * @param {string} cursor
+     * @param {string} type
+     */
+    dragStart(event, cursor, type) {
+      const { clientX, clientY } = event;
+      this.drag = {
+        dragging: true,
+        startX: clientX,
+        startY: clientY,
+        type,
+      };
+
+      this.bgColor = '#272727';
+      document.body.style.cursor = cursor;
+    },
+    /**
+     * 拖拽移动
+     * @param {MouseEvent} event
+     */
+    dragMove(event) {
+      if (!this.drag.dragging) return;
+
+      const { clientX, clientY } = event;
+      const { startX, startY, type } = this.drag;
+      const offsetX = clientX - startX;
+      const offsetY = clientY - startY;
+
+      this.handleComponentMove({ type, offsetX, offsetY });
+
+      this.drag.startX = clientX;
+      this.drag.startY = clientY;
+    },
+    /**
+     * 拖拽结束
+     */
+    dragEnd() {
+      this.drag = {
+        dragging: false,
+        startX: 0,
+        startY: 0,
+        type: '',
+      };
+
+      this.bgColor = '#ebebeb';
+      document.body.style.cursor = 'auto';
     },
   },
 };
 </script>
 
 <style lang="scss" scoped>
-.module {
-  padding: 8px;
-  background-color: #fff;
-  border: 1px solid #ebebeb;
-
-  &-top {
-    display: flex;
-    justify-content: space-between;
-    margin-bottom: 3px;
-
-    .title {
-      font-size: 12px;
-      color: $label-color;
+.module-wrapper {
+  display: grid;
+  grid-template:
+    'top top top' 1px
+    'left module right' auto
+    'bottom bottom bottom' 1px
+    / 1px auto 1px;
+
+  .horizontal-line {
+    width: 100%;
+    cursor: ns-resize;
+
+    &.top {
+      grid-area: top;
+    }
+
+    &.bottom {
+      grid-area: bottom;
     }
   }
 
-  &-icon {
-    display: flex;
-    column-gap: 8px;
+  .vertical-line {
+    cursor: ew-resize;
+
+    &.left {
+      grid-area: left;
+    }
 
-    span {
+    &.right {
+      grid-area: right;
+    }
+  }
+
+  .module {
+    grid-area: module;
+    padding: 8px;
+    overflow: auto;
+    background-color: #fff;
+
+    &-top {
       display: flex;
-      align-items: center;
-      justify-content: center;
-      width: 16px;
-      height: 16px;
-      cursor: pointer;
-      background-color: #fff;
-      border-radius: 20px;
+      justify-content: space-between;
+      margin-bottom: 3px;
 
-      &.active {
-        background-color: #c9c9c9;
+      .title {
+        font-size: 12px;
+        color: $label-color;
       }
+    }
 
-      .svg-icon.setup {
-        color: #000;
-      }
+    &-icon {
+      display: flex;
+      column-gap: 8px;
 
-      .svg-icon.delete {
-        color: #ed4646;
+      span {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 16px;
+        height: 16px;
+        cursor: pointer;
+        background-color: #fff;
+        border-radius: 20px;
+
+        &.active {
+          background-color: #c9c9c9;
+        }
+
+        .svg-icon.setup {
+          color: #000;
+        }
+
+        .svg-icon.delete {
+          color: #ed4646;
+        }
       }
     }
-  }
 
-  &-content {
-    padding: 8px;
-    background-color: #fff;
+    &-content {
+      padding: 8px;
+      background-color: #fff;
+    }
   }
 }
 </style>

+ 13 - 0
src/views/book/courseware/create/components/common/ModuleMixin.js

@@ -16,6 +16,14 @@ const mixin = {
       type: String,
       required: true,
     },
+    deleteComponent: {
+      type: Function,
+      required: true,
+    },
+    componentMove: {
+      type: Function,
+      required: true,
+    },
   },
   components: {
     ModuleBase,
@@ -24,6 +32,8 @@ const mixin = {
     return {
       showSetting: this.showSetting,
       id: this.id,
+      deleteComponent: this.deleteComponent,
+      handleComponentMove: this.handleComponentMove,
     };
   },
   inject: ['courseware_id'],
@@ -47,6 +57,9 @@ const mixin = {
     updateSetting(property) {
       this.data.property = property;
     },
+    handleComponentMove(data) {
+      this.componentMove({ ...data, id: this.id });
+    },
     saveCoursewareComponentContent() {
       SaveCoursewareComponentContent({
         courseware_id: this.courseware_id,

+ 238 - 72
src/views/book/courseware/create/components/createCanvas.vue

@@ -15,41 +15,52 @@
     ]"
   >
     <span class="drag-line" data-row="-1"></span>
-    <div v-for="(row, i) in data.row_list" :key="i" class="row">
-      <div
-        v-for="(col, j) in row.col_list"
-        :key="j"
-        class="col"
-        :style="{
-          width: col.width,
-          gridTemplateAreas: col.grid_template_areas,
-          gridTemplateColumns: col.grid_template_columns,
-          gridTemplateRows: col.grid_template_rows,
-        }"
-      >
-        <template v-for="(grid, k) in col.grid_list">
-          <span
-            v-if="k === 0"
-            :key="`start-${k}`"
-            class="drag-vertical-line"
-            :data-row="i"
-            :data-col="j"
-            :data-grid="k"
-          ></span>
-          <component
-            :is="componentList[grid.type]"
-            :id="grid.id"
-            ref="component"
-            :key="k"
-            :style="{ gridArea: grid.grid_area }"
-            @showSetting="showSetting"
-            @deleteComponent="deleteComponent"
-          />
-          <span :key="`end-${k}`" class="drag-vertical-line" :data-row="i" :data-col="j" :data-grid="k + 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" :data-row="i" :data-col="j"></span>
+          <div
+            :key="j"
+            :class="['col', `col-${i}-${j}`]"
+            :style="{
+              width: col.width,
+              gridTemplateAreas: col.grid_template_areas,
+              gridTemplateColumns: col.grid_template_columns,
+              gridTemplateRows: col.grid_template_rows,
+            }"
+          >
+            <!-- 网格 -->
+            <template v-for="(grid, k) in col.grid_list">
+              <span
+                v-if="k === 0"
+                :key="`start-${k}`"
+                class="drag-line grid-line"
+                :data-row="i"
+                :data-col="j"
+                :data-grid="k"
+              ></span>
+              <component
+                :is="componentList[grid.type]"
+                :id="grid.id"
+                ref="component"
+                :key="k"
+                :class="[grid.id]"
+                :style="{ gridArea: grid.grid_area, height: grid.height }"
+                :delete-component="deleteComponent(i, j, k)"
+                :component-move="componentMove(i, j, k)"
+                @showSetting="showSetting"
+              />
+              <span :key="`end-${k}`" class="drag-line grid-line" :data-row="i" :data-col="j" :data-grid="k + 1"></span>
+            </template>
+          </div>
+          <span :key="`end-${i}-${j}`" class="drag-vertical-line" :data-row="i" :data-col="j + 1"></span>
         </template>
       </div>
-      <span class="drag-line" :data-row="i"></span>
-    </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>
   </main>
 </template>
 
@@ -166,9 +177,108 @@ export default {
     showSetting(setting, type, id) {
       this.$emit('showSetting', setting, type, id);
     },
-    deleteComponent() {
-      console.log(1);
-      this.$message.success('删除成功');
+    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`;
+          }
+        }
+
+        if (type === 'left' && j > 0) {
+          const prevGrid = row.width_list[j - 1];
+          const prevWidth = Number(prevGrid.replace('%', ''));
+          const width = Number(row.width_list[j].replace('%', ''));
+          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}%`;
+          row.width_list[j] = `${width - ratio}%`;
+          this.$forceUpdate();
+        }
+        if (type === 'right' && j < row.col_list.length - 1) {
+          let nextGrid = row.width_list[j + 1];
+          const nextWidth = Number(nextGrid.replace('%', ''));
+          const width = Number(row.width_list[j].replace('%', ''));
+          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}%`;
+          row.width_list[j] = `${width + ratio}%`;
+          this.$forceUpdate();
+        }
+      };
+    },
+    /**
+     * 重新计算格子宽度
+     * @param {number} i 行
+     * @param {number} j 列
+     */
+    recalculateGridWidth(i, j) {
+      let col = this.data.row_list[i].col_list[j];
+      let grid_template_columns = '0';
+      col.grid_list.forEach(({ width }, i) => {
+        const w = `calc(${width} - ${(16 * (col.grid_list.length - 1)) / col.grid_list.length}px)`;
+        if (i === col.grid_list.length - 1) {
+          grid_template_columns += ` ${w} 0`;
+        } else {
+          grid_template_columns += ` ${w} 16px`;
+        }
+      });
+      col.grid_template_columns = grid_template_columns;
+    },
+    /**
+     * 删除组件
+     * @param {number} i 行
+     * @param {number} j 列
+     * @param {number} k 格子
+     */
+    deleteComponent(i, j, k) {
+      return () => {
+        this.data.row_list[i].col_list[j].grid_list.splice(k, 1);
+
+        const colList = this.data.row_list[i].col_list[j];
+        if (colList.grid_list.length === 0) {
+          this.data.row_list[i].col_list.splice(j, 1);
+          let width_list = this.data.row_list[i].width_list;
+          const delW = width_list[j];
+          width_list.splice(j, 1);
+          this.data.row_list[i].width_list = width_list.map((item) => {
+            return `${Number(item.replace('%', '')) + Number(delW.replace('%', '') / width_list.length)}%`;
+          });
+        }
+
+        if (this.data.row_list[i].col_list.length === 0) {
+          this.data.row_list.splice(i, 1);
+        }
+
+        const gridList = this.data.row_list[i]?.col_list[j]?.grid_list;
+        if (gridList?.length > 0) {
+          colList.grid_template_columns = `100%`;
+
+          const grid_template_areas = gridList.map(({ grid_area }) => grid_area).join(' ');
+          colList.grid_template_areas = `'.' '${grid_template_areas}' '.'`;
+
+          colList.grid_template_rows = `0 ${gridList.map(({ height }) => height).join(' 16px ')} 0`;
+        }
+      };
     },
     /**
      * 拖拽开始
@@ -256,69 +366,117 @@ export default {
         if (this.curRow >= -1 && this.curCol <= -1) {
           this.data.row_list.splice(this.curRow + 1, 0, this.calculateRowInsertedObject());
         }
-        if (this.curRow >= -1 && this.curCol > -1 && this.curGrid > -1) {
+
+        if (this.curRow >= -1 && this.curCol > -1 && this.curGrid <= -1) {
           this.calculateColObject();
         }
+
+        if (this.curRow >= -1 && this.curCol > -1 && this.curGrid > -1) {
+          this.calculateGridObject();
+        }
       }
       this.enterCanvas = false;
     },
+    getMultipleColStyle(i) {
+      let row = this.data.row_list[i];
+      let col = row.col_list;
+      if (col.length <= 1) {
+        return {
+          gridTemplateColumns: '0 100% 0',
+        };
+      }
+      let str = row.width_list
+        .map((item) => {
+          return `calc(${item} - ${(16 * (row.width_list.length - 1)) / row.width_list.length}px)`;
+        })
+        .join(' 16px ');
+      let gridTemplateColumns = `0 ${str} 0`;
+
+      return {
+        gridAutoFlow: 'column',
+        gridTemplateColumns,
+        gridTemplateRows: 'auto',
+      };
+    },
     /**
-     * 计算列插入的对象
+     * 计算网格插入的对象
      */
-    calculateColObject() {
-      let num = 0; // 计算当前行之前的所有 grid_list 的数量
-      for (let i = 0; i <= this.curRow; i++) {
-        this.data.row_list[i]?.col_list.forEach((item) => {
-          num += item.grid_list.length;
-        });
-      }
-      const id = getRandomNumber(12);
-      const letter = String.fromCharCode(65 + num);
-      let col = this.data.row_list[this.curRow].col_list[this.curCol];
+    calculateGridObject() {
+      const id = `ID-${getRandomNumber(12)}`;
+      const letter = `L${getRandomNumber(6)}`;
 
-      col.grid_list.splice(this.curGrid, 0, {
+      let row = this.data.row_list[this.curRow];
+      let col = row.col_list[this.curCol];
+      let grid = col.grid_list;
+
+      grid.splice(this.curGrid, 0, {
         id,
         grid_area: letter,
+        width: '100%',
+        height: 'auto',
         type: this.curType,
       });
+      col.grid_template_areas = `'.' ${grid.map(({ grid_area }) => `'${grid_area}'`).join(" '.' ")} '.'`;
+      col.grid_template_rows = `0 ${grid.map(({ height }) => height).join(' 16px ')} 0`;
+    },
+    /**
+     * 计算列插入的对象
+     */
+    calculateColObject() {
+      const id = `ID-${getRandomNumber(12)}`;
+      const letter = `L${getRandomNumber(6)}`;
 
-      let grid_template_columns = '0';
-      col.grid_list.forEach((item, i) => {
-        if (i === col.grid_list.length - 1) grid_template_columns += ' auto 0';
-        else grid_template_columns += ' auto 16px';
+      let row = this.data.row_list[this.curRow];
+      let col = row.col_list;
+
+      let w = 0;
+      row.width_list.forEach((item, i) => {
+        let itemW = Number(item.replace('%', ''));
+        let rowW = itemW / (row.width_list.length + 1);
+        w += rowW;
+        row.width_list[i] = `${itemW - rowW}%`;
       });
-      col.grid_template_columns = grid_template_columns;
+      row.width_list.splice(this.curCol, 0, `${w}%`);
 
-      let grid_template_areas = '.';
-      col.grid_list.forEach(({ grid_area }) => {
-        grid_template_areas += ` ${grid_area} .`;
+      col.splice(this.curCol, 0, {
+        width: '100%',
+        height: 'auto',
+        grid_template_areas: `'.' '${letter}' '.'`,
+        grid_template_columns: '100%',
+        grid_template_rows: '0 auto 0',
+        grid_list: [
+          {
+            id,
+            grid_area: letter,
+            width: '100%',
+            height: 'auto',
+            type: this.curType,
+          },
+        ],
       });
-      col.grid_template_areas = `"${grid_template_areas}"`;
     },
     /**
      * 计算行插入的对象
      */
     calculateRowInsertedObject() {
-      let num = 0; // 计算当前行之前的所有 grid_list 的数量
-      for (let i = 0; i <= this.curRow; i++) {
-        this.data.row_list[i]?.col_list.forEach((item) => {
-          num += item.grid_list.length;
-        });
-      }
-      const id = getRandomNumber(12);
-      const letter = String.fromCharCode(65 + num);
+      const id = `ID-${getRandomNumber(12)}`;
+      const letter = `L${getRandomNumber(6)}`;
 
       return {
+        width_list: ['100%'],
         col_list: [
           {
             width: '100%',
-            grid_template_areas: `'. ${letter} .'`,
-            grid_template_columns: '0 auto 0',
-            grid_template_rows: 'auto',
+            height: 'auto',
+            grid_template_areas: `'.' '${letter}' '.'`,
+            grid_template_columns: '100%',
+            grid_template_rows: '0 auto 0',
             grid_list: [
               {
                 id,
                 grid_area: letter,
+                width: '100%',
+                height: 'auto',
                 type: this.curType,
               },
             ],
@@ -365,9 +523,12 @@ export default {
   border-radius: 4px;
 
   .row {
-    display: flex;
-    flex-direction: column;
-    row-gap: 6px;
+    display: grid;
+    row-gap: 16px;
+
+    > .drag-vertical-line:not(:first-child, :last-child) {
+      left: 6px;
+    }
 
     .col {
       display: grid;
@@ -393,6 +554,11 @@ export default {
     background-color: #379fff;
     border-radius: 4px;
     opacity: 0;
+
+    &.grid-line:not(:first-child, :last-child) {
+      position: relative;
+      top: 6px;
+    }
   }
 
   .drag-vertical-line {

+ 1 - 1
src/views/book/courseware/create/index.vue

@@ -25,7 +25,7 @@
         <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><i class="el-icon-close"></i> 退出编辑</span>
+          <span @click="back"><i class="el-icon-close"></i> 退出编辑</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">

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

@@ -0,0 +1,29 @@
+import { GetCoursewareComponentContent_View } from '@/api/book';
+
+const mixin = {
+  data() {
+    return {
+      courseware_id: this.$route.params.courseware_id,
+    };
+  },
+  props: {
+    componentId: {
+      type: String,
+      required: true,
+    },
+  },
+  created() {
+    this.getCoursewareComponentContent();
+  },
+  methods: {
+    getCoursewareComponentContent_View() {
+      GetCoursewareComponentContent_View({ courseware_id: this.courseware_id, component_id: this.componentId }).then(
+        ({ content }) => {
+          if (content) this.data = JSON.parse(content);
+        },
+      );
+    },
+  },
+};
+
+export default mixin;

+ 126 - 0
src/views/book/courseware/preview/index.vue

@@ -0,0 +1,126 @@
+<template>
+  <div class="preview">
+    <div></div>
+    <div>
+      <div
+        class="content"
+        :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}%`
+              : '',
+          },
+        ]"
+      >
+        <div></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { GetCoursewareContent, GetBookChapterStruct, GetCoursewareList_Chapter } from '@/api/book';
+
+export default {
+  name: 'PreviewPage',
+  data() {
+    const { chapter_id } = this.$route.query;
+    return {
+      book_id: this.$route.params.book_id,
+      chapter_id,
+      curChaterId: chapter_id,
+      data: {
+        background_image_url: '',
+        background_position: {
+          width: 100,
+          height: 100,
+          top: 0,
+          left: 0,
+        },
+        // 组件列表
+        row_list: [],
+      },
+      nodes: [],
+      curPosition: [],
+      courseware_list: [],
+    };
+  },
+  created() {
+    GetBookChapterStruct({ book_id: this.book_id, node_deep_mode: 0 }).then(({ nodes }) => {
+      this.nodes = nodes ?? [];
+      this.setCurPosition(this.chapter_id);
+    });
+    this.getCoursewareList_Chapter(this.chapter_id);
+  },
+  methods: {
+    goBack() {
+      this.$router.push(`/chapter?chapter_id=${this.chapter_id}&book_id=${this.book_id}`);
+    },
+    /**
+     * 根据节点id查找节点在nodes中的位置
+     * @param {array} nodes 节点数组
+     * @param {string} id 节点id
+     * @param {array} position 节点位置
+     */
+    findNodeIndexById(nodes, id, position = []) {
+      for (let i = 0; i < nodes.length; i++) {
+        const node = nodes[i];
+        if (node.id === id) {
+          position.push(i);
+          return position;
+        }
+        if (node.nodes && node.nodes.length > 0) {
+          const childPosition = this.findNodeIndexById(node.nodes, id, [...position, i]);
+          if (childPosition.length > 0) {
+            return childPosition;
+          }
+        }
+      }
+      return [];
+    },
+    setCurPosition(id) {
+      this.curPosition = this.findNodeIndexById(this.nodes, id);
+    },
+    getCoursewareList_Chapter(chapter_id) {
+      GetCoursewareList_Chapter({ chapter_id }).then(({ courseware_list }) => {
+        this.courseware_list = courseware_list ?? [];
+      });
+    },
+    getCoursewareContent() {
+      GetCoursewareContent({ id: this.courseware_id }).then(({ content }) => {
+        if (content) this.data = JSON.parse(content);
+      });
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.preview {
+  .content {
+    display: flex;
+    flex-direction: column;
+    row-gap: 6px;
+    width: 100%;
+    min-height: calc(100% - 56px);
+    padding: 24px;
+    background-color: #fff;
+    background-repeat: no-repeat;
+    border-radius: 4px;
+
+    .row {
+      display: grid;
+      row-gap: 16px;
+      align-items: start;
+
+      .col {
+        display: grid;
+      }
+    }
+  }
+}
+</style>