浏览代码

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

zq 1 年之前
父节点
当前提交
ff4a4f0796

+ 7 - 0
src/api/book.js

@@ -153,3 +153,10 @@ export function GetCoursewareComponentContent_View(data) {
     data,
   );
 }
+
+/**
+ * 删除互动课件
+ */
+export function DeleteCourseware(data) {
+  return http.post(`${process.env.VUE_APP_BookWebSI}?MethodName=book-courseware_manager-DeleteCourseware`, data);
+}

+ 14 - 0
src/styles/mixin.scss

@@ -1,5 +1,6 @@
 @use './variables.scss' as *;
 
+// 富文本样式
 @mixin rich-text($font-size: 16pt) {
   font-family: 'Arial', 'Helvetica', sans-serif;
   font-size: $font-size;
@@ -13,6 +14,7 @@
   }
 }
 
+// 设置基础样式
 @mixin setting-base {
   .serial-number {
     :deep .el-form-item__content {
@@ -35,6 +37,7 @@
   }
 }
 
+// 预览基础样式
 @mixin preview-base {
   display: grid;
   gap: 6px;
@@ -63,3 +66,14 @@
     width: 100%;
   }
 }
+
+// 序号样式
+@mixin serial-number($height: 36px) {
+  width: 40px;
+  height: $height;
+  padding: 4px 8px;
+  font-size: 14px;
+  color: $text-color;
+  background-color: $fill-color;
+  border-radius: 2px;
+}

+ 1 - 1
src/views/book/chapter.vue

@@ -185,7 +185,7 @@ export default {
         .then(() => {
           DeleteCourseware({ id })
             .then(() => {
-              this.getCoursewareList_Chapter(id);
+              this.getCoursewareList_Chapter(this.curChapterId);
               this.$message.success('删除成功');
             })
             .catch(() => {

+ 3 - 1
src/views/book/courseware/create/components/CreateCanvas.vue

@@ -54,7 +54,7 @@
                   :is="componentList[grid.type]"
                   :id="grid.id"
                   ref="component"
-                  :key="`grid-${i}-${j}-${k}-${grid.id}`"
+                  :key="`grid-${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)"
@@ -610,6 +610,8 @@ export default {
         this.drag.dragging = false;
       }
 
+      if (!this.isEdit) return;
+
       if (this.enterCanvas) {
         if (this.curRow >= -1 && this.curCol <= -1) {
           this.calculateRowInsertedObject();

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

@@ -34,7 +34,7 @@
                 :style="{
                   gridArea: 'preview',
                   height: grid.height,
-                  overflow: 'auto',
+                  overflow: 'hidden',
                 }"
               />
             </div>

+ 172 - 0
src/views/book/courseware/create/components/question/matching/Matching.vue

@@ -0,0 +1,172 @@
+<template>
+  <ModuleBase :type="data.type">
+    <template #content>
+      <ul class="option-list">
+        <li v-for="(li, i) in data.option_list" :key="i" class="option-item">
+          <div v-for="(item, j) in li" :key="item.mark" class="option">
+            <span class="serial-number">{{ computeOptionMethods[data.property.serial_number_type_list[j]](i) }}</span>
+            <el-input v-model="item.content" placeholder="请输入" />
+          </div>
+        </li>
+      </ul>
+      <div class="answer-tips">答案:</div>
+      <ul class="answer-list">
+        <li v-for="(li, i) in data.answer.answer_list" :key="i" class="answer-item">
+          <template v-for="(item, j) in li">
+            <span v-if="j === 0" :key="`${j}-${item.mark}`">
+              {{ computeOptionMethods[data.property.serial_number_type_list[j]](i) }}
+            </span>
+            <el-select v-else :key="`${j}-${item.mark}`" v-model="item.mark" placeholder="请选择">
+              <el-option
+                v-for="answer in answerList[j - 1]"
+                :key="answer.value"
+                :label="answer.label"
+                :value="answer.value"
+              />
+            </el-select>
+          </template>
+        </li>
+      </ul>
+    </template>
+  </ModuleBase>
+</template>
+
+<script>
+import ModuleMixin from '../../common/ModuleMixin';
+
+import { getMatchingData, getOption, getOptionItem } from '@/views/book/courseware/data/matching';
+import { computeOptionMethods, serialNumberTypeList } from '@/views/book/courseware/data/common';
+
+export default {
+  name: 'MatchingPage',
+  mixins: [ModuleMixin],
+  data() {
+    return {
+      data: getMatchingData(),
+      computeOptionMethods,
+      serialNumberTypeList,
+    };
+  },
+  computed: {
+    answerList() {
+      let list = Array.from({ length: this.data.property.column_num - 1 }, () => []);
+      for (let i = 0; i < this.data.option_list.length; i++) {
+        for (let j = 1; j < this.data.option_list[i].length; j++) {
+          list[j - 1].push({
+            label: computeOptionMethods[this.data.property.serial_number_type_list[j]](i),
+            value: this.data.option_list[i][j].mark,
+          });
+        }
+      }
+      return list;
+    },
+  },
+  watch: {
+    'data.property.column_num': {
+      handler(val) {
+        let optionNum = this.data.option_list[0].length;
+        if (val > optionNum) {
+          // 修改序号类型列表
+          this.data.property.serial_number_type_list.push(
+            serialNumberTypeList[val]?.value || serialNumberTypeList[0].value,
+          );
+          // 增加选项
+          for (let i = 0; i < this.data.option_list.length; i++) {
+            this.data.option_list[i].push(getOptionItem());
+          }
+          // 修改答案列表
+          this.data.answer.answer_list.forEach((li) => {
+            li.push({ mark: '' });
+          });
+          return;
+        }
+        if (val < optionNum) {
+          this.data.property.serial_number_type_list.splice(-1, 1);
+          this.data.option_list.forEach((li) => {
+            li.splice(-1, 1);
+          });
+          // 修改答案列表
+          this.data.answer.answer_list.forEach((li) => {
+            li.splice(-1, 1);
+          });
+        }
+      },
+    },
+    'data.property.row_num': {
+      handler(val) {
+        let optionNum = this.data.option_list.length;
+        if (val > optionNum) {
+          for (let i = 0; i < val - optionNum; i++) {
+            this.data.option_list.push(getOption(this.data.property.column_num));
+          }
+          // 增加答案列表
+          this.data.answer.answer_list.push(Array(this.data.property.column_num).fill(''));
+          // 将答案列表最后一项的第一个元素设置进答案列表第一项
+          this.data.answer.answer_list[this.data.answer.answer_list.length - 1][0] = {
+            mark: this.data.option_list[this.data.option_list.length - 1][0].mark,
+          };
+          return;
+        }
+        if (val < optionNum) {
+          this.data.option_list.splice(val);
+          this.data.answer.answer_list.splice(val);
+        }
+      },
+    },
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.option-list {
+  display: flex;
+  flex-direction: column;
+  row-gap: 16px;
+
+  .option-item {
+    display: flex;
+    column-gap: 16px;
+
+    .option {
+      display: flex;
+      flex: 1;
+      column-gap: 4px;
+      align-items: center;
+
+      .serial-number {
+        @include serial-number(32px);
+      }
+    }
+  }
+}
+
+.answer-tips {
+  margin: 16px 0;
+}
+
+.answer-list {
+  display: flex;
+  flex-direction: column;
+  row-gap: 16px;
+
+  .answer-item {
+    display: flex;
+    column-gap: 16px;
+
+    span:first-child {
+      width: 70px;
+      height: 32px;
+      padding: 4px 8px;
+      color: #c9cdd4;
+      background-color: $fill-color;
+    }
+
+    .el-select {
+      flex: 1;
+    }
+  }
+}
+</style>

+ 94 - 0
src/views/book/courseware/create/components/question/matching/MatchingSetting.vue

@@ -0,0 +1,94 @@
+<template>
+  <div>
+    <el-form :model="property" label-width="42px">
+      <el-form-item class="serial-number" label-width="0">
+        <div>序号</div>
+        <div class="serial-number-list">
+          <div
+            v-for="(item, i) in property.serial_number_type_list"
+            :key="i"
+            class="serial-number-item"
+            @dblclick="changeSerialNumberType(i, item)"
+          >
+            {{ computeOptionMethods[item](0) }}
+          </div>
+        </div>
+      </el-form-item>
+      <el-divider />
+      <el-form-item label="列数">
+        <el-input-number v-model="property.column_num" :min="2" :max="3" />
+      </el-form-item>
+      <el-form-item label="行数">
+        <el-input-number v-model="property.row_num" :min="1" />
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
+
+import { getMatchingProperty } from '@/views/book/courseware/data/matching';
+import { computeOptionMethods, serialNumberTypeList } from '@/views/book/courseware/data/common';
+
+export default {
+  name: 'MatchingSetting',
+  mixins: [SettingMixin],
+  data() {
+    return {
+      property: getMatchingProperty(),
+      computeOptionMethods,
+    };
+  },
+  methods: {
+    /**
+     * 获取下一个序号类型
+     * @param {number} item 序号类型值
+     */
+    getNextSerialNumberType(item) {
+      let findIndex = serialNumberTypeList.findIndex(({ value }) => value === item);
+      return serialNumberTypeList[findIndex + 1]?.value || serialNumberTypeList[0].value;
+    },
+    /**
+     * 改变序号类型
+     * @param {number} index 序号类型索引
+     * @param {number} item 序号类型值
+     */
+    changeSerialNumberType(index, item) {
+      this.$set(this.property.serial_number_type_list, index, this.getNextSerialNumberType(item));
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.el-form {
+  @include setting-base;
+
+  .serial-number {
+    :deep .el-form-item__content {
+      display: flex;
+      flex-direction: column;
+      row-gap: 8px;
+      align-items: flex-start;
+
+      .serial-number-list {
+        display: flex;
+        flex-wrap: wrap;
+        column-gap: 8px;
+
+        .serial-number-item {
+          min-width: 80px;
+          height: 32px;
+          padding: 4px 12px;
+          line-height: 26px;
+          cursor: pointer;
+          background-color: $fill-color;
+        }
+      }
+    }
+  }
+}
+</style>

+ 25 - 0
src/views/book/courseware/create/components/question/sort/Sort.vue

@@ -0,0 +1,25 @@
+<template>
+  <ModuleBase :type="data.type">
+    <template #content> </template>
+  </ModuleBase>
+</template>
+
+<script>
+import ModuleMixin from '../../common/ModuleMixin';
+
+import { arrangeTypeList, getSortData } from '@/views/book/courseware/data/sort';
+
+export default {
+  name: 'SortPage',
+  mixins: [ModuleMixin],
+  data() {
+    return {
+      data: getSortData(),
+      arrangeTypeList,
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 57 - 0
src/views/book/courseware/create/components/question/sort/SortSetting.vue

@@ -0,0 +1,57 @@
+<template>
+  <div>
+    <el-form :model="property" 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-input-number v-model="property.option_count" :min="1" />
+      </el-form-item>
+      <el-form-item label="排列">
+        <el-radio
+          v-for="{ value, label } in arrangeTypeList"
+          :key="value"
+          v-model="property.arrange_type"
+          :label="value"
+        >
+          {{ label }}
+        </el-radio>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
+
+import { arrangeTypeList, getSortProperty } from '@/views/book/courseware/data/sort';
+
+export default {
+  name: 'SortPreview',
+  mixins: [SettingMixin],
+  data() {
+    return {
+      property: getSortProperty(),
+      arrangeTypeList,
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 9 - 0
src/views/book/courseware/data/bookType.js

@@ -16,6 +16,8 @@ import DescribePage from '../create/components/base/describe/Describe.vue';
 import DescribeSetting from '../create/components/base/describe/DescribeSetting.vue';
 import LabelPage from '../create/components/base/label/Label.vue';
 import LabelSetting from '../create/components/base/label/LabelSetting.vue';
+import MatchingPage from '../create/components/question/matching/Matching.vue';
+import MatchingSetting from '../create/components/question/matching/MatchingSetting.vue';
 
 export const bookTypeOption = [
   {
@@ -92,6 +94,13 @@ export const bookTypeOption = [
         component: SelectPage,
         set: SelectSetting,
       },
+      {
+        value: 'matching',
+        label: '连线组件',
+        icon: '',
+        component: MatchingPage,
+        set: MatchingSetting,
+      },
     ],
   },
 ];

+ 9 - 8
src/views/book/courseware/data/common.js

@@ -40,6 +40,12 @@ export const pinyinPositionList = [
   { value: 'bottom', label: '下' },
 ];
 
+// 排列方式
+export const arrangeTypeList = [
+  { value: 'horizontal', label: '横排' },
+  { value: 'vertical', label: '竖排' },
+];
+
 /**
  * 判断序号类型
  * @param {string} str
@@ -50,13 +56,7 @@ export function checkString(str) {
   const capital = /[A-Z]/.test(str); // 判断是否包含大写字母
   const bracket_number = /\(\d+\)/.test(str); // 判断是否包含括号数字,例如 (123)
   const obj = { number, letter, capital, bracket_number };
-  let strType = '';
-  Object.keys(obj).forEach((key) => {
-    if (obj[key]) {
-      strType = key;
-      return true;
-    }
-  });
+  let strType = Object.keys(obj).find((key) => obj[key]);
   return strType;
 }
 
@@ -68,6 +68,7 @@ export const computeOptionMethods = {
   [serialNumberTypeList[3].value]: (i) => `${String.fromCharCode(65 + i)}`, // 大写
 };
 
+// 反向计算选项方法
 export const reversedComputeOptionMethods = {
   [serialNumberTypeList[0].value]: (i) => Number(i),
   [serialNumberTypeList[1].value]: (i) => Number(i.replace('(', '').replace(')', '')),
@@ -77,7 +78,7 @@ export const reversedComputeOptionMethods = {
 
 /**
  * 改变选项序号
- * @param {object} property
+ * @param {object} property 选项属性
  */
 export function switchSerialNumber(property) {
   let relNum = 1;

+ 64 - 0
src/views/book/courseware/data/matching.js

@@ -0,0 +1,64 @@
+import { serialNumberTypeList } from '@/views/book/courseware/data/common';
+import { getRandomNumber } from '@/utils';
+
+/**
+ * 获取连线题属性
+ * @param {number} column_num 列数 2 ~ 3
+ */
+export function getMatchingProperty(column_num = 2) {
+  if (column_num !== 2 && column_num !== 3) {
+    throw new Error('column_num must be 2 or 3');
+  }
+
+  return {
+    serial_number_type_list: serialNumberTypeList.slice(0, column_num).map((item) => item.value),
+    row_num: 2, // 行数
+    column_num, // 列数 2 ~ 3
+  };
+}
+
+export function getOptionItem() {
+  return {
+    content: '',
+    mark: getRandomNumber(),
+  };
+}
+
+/**
+ * 获取连线题选项
+ * @param {number} column_num 列数 2 ~ 3
+ */
+export function getOption(column_num = 2) {
+  if (column_num !== 2 && column_num !== 3) {
+    throw new Error('column_num must be 2 or 3');
+  }
+
+  let option = [];
+  for (let i = 0; i < column_num; i++) {
+    option.push(getOptionItem());
+  }
+
+  return option;
+}
+
+export const svgNS = 'http://www.w3.org/2000/svg'; // SVG命名空间
+
+export function getMatchingData() {
+  let option_list = [getOption(), getOption()];
+
+  let answer_list = option_list.map((item) =>
+    item.map(({ mark }, i) => {
+      return i === 0 ? { mark } : { mark: '' };
+    }),
+  );
+
+  return {
+    type: 'matching',
+    title: '连线',
+    option_list,
+    answer: {
+      answer_list,
+    },
+    property: getMatchingProperty(),
+  };
+}

+ 2 - 5
src/views/book/courseware/data/select.js

@@ -2,14 +2,11 @@ import {
   snGenerationMethodList,
   serialNumberTypeList,
   serialNumberPositionList,
+  arrangeTypeList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
 
-// 排列方式
-export const arrangeTypeList = [
-  { value: 'horizontal', label: '横排' },
-  { value: 'vertical', label: '竖排' },
-];
+export { arrangeTypeList };
 
 /**
  * 获取选择题属性

+ 27 - 0
src/views/book/courseware/data/sort.js

@@ -0,0 +1,27 @@
+import {
+  arrangeTypeList,
+  serialNumberTypeList,
+  serialNumberPositionList,
+  snGenerationMethodList,
+} from '@/views/book/courseware/data/common';
+
+export { arrangeTypeList };
+
+export function getSortProperty() {
+  return {
+    serial_number: 1,
+    sn_type: serialNumberTypeList[0].value,
+    sn_position: serialNumberPositionList[0].value,
+    sn_generation_method: snGenerationMethodList[0].value,
+    arrange_direction: arrangeTypeList[0].value,
+  };
+}
+
+export function getSortData() {
+  return {
+    type: 'sort',
+    title: '排序',
+    option_list: [],
+    property: getSortProperty(),
+  };
+}

+ 1 - 1
src/views/book/courseware/preview/CoursewarePreview.vue

@@ -198,7 +198,7 @@ export default {
     .col {
       display: grid;
       gap: 16px;
-      overflow: auto;
+      overflow: hidden;
     }
   }
 }

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

@@ -7,6 +7,7 @@ import StemPreview from '../stem/StemPreview.vue';
 import DescribePreview from '../describe/DescribePreview.vue';
 import LabelPreview from '../label/LabelPreview.vue';
 import SelectPreview from '../select/SelectPreview.vue';
+import MatchingPreview from '../matching/MatchingPreview.vue';
 
 export const previewComponentMap = {
   audio: AudioPreview,
@@ -18,4 +19,5 @@ export const previewComponentMap = {
   describe: DescribePreview,
   label: LabelPreview,
   select: SelectPreview,
+  matching: MatchingPreview,
 };

+ 374 - 0
src/views/book/courseware/preview/components/matching/MatchingPreview.vue

@@ -0,0 +1,374 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+  <div class="matching-preview">
+    <ul ref="list" class="option-list">
+      <li v-for="(item, i) in data.option_list" :key="i" class="list-item">
+        <div
+          v-for="({ content, mark }, j) in item"
+          :key="mark"
+          :class="['item-wrapper', `item-${mark}`]"
+          :style="{ cursor: disabled ? 'default' : 'pointer' }"
+          @mousedown="mousedown($event, i, j, mark)"
+          @mouseup="mouseup($event, i, j, mark)"
+          @click="handleClickConnection($event, i, j, mark)"
+        >
+          <span class="content rich-text" v-html="sanitizeHTML(content)"></span>
+        </div>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import { getMatchingData, svgNS } from '@/views/book/courseware/data/matching';
+
+import PreviewMixin from '../common/PreviewMixin';
+
+export default {
+  name: 'MatchingPreview',
+  mixins: [PreviewMixin],
+  data() {
+    return {
+      data: getMatchingData(),
+      answerList: [], // 答案列表
+      curConnectionPoint: { i: -1, j: -1, mark: '' }, // 当前连线点
+      // 拖拽相关
+      drag: false,
+      mouseEvent: {
+        clientX: 0,
+        clientY: 0,
+      },
+    };
+  },
+  watch: {
+    'data.option_list': {
+      handler(val) {
+        this.clearLine();
+        if (!val) return;
+        let list = val.map((item) => {
+          return item.map(({ mark }) => {
+            return { mark, preMark: '', nextMark: '' };
+          });
+        });
+        this.$set(this, 'answerList', list);
+      },
+      immediate: true,
+    },
+  },
+  created() {
+    document.addEventListener('click', this.handleEventConnection);
+    document.addEventListener('mousemove', this.documentMousemouse);
+    document.addEventListener('mouseup', this.documentMouseup);
+  },
+  beforeDestroy() {
+    document.removeEventListener('click', this.handleEventConnection);
+    document.removeEventListener('mousemove', this.documentMousemouse);
+    document.removeEventListener('mouseup', this.documentMouseup);
+  },
+  methods: {
+    /* 用 mouse 事件模拟拖拽 开始*/
+    documentMousemouse(e) {
+      if (!this.drag) return;
+      // 使用 svg 绘制跟随鼠标移动的连接线
+      let svg = document.querySelector('.move-connection');
+      let list = this.$refs.list.getBoundingClientRect(); // 列表的位置
+      let { clientX, clientY } = e;
+      let isLeft = clientX < this.mouseEvent.clientX; // 鼠标是否向左移动
+      // 计算 svg 的宽度,宽度不能超过列表的宽度
+      let width = Math.min(list.width, isLeft ? list.width - clientX + list.left - 1 : clientX - list.left - 1);
+      if (svg) {
+        svg.setAttribute(
+          'style',
+          `position: absolute; ${isLeft ? 'right: 0;' : 'left: 0;'} width: ${width}px; height: 100%;`,
+        );
+      } else {
+        svg = document.createElementNS(svgNS, 'svg');
+        svg.classList.add('move-connection');
+        svg.setAttribute('style', `position: absolute; width: ${width}px; height: 100%;`);
+        let path = document.createElementNS(svgNS, 'path');
+        this.setPathAttr(path);
+        svg.appendChild(path);
+        this.$refs.list.appendChild(svg);
+      }
+      let top = this.mouseEvent.clientY - list.top;
+      let left = isLeft
+        ? this.mouseEvent.clientX - list.left - Math.abs(width - list.width)
+        : this.mouseEvent.clientX - list.left;
+      let mouseX = isLeft ? clientX - list.left - Math.abs(width - list.width) : clientX - list.left;
+      let mouseY = clientY - list.top;
+      let path = svg.querySelector('path');
+      path.setAttribute('d', `M ${left} ${top} L ${mouseX} ${mouseY}`);
+    },
+    documentMouseup() {
+      if (!this.drag) return;
+      this.drag = false;
+      document.querySelector('.move-connection')?.remove();
+      document.body.style.userSelect = 'auto'; // 允许选中文本
+      this.mousePointer = { i: -1, j: -1, mark: '' };
+      this.mouseEvent = { clientX: 0, clientY: 0 };
+    },
+    /**
+     * 鼠标按下事件,设置当前连线点
+     * @param {PointerEvent} e 事件对象
+     * @param {number} i 选项列表索引
+     * @param {number} j 选项索引
+     * @param {string} mark 选项标识
+     */
+    mousedown(e, i, j, mark) {
+      this.drag = true;
+      document.body.style.userSelect = 'none'; // 禁止选中文本
+      this.mouseEvent = { clientX: e.clientX, clientY: e.clientY };
+      this.mousePointer = { i, j, mark };
+    },
+    /**
+     * 鼠标抬起事件,如果是一个合适的连接点,则创建连接线
+     * @param {PointerEvent} e 事件对象
+     * @param {Number} i 选项列表索引
+     * @param {Number} j 选项索引
+     * @param {String} mark 选项标识
+     */
+    mouseup(e, i, j, mark) {
+      let { i: curI, j: curJ, mark: curMark } = this.mousePointer;
+      if (curI === -1 && curJ === -1) return;
+      if (Math.abs(curJ - j) > 1 || mark === curMark) return;
+      this.changeConnectionList(mark, j, true);
+      this.createLine(mark, true);
+    },
+    /* 用 mouse 事件模拟拖拽 结束 */
+
+    // 重置当前连线点
+    resetCurConnectionPoint() {
+      this.curConnectionPoint = { i: -1, j: -1, mark: '' };
+    },
+    /**
+     * 当点击的不是连线点时,清除所有连线点的选中状态
+     * @param {PointerEvent} e
+     */
+    handleEventConnection(e) {
+      let currentNode = e.target;
+      while (currentNode !== null) {
+        if (currentNode.classList && currentNode.classList.contains('item-wrapper')) {
+          break;
+        }
+        currentNode = currentNode.parentNode;
+      }
+      if (currentNode) return;
+      Array.from(document.getElementsByClassName('item-wrapper')).forEach((item) => {
+        item.classList.remove('focus');
+      });
+      this.resetCurConnectionPoint();
+    },
+
+    /**
+     * 处理点击连线
+     * @param {PointerEvent} e 事件对象
+     * @param {Number} i 选项列表索引
+     * @param {Number} j 选项索引
+     * @param {String} mark 选项标识
+     */
+    handleClickConnection(e, i, j, mark) {
+      let { i: curI, j: curJ, mark: curMark } = this.curConnectionPoint;
+      // 获取 item-wrapper 元素
+      let currentNode = e.target;
+      while (currentNode !== null) {
+        if (currentNode.classList && currentNode.classList.contains('item-wrapper')) {
+          break;
+        }
+        currentNode = currentNode.parentNode;
+      }
+      // 如果当前连线点不存在或就是当前连线点,则设置当前连线点
+      if ((curI === -1 && curJ === -1) || mark === curMark) {
+        this.curConnectionPoint = { i, j, mark };
+        currentNode.classList.add('focus');
+        return;
+      }
+      // 如果当前连线点存在,清除所有连线点的选中状态
+      Array.from(this.$refs.list.getElementsByClassName('item-wrapper')).forEach((item) => {
+        item.classList.remove('focus');
+      });
+      // 如果当前连线点与上一个连线点不在相邻的位置,则设置点击的连接点为当前连线点
+      if (Math.abs(curJ - j) > 1 || mark === curMark || (curJ === j && curI !== i)) {
+        this.curConnectionPoint = { i, j, mark };
+        currentNode.classList.add('focus');
+        return;
+      }
+      this.changeConnectionList(mark, j);
+      // 如果当前连线点与上一个连线点在相邻的位置,则创建连接线
+      this.createLine(mark);
+    },
+
+    /**
+     * 创建连接线
+     * @param {String} mark 选项标识
+     * @param {Boolean} isDrag 是否是拖拽
+     */
+    createLine(mark, isDrag = false) {
+      let { offsetWidth, offsetLeft, offsetTop, offsetHeight } = document.getElementsByClassName(`item-${mark}`)[0];
+      const { curOffsetWidth, curOffsetLeft, curOffsetTop, curMark } = this.computedCurConnectionPoint(isDrag);
+      let top = Math.min(offsetTop + offsetHeight / 2, curOffsetTop + offsetHeight / 2);
+      let left = Math.min(offsetLeft + offsetWidth, curOffsetLeft + curOffsetWidth);
+      let width = Math.abs(
+        offsetLeft > curOffsetLeft
+          ? curOffsetLeft - offsetLeft + offsetWidth
+          : offsetLeft - curOffsetLeft + curOffsetWidth,
+      );
+      let height = Math.abs(offsetTop - curOffsetTop);
+      let size = offsetLeft > curOffsetLeft ? offsetTop > curOffsetTop : offsetTop < curOffsetTop; // 判断是左上还是右下
+      // 创建一个空的SVG元素
+      let svg = document.createElementNS(svgNS, 'svg');
+      svg.setAttribute(
+        'style',
+        `position:absolute; width: 62px; height: ${Math.max(8, height)}px; top: ${top}px; left: ${left}px;`,
+      );
+      svg.classList.add('connection-line', `svg-${mark}-${curMark}`); // 添加类名
+      // 向SVG元素添加 path 元素
+      let path = document.createElementNS(svgNS, 'path');
+      path.setAttribute('d', `M ${size ? 0 : width} 0 L ${size ? width : 0} ${height}`); // 设置路径数据
+      this.setPathAttr(path);
+      svg.appendChild(path);
+      this.$refs.list.appendChild(svg); // 将SVG元素插入到文档中
+
+      // 清除当前连线点
+      this.resetCurConnectionPoint();
+    },
+    // 设置 path 公用属性
+    setPathAttr(path) {
+      path.setAttribute('stroke-width', '2'); // 设置线条宽度
+      path.setAttribute('stroke-linecap', 'round'); // 设置线段的两端样式
+      path.setAttribute('stroke', '#306eff'); // 设置填充颜色
+      path.setAttribute('transform', `translate(2, 2)`); // 设置偏移量
+    },
+    /**
+     * 计算当前连线点的位置
+     * @param {Boolean} isDrag 是否是拖拽
+     */
+    computedCurConnectionPoint(isDrag = false) {
+      const { mark } = isDrag ? this.mousePointer : this.curConnectionPoint;
+      let dom = document.getElementsByClassName(`item-${mark}`)[0];
+      return {
+        curOffsetWidth: dom.offsetWidth,
+        curOffsetLeft: dom.offsetLeft,
+        curOffsetTop: dom.offsetTop,
+        curOffsetHeight: dom.offsetHeight / 2,
+        curMark: mark,
+      };
+    },
+
+    // 清除所有连接线
+    clearLine() {
+      document.querySelectorAll('svg.connection-line').forEach((item) => {
+        item.remove();
+      });
+    },
+
+    /**
+     * 修改连接列表
+     * @param {String} mark 选项标识
+     * @param {Boolean} isDrag 是否是拖拽
+     */
+    changeConnectionList(mark, j, isDrag = false) {
+      const { mark: curMark, j: curJ } = isDrag ? this.mousePointer : this.curConnectionPoint;
+      this.changeAnswerList(curMark, mark, curJ < j);
+      this.changeAnswerList(mark, curMark, curJ > j);
+    },
+    /**
+     * 改变答案列表
+     * @param {String} curMark 当前选项标识
+     * @param {String} mark 选项标识
+     */
+    changeAnswerList(curMark, mark, isPre) {
+      let oldPointer = { mark: '', position: '' };
+      // 找到当前选项,修改 preMark 或 nextMark
+      this.answerList.find((item) =>
+        item.find((li) => {
+          if (li.mark === curMark) {
+            if (isPre) {
+              if (li.nextMark) {
+                oldPointer = { mark: li.nextMark, position: 'next' };
+              }
+              li.nextMark = mark;
+            } else {
+              if (li.preMark) {
+                oldPointer = { mark: li.preMark, position: 'pre' };
+              }
+              li.preMark = mark;
+            }
+            return true;
+          }
+        }),
+      );
+      // 如果当前选项有 preMark 或 nextMark,则清除原来的连线
+      if (!oldPointer.mark) return;
+      document.querySelector(`svg.connection-line.svg-${curMark}-${oldPointer.mark}`)?.remove();
+      document.querySelector(`svg.connection-line.svg-${oldPointer.mark}-${curMark}`)?.remove();
+
+      // 找到原来的选项,清除 preMark 或 nextMark
+      this.answerList.find((item) =>
+        item.find((li) => {
+          if (li.mark === oldPointer.mark) {
+            if (oldPointer.position === 'pre') {
+              li.nextMark = '';
+            } else {
+              li.preMark = '';
+            }
+            return true;
+          }
+        }),
+      );
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.matching-preview {
+  @include preview-base;
+
+  .option-list {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    row-gap: 16px;
+
+    .list-item {
+      display: flex;
+      column-gap: 8px;
+      align-items: center;
+
+      .item-wrapper {
+        position: relative;
+        display: flex;
+        flex: 1;
+        column-gap: 24px;
+        align-items: center;
+        min-height: 48px;
+        padding: 12px 24px;
+        background-color: $content-color;
+        border-radius: 40px;
+
+        &:not(:last-child) {
+          margin-right: 52px;
+        }
+
+        &.focus {
+          background-color: #dcdbdd;
+        }
+
+        &.right {
+          background-color: $right-bc-color;
+        }
+
+        &.wrong {
+          box-shadow: 0 0 0 1px $error-color;
+        }
+
+        .content {
+          flex: 1;
+        }
+      }
+    }
+  }
+}
+</style>

+ 1 - 1
src/views/book/setting.vue

@@ -225,7 +225,7 @@ export default {
   },
   methods: {
     edit() {
-      this.$router.push({ path: '/book/create', query: { id: this.book_id } });
+      this.$router.push({ path: '/book/create', query: { book_id: this.book_id } });
     },
     enterPreview() {
       this.$router.push(`/preview/courseware/${this.book_id}`);