Explorar o código

feat: 视频节点编辑组件、视频预览组件

dx hai 11 meses
pai
achega
5e7aca1fa0

+ 97 - 0
src/components/InteractiveVideoEditor/VideoLineEditor/hooks/useJumpEditorDrawer.js

@@ -0,0 +1,97 @@
+import { ref, watch } from 'vue'
+
+const useJumpEditorDrawer = (curNodeRef, graphRef) => {
+  // 跳转模块得抽屉状态
+  const jumpDrawerRef = ref(false)
+  // 跳转模块得表单数据
+  const jumpFormRef = ref({
+    jumpNodeId: '',
+    jumpNodeName: ''
+  })
+  // 表单规则
+  const jumpFormRuleRef = ref({
+    jumpNodeId: [{ required: true, message: '请选择跳转模块', trigger: 'change' }]
+  })
+
+  // 可以跳转得节点列表
+  const canJumpNodeListRef = ref([])
+  // 更新可以跳转节点列表
+  const updataCanJumpNodeList = () => {
+    const allNode = graphRef.value.getNodes()
+    const curNode = curNodeRef.value
+    const curModel = curNode.getModel()
+    // 过滤出非自己本身和直接上级节点的所有节点
+    const result = []
+    for (let i = 0; i < allNode.length; i++) {
+      const _node = allNode[i]
+      const _model = _node.getModel()
+      if (_model.type === 'jump-node' || _model.id === curModel.preNodeId) continue
+      result.push({
+        jumpNodeId: _model.id,
+        jumpNodeName: _model.data?.name || ''
+      })
+    }
+    canJumpNodeListRef.value = result
+  }
+  // 选择跳转节点之后赋值跳转节点名称
+  const handleChangeJumpTarget = (jumpNodeId) => {
+    const target = canJumpNodeListRef.value.find((item) => item.jumpNodeId === jumpNodeId)
+    jumpFormRef.value.jumpNodeName = target.jumpNodeName
+  }
+  // 关闭抽屉
+  const handleJumpCancel = () => {
+    ElMessageBox.confirm('是否确认取消更改?')
+      .then(() => {
+        jumpDrawerRef.value = false
+        curNodeRef.value = null
+      })
+      .catch(() => {
+        // catch error
+      })
+  }
+  // 保存
+  const handleJumpSave = () => {
+    if (!graphRef.value) return console.warn('画布不存在')
+    const config = {
+      data: {
+        jumpNodeId: jumpFormRef.value.jumpNodeId,
+        jumpNodeName: jumpFormRef.value.jumpNodeName
+      }
+    }
+    // 更新当前节点
+    graphRef.value.updateItem(curNodeRef.value, config)
+    jumpDrawerRef.value = false
+    curNodeRef.value = null
+  }
+
+  // 根据传入的节点初始化内容
+  const init = (node) => {
+    const model = node.getModel()
+    const data = model.data || {}
+    jumpFormRef.value = {
+      jumpNodeId: data.jumpNodeId,
+      jumpNodeName: data.jumpNodeName
+    }
+  }
+
+  watch(
+    () => curNodeRef.value,
+    (node) => {
+      if (!node) return
+      init(node)
+    }
+  )
+
+  return {
+    jumpDrawerRef,
+    jumpFormRef,
+    jumpFormRuleRef,
+    canJumpNodeListRef,
+    updataCanJumpNodeList,
+    handleChangeJumpTarget,
+    handleJumpCancel,
+    handleJumpSave
+  }
+}
+
+export default useJumpEditorDrawer

+ 27 - 0
src/components/InteractiveVideoEditor/VideoLineEditor/hooks/useModuleForm.js

@@ -0,0 +1,27 @@
+import { ref } from "vue";
+const useModuleForm = () => {
+  const moduleFormRef = ref({
+    plotName: "",
+    plotVideoSource: "",
+    plotImageSource: "",
+  });
+
+  const moduleFormRuleRef = ref({
+    plotName: [
+      { required: true, message: "请填写剧情模块名", trigger: "blur" },
+    ],
+    plotVideoSource: [
+      { required: true, message: "请选择剧情视频素材", trigger: "change" },
+    ],
+    plotImageSource: [
+      { required: true, message: "请选择剧情封面素材", trigger: "change" },
+    ],
+  });
+
+  return {
+    moduleFormRef,
+    moduleFormRuleRef,
+  };
+};
+
+export default useModuleForm;

+ 198 - 0
src/components/InteractiveVideoEditor/VideoLineEditor/hooks/usePoltEditorDrawer.js

@@ -0,0 +1,198 @@
+import { ref, watch } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
+const usePoltEditorDrawer = (curNodeRef, graphRef, moduleFormRef) => {
+  // 抽屉状态
+  const poltDrawerRef = ref(false);
+  // 当前场景数据
+  const poltDataRef = ref({
+    // 视频素材id
+    videoId: "",
+    // 图片素材id
+    imageId: "",
+    // 视频素材地址
+    videoUrl: "",
+    // 图片素材地址
+    imageUrl: "",
+  });
+  // 问题内容
+  const questionRef = ref("");
+  // 当前选项数据
+  const optionsRef = ref([]);
+  // 切换剧情视频素材
+  const handleChangePlotVideoSource = (sourceId, sourcesList) => {
+    const target = sourcesList.find((item) => item.id === sourceId) || {};
+    poltDataRef.value.videoId = target.id;
+    poltDataRef.value.videoUrl = target.url;
+  };
+  // 切换剧情图片素材
+  const handleChangePlotImageSource = (sourceId, sourcesList) => {
+    const target = sourcesList.find((item) => item.id === sourceId) || {};
+    poltDataRef.value.imageId = target.id;
+    poltDataRef.value.imageUrl = target.url;
+  };
+  // 添加分支
+  const handleAddOptions = () => {
+    if (optionsRef.value.length >= 4)
+      return ElMessage.warning("最多可添加四个分支选项");
+    optionsRef.value.push({
+      nodeId: null,
+      optionType: String.fromCharCode(65 + optionsRef.value.length),
+      optionName: "",
+    });
+  };
+  // 每次输入变化之后提交,是否有相同选项
+  const handleCheckSameName = (val, index) => {
+    for (let i = 0; i < optionsRef.value.length; i++) {
+      if (i === index) continue;
+      const option = optionsRef.value[i];
+      if (option.optionName === val)
+        return !!ElMessage.warning("分支名称不可以有相同的");
+    }
+    return false;
+  };
+
+  // 更新对应节点的选项内容
+  const updateOptionNode = (graph, options) => {
+    if (!graph || !options || options.length <= 0) return;
+    for (let i = 0; i < options.length; i++) {
+      const option = options[i] || {};
+      const targetNode = graph.findById(option.nodeId);
+      const targetModel = targetNode.getModel();
+      // 都相同表示没有变化,不需要更新
+      if (
+        option.optionType === targetModel.data.optionType &&
+        option.optionName === targetModel.data.optionName
+      )
+        continue;
+      // 更新当前节点
+      graph.updateItem(
+        targetNode,
+        {
+          data: {
+            optionType: option.optionType,
+            optionName: option.optionName,
+          },
+        },
+        false
+      );
+    }
+  };
+
+  // 查找存在关联关系的跳转模块,并更新对应文本内容
+  const findLinkNodeAndUpdate = (graph, node) => {
+    // 获取所有的跳转节点
+    const allJumpNodes = graph.findAll("node", (_node) => {
+      return _node.get("model").type === "jump-node";
+    });
+    const curNodeModel = node.getModel();
+    // 看一下是否有跳转节点的id是该节点
+    allJumpNodes.forEach((jumpNode) => {
+      const _model = jumpNode.getModel();
+      const data = _model.data || {};
+      if (data.jumpNodeId === curNodeModel.id) {
+        graph.updateItem(
+          jumpNode,
+          {
+            data: {
+              jumpNodeName: curNodeModel.data.name,
+            },
+          },
+          false
+        );
+      }
+    });
+  };
+
+  // 保存
+  const handleSave = () => {
+    console.log("保存");
+    if (!graphRef.value) return console.warn("画布不存在");
+    const config = {
+      data: {
+        name: moduleFormRef.value.plotName,
+        image: poltDataRef.value.imageUrl,
+        uri: poltDataRef.value.videoUrl,
+        videoId: poltDataRef.value.videoId,
+        imageId: poltDataRef.value.imageId,
+        question: questionRef.value,
+        optionsList: optionsRef.value,
+      },
+    };
+    // 更新当前节点
+    graphRef.value.updateItem(curNodeRef.value, config);
+    // 更新子节点
+    updateOptionNode(graphRef.value, optionsRef.value);
+    graphRef.value.layout(false);
+    findLinkNodeAndUpdate(graphRef.value, curNodeRef.value);
+    poltDrawerRef.value = false;
+    curNodeRef.value = null;
+  };
+  // 取消更改
+  const handleCancel = () => {
+    ElMessageBox.confirm("是否确认取消更改?")
+      .then(() => {
+        poltDrawerRef.value = false;
+        curNodeRef.value = null;
+      })
+      .catch(() => {
+        // catch error
+      });
+  };
+
+  // 获取节点下的选项数据
+  const modelOptions = (model) => {
+    const children = model.children || [];
+    const result = [];
+    for (let i = 0; i < children.length; i++) {
+      const subNodeData = children[i].data || {};
+      result.push({
+        nodeId: children[i].id,
+        optionType: subNodeData.optionType || "",
+        optionName: subNodeData.optionName || "",
+      });
+    }
+    return result;
+  };
+
+  // 根据传入的节点初始化内容
+  const init = (node) => {
+    const model = node.getModel();
+    const data = model.data || {};
+    moduleFormRef.value = {
+      plotName: data.name,
+      plotVideoSource: data.videoId,
+      plotImageSource: data.imageId,
+    };
+    poltDataRef.value = {
+      videoId: data.videoId,
+      imageId: data.imageId,
+      videoUrl: data.uri,
+      imageUrl: data.image,
+    };
+    optionsRef.value = modelOptions(model);
+    questionRef.value = data.question || "";
+  };
+
+  watch(
+    () => curNodeRef.value,
+    (node) => {
+      if (!node) return;
+      init(node);
+    }
+  );
+
+  return {
+    poltDrawerRef,
+    poltDataRef,
+    optionsRef,
+    questionRef,
+    handleChangePlotVideoSource,
+    handleChangePlotImageSource,
+    handleAddOptions,
+    handleCheckSameName,
+    handleSave,
+    handleCancel,
+  };
+};
+
+export default usePoltEditorDrawer;

+ 395 - 0
src/components/InteractiveVideoEditor/VideoLineEditor/index.vue

@@ -0,0 +1,395 @@
+<template>
+  <VideoLineGraph
+    ref="G6GraphRef"
+    :graphData="props.graphData"
+    :ifSendMsg="ifSendMsg"
+    :pipData="pipData"
+    @onEdit="handleEdit"
+  />
+  <el-drawer
+    v-model="poltDrawerRef"
+    class="editor-drawer"
+    direction="rtl"
+    :size="380"
+    :show-close="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+  >
+    <template #header="{ titleId }">
+      <h2 :id="titleId" class="drawer-title">剧情模块</h2>
+      <el-button text @click="handleCancel"> 取消更改 </el-button>
+      <el-button type="primary" @click="handleSave"> 保存 </el-button>
+    </template>
+    <div class="plot-module-box">
+      <h3 class="drawer-title">剧情模块设置</h3>
+      <el-form
+        ref="moduleForm"
+        :model="moduleFormRef"
+        :rules="moduleFormRuleRef"
+        label-width="auto"
+        status-icon
+      >
+        <el-form-item
+          label="剧情名称"
+          prop="plotName"
+          placeholder="请输入剧情名称"
+        >
+          <el-input v-model="moduleFormRef.plotName" />
+        </el-form-item>
+        <el-form-item label="剧情视频素材" prop="plotVideoSource">
+          <el-select
+            v-model="moduleFormRef.plotVideoSource"
+            placeholder="请选择剧情视频素材"
+            @change="
+              (sourceId) =>
+                handleChangePlotVideoSource(sourceId, props.videoSources)
+            "
+          >
+            <el-option
+              v-for="item in props.videoSources"
+              :key="item.id"
+              :label="item.name"
+              :value="item.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="剧情封面素材" prop="plotImageSource">
+          <el-select
+            v-model="moduleFormRef.plotImageSource"
+            placeholder="请选择剧情封面素材"
+            @change="
+              (sourceId) =>
+                handleChangePlotImageSource(sourceId, props.imageSources)
+            "
+          >
+            <el-option
+              v-for="item in props.imageSources"
+              :key="item.id"
+              :label="item.name"
+              :value="item.id"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+    </div>
+    <div
+      class="plot-options-box"
+      v-if="poltDataRef.videoId && poltDataRef.imageId"
+    >
+      <div class="plot-options-title">
+        <h3 class="drawer-title">剧情分支设置</h3>
+        <!-- <el-button text size="small" type="primary" @click="handleReview"> 预览 </el-button> -->
+      </div>
+      <div class="view-box">
+        <img style="width: 100%" :src="poltDataRef.imageUrl" alt="" />
+        <div v-if="questionRef" class="quesetion">问:{{ questionRef }}</div>
+        <div
+          class="option-btn-box"
+          :style="{ flexWrap: optionsRef.length >= 4 ? 'wrap' : 'nowrap' }"
+        >
+          <div
+            class="option-btn-item"
+            v-for="(option, index) in optionsRef"
+            :key="index"
+          >
+            <div class="option-btn">
+              {{ option.optionType }}:{{ option.optionName }}
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="question-wrapper">
+        <h4 class="drawer-title">问题内容</h4>
+        <div class="question-content">
+          <el-input
+            v-model="questionRef"
+            rows="3"
+            resize="none"
+            maxlength="50"
+            placeholder="请输入问题内容"
+            show-word-limit
+            clearable
+            type="textarea"
+          />
+        </div>
+      </div>
+      <div class="options-wrapper" v-if="optionsRef.length > 0">
+        <div class="options-title">
+          <h4 class="drawer-title">分支选项</h4>
+          <!-- 这里不能新增,节点类型不知道 -->
+          <!-- <el-button text size="small" type="primary" @click="handleAddOptions">
+            添加分支
+          </el-button> -->
+        </div>
+        <div class="options-content">
+          <div
+            class="options-card"
+            v-for="(item, index) in optionsRef"
+            :key="index"
+          >
+            <div class="option-name">
+              选项{{ item.optionType }} > 剧情模块:{{ item.optionName }}
+            </div>
+            <div class="option-input">
+              <el-input
+                v-model="item.optionName"
+                rows="3"
+                resize="none"
+                maxlength="16"
+                placeholder="请输入选项名称"
+                show-word-limit
+                clearable
+                type="textarea"
+                @change="(value) => handleCheckSameName(value, index)"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </el-drawer>
+
+  <el-drawer
+    v-model="jumpDrawerRef"
+    class="editor-drawer"
+    direction="rtl"
+    :size="380"
+    :show-close="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+  >
+    <template #header="{ titleId }">
+      <h2 :id="titleId" class="drawer-title">跳转模块</h2>
+      <el-button text @click="handleJumpCancel"> 取消更改 </el-button>
+      <el-button type="primary" @click="handleJumpSave"> 保存 </el-button>
+    </template>
+    <div class="jump-module-box">
+      <h3 class="drawer-title">跳转模块设置</h3>
+      <el-form
+        ref="jumpForm"
+        :model="jumpFormRef"
+        :rules="jumpFormRuleRef"
+        label-width="auto"
+        status-icon
+      >
+        <el-form-item label="剧情素材" prop="jumpNodeId">
+          <el-select
+            v-model="jumpFormRef.jumpNodeId"
+            placeholder="请选择剧情素材"
+            @change="handleChangeJumpTarget"
+          >
+            <el-option
+              v-for="item in canJumpNodeListRef"
+              :key="item.jumpNodeId"
+              :label="item.jumpNodeName"
+              :value="item.jumpNodeId"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+    </div>
+  </el-drawer>
+</template>
+
+<script>
+export default {
+  name: "VideoLineEditor",
+};
+</script>
+
+<script setup>
+import { ref } from "vue";
+import VideoLineGraph from "../VideoLineGraph/index.vue";
+import usePoltEditorDrawer from "./hooks/usePoltEditorDrawer.js";
+import useModuleForm from "./hooks/useModuleForm.js";
+import useJumpEditorDrawer from "./hooks/useJumpEditorDrawer.js";
+const props = defineProps({
+  // 初始化图表数据
+  graphData: {
+    type: Object,
+    default: () => ({}),
+  },
+  // 视频资源列表
+  videoSources: {
+    type: Array,
+    default: () => [],
+  },
+  // 图片资源列表
+  imageSources: {
+    type: Array,
+    default: () => [],
+  },
+  // 是否需要对外发送信息
+  ifSendMsg: {
+    type: Boolean,
+    default: false,
+  },
+  // 外部调用者传递的数据, ifSendMsg 为 false时,说明没有外部调用,此参数就无意义
+  pipData: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+// 当前选中的节点
+const curNodeRef = ref(null);
+// 画布
+const graphRef = ref(null);
+// 编辑器组件实例
+const G6GraphRef = ref(null);
+
+const { moduleFormRef, moduleFormRuleRef } = useModuleForm();
+
+const {
+  poltDrawerRef,
+  poltDataRef,
+  optionsRef,
+  questionRef,
+  handleChangePlotVideoSource,
+  handleChangePlotImageSource,
+  // handleAddOptions,
+  handleCheckSameName,
+  handleSave,
+  handleCancel,
+} = usePoltEditorDrawer(curNodeRef, graphRef, moduleFormRef, props);
+
+const {
+  jumpDrawerRef,
+  jumpFormRef,
+  jumpFormRuleRef,
+  canJumpNodeListRef,
+  updataCanJumpNodeList,
+  handleChangeJumpTarget,
+  handleJumpCancel,
+  handleJumpSave,
+} = useJumpEditorDrawer(curNodeRef, graphRef);
+
+// 触发编辑,打开弹窗
+const handleEdit = (node, graph) => {
+  graphRef.value = graph;
+  curNodeRef.value = node;
+  const model = node.getModel();
+  switch (model.type) {
+    case "root-node":
+    case "plot-node":
+      poltDrawerRef.value = true;
+      break;
+    case "jump-node":
+      updataCanJumpNodeList();
+      jumpDrawerRef.value = true;
+      break;
+    default:
+      break;
+  }
+};
+
+const getGraphData = () => {
+  return G6GraphRef.value.getGraphData();
+};
+
+defineExpose({
+  getGraphData,
+});
+</script>
+
+<style lang="scss" scoped>
+.drawer-title {
+  line-height: 25px;
+  color: #212121;
+  font-weight: bold;
+}
+.plot-module-box,
+.plot-options-box,
+.jump-module-box {
+  width: 100%;
+  padding: 0 16px;
+  border-bottom: 1px solid #e7e7e7;
+  padding-bottom: 12px;
+  h3 {
+    padding: 12px 0;
+  }
+}
+.plot-options-box {
+  .plot-options-title {
+    width: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+  }
+  .view-box {
+    width: 100%;
+    position: relative;
+    .quesetion {
+      width: 100%;
+      position: absolute;
+      padding: 8px 16px;
+      top: 0;
+      left: 0;
+      background-color: rgba($color: #000000, $alpha: 0.6);
+      color: #fff;
+      font-size: 14px;
+      font-weight: 600;
+    }
+    .option-btn-box {
+      width: 100%;
+      position: absolute;
+      bottom: 10px;
+      left: 0;
+      display: flex;
+      .option-btn-item {
+        width: 50%;
+        padding: 2px;
+        .option-btn {
+          border: 1px solid #000;
+          background-color: rgba($color: #ffffff, $alpha: 0.6);
+          color: #000000;
+          font-size: 8px;
+          width: 100%;
+          height: 28px;
+          display: flex;
+          align-items: center;
+          justify-content: flex-start;
+          padding: 0 2px;
+          user-select: none;
+        }
+      }
+    }
+  }
+  .question-wrapper {
+    width: 100%;
+    padding-top: 12px;
+    .question-content {
+      background-color: #f5f6f7;
+      padding: 12px 10px;
+      margin-top: 8px;
+    }
+  }
+  .options-wrapper {
+    width: 100%;
+    padding: 12px 0;
+    .options-title {
+      widows: 100%;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+    .options-content {
+      width: 100%;
+      .options-card {
+        width: 100%;
+        background-color: #f5f6f7;
+        padding: 12px 10px;
+        margin-top: 8px;
+        .option-name {
+          width: 100%;
+          font-size: 13px;
+        }
+        .option-input {
+          width: 100%;
+          padding-top: 12px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 69 - 0
src/components/InteractiveVideoEditor/VideoLineGraph/hooks/useGraphHandle.js

@@ -0,0 +1,69 @@
+import { ref } from 'vue'
+// 注册事件并绑定
+const useGraphHandle = (emits) => {
+  const handleAutoResize = (graph, containerDom) => {
+    if (typeof window !== 'undefined')
+      window.onresize = () => {
+        console.log('onresize')
+        if (!graph || graph.get('destroyed')) return
+        if (!containerDom || !containerDom.scrollWidth || !containerDom.scrollHeight) return
+        graph.changeSize(containerDom.scrollWidth, containerDom.scrollHeight)
+      }
+  }
+
+  const selectedNode = ref(null)
+
+  // 处理选中节点
+  const handleSelectedNode = (targetNode, graph) => {
+    // 如果已经有选中的节点,则取消选中状态
+    if (selectedNode.value && selectedNode.value !== targetNode) {
+      graph.updateItem(
+        selectedNode.value,
+        {
+          selected: false
+        },
+        false
+      )
+    }
+    // 更新状态
+    graph.updateItem(
+      targetNode,
+      {
+        selected: true
+      },
+      false
+    )
+    // 记录选中的节点
+    selectedNode.value = targetNode
+  }
+
+  // 主内容区域点击事件
+  const handleContentNodeClick = (graph) => {
+    graph.on('contentNode:click', (evt) => {
+      handleSelectedNode(evt.item, graph)
+      emits('onEdit', evt.item, graph)
+    })
+  }
+
+  // 提示区域(选项区)点击事件
+  const handleTipsNodeClick = (graph) => {
+    graph.on('tipsNode:click', (evt) => {
+      const pNode = evt.item.get('parent')
+      handleSelectedNode(pNode, graph)
+      emits('onEdit', pNode, graph)
+    })
+  }
+
+  // 初始化绑定事件
+  const initHandle = (graph, containerDom) => {
+    handleAutoResize(graph, containerDom)
+    handleContentNodeClick(graph)
+    handleTipsNodeClick(graph)
+  }
+
+  return {
+    initHandle
+  }
+}
+
+export default useGraphHandle

+ 207 - 0
src/components/InteractiveVideoEditor/VideoLineGraph/hooks/useGraphPlugins.js

@@ -0,0 +1,207 @@
+import G6 from "@antv/g6";
+
+// 最多几个分支选项
+const maxOptionsNums = 4;
+
+// 根据不同节点的不同状态展示对应的下拉菜单
+const createMenuListByNode = {
+  // 根节点
+  "root-node": (node, model) => {
+    const data = model.data || {};
+    // 判断是否已经填写
+    const hasData = !!data.name;
+    const children = model.children || [];
+
+    return `<ul class="menu-list">
+        ${
+          children.length >= maxOptionsNums
+            ? ``
+            : `<li class="menu-item" data-type="create-plot">创建剧情模块</li> <li class="menu-item" data-type="create-jump">创建跳转模块</li>`
+        }
+        ${
+          children.length < maxOptionsNums && hasData
+            ? `<li class="menu-item-split"></li>`
+            : ""
+        }
+        ${
+          hasData ? `<li class="menu-item" data-type="clean">清空模块</li>` : ""
+        }
+      </ul>`;
+  },
+  // 剧情模块
+  "plot-node": (node, model) => {
+    const data = model.data || {};
+    // 判断是否已经填写
+    const hasData = !!data.name;
+    const children = model.children || [];
+
+    return `<ul class="menu-list">
+        ${
+          children.length >= maxOptionsNums
+            ? ``
+            : `<li class="menu-item" data-type="create-plot">创建剧情模块</li> <li class="menu-item" data-type="create-jump">创建跳转模块</li><li class="menu-item-split"></li>`
+        }
+        ${
+          hasData ? `<li class="menu-item" data-type="clean">清空模块</li>` : ""
+        }
+        <li class="menu-item" data-type="delete">删除模块</li>
+      </ul>`;
+  },
+  // 跳转模块
+  "jump-node": () => {
+    return `<ul class="menu-list">
+        <li class="menu-item" data-type="delete">删除模块</li>
+      </ul>`;
+  },
+};
+
+// 剧情节点data的默认值
+const plotNodeDefaultData = {
+  name: "",
+  image: "",
+  url: "",
+  materialId: "",
+  question: "",
+};
+
+// 跳转节点data的默认值
+const jumpNodeDefaultData = {
+  jumpNodeName: "",
+  jumpNodeId: "",
+};
+
+// 不同点击类型的功能处理
+const menuClickFuncMap = {
+  // 创建剧情模块
+  "create-plot": (node, graph) => {
+    const model = node.getModel();
+    const children = model.children || [];
+    // 当前选项的字母
+    const newOptionType = String.fromCharCode(65 + children.length);
+    // 新节点的 id
+    const newNodeId = new Date().getTime() + "";
+    graph.addChild(
+      {
+        id: newNodeId,
+        type: "plot-node",
+        preNodeId: model.id,
+        data: {
+          ...plotNodeDefaultData,
+          optionType: newOptionType,
+          optionName: "",
+        },
+      },
+      model.id
+    );
+    graph.fitCenter();
+  },
+  // 创建跳转模块
+  "create-jump": (node, graph) => {
+    const model = node.getModel();
+    const children = model.children || [];
+    // 当前选项的字母
+    const newOptionType = String.fromCharCode(65 + children.length);
+    // 新节点的 id
+    const newNodeId = new Date().getTime() + "";
+    graph.addChild(
+      {
+        id: newNodeId,
+        type: "jump-node",
+        preNodeId: model.id,
+        data: {
+          ...jumpNodeDefaultData,
+          optionType: newOptionType,
+          optionName: "",
+        },
+      },
+      model.id
+    );
+    graph.fitCenter();
+  },
+  // 清空模块数据
+  clean: (node, graph) => {
+    const defaultData =
+      node.getModel().type === "plot-node"
+        ? plotNodeDefaultData
+        : jumpNodeDefaultData;
+    graph.updateItem(node, {
+      data: defaultData,
+    });
+    graph.layout(false);
+  },
+  // 删除模块
+  delete: (node, graph) => {
+    const model = node.getModel();
+    // 获取父节点
+    const pNode = node.get("parent");
+    if (!pNode) return;
+    const pModel = pNode.getModel();
+
+    let _tempNum = 0;
+    const _children = [];
+    // 全量更新子节点数据
+    pModel.children.forEach((item) => {
+      // 不是被删除节点,需要添加进入新的数组
+      if (item.id !== model.id) {
+        _children.push({
+          ...item,
+          data: {
+            ...item.data,
+            optionType: String.fromCharCode(65 + _tempNum),
+          },
+        });
+        _tempNum += 1;
+      }
+    });
+    graph.updateChildren(_children, pModel.id);
+  },
+};
+
+// 菜单配置
+const menuConfig = {
+  offsetX: 6,
+  offsetY: 10,
+  itemTypes: ["node"],
+  // 获取菜单内容
+  getContent(e) {
+    // console.log('getContent', e, e.item.getModel())
+    const node = e.item;
+    const model = node.getModel();
+    // const graph = e.currentTarget
+    const outDiv = document.createElement("div");
+    outDiv.style.width = "180px";
+    outDiv.style.fontSize = "14px";
+    outDiv.style.lineHeight = "20px";
+    outDiv.innerHTML = createMenuListByNode[model.type]
+      ? createMenuListByNode[model.type](node, model)
+      : "";
+    return outDiv;
+  },
+  // 控制菜单是否出现
+  shouldBegin(e) {
+    const actionType = e.target.get("action");
+    // console.log('shouldBegin', e, e.target.get('action'))
+    if (actionType === "tips") return false;
+    return true;
+  },
+  handleMenuClick(target, item, graph) {
+    const type = target.dataset.type;
+    if (!type || !menuClickFuncMap[type]) return;
+    menuClickFuncMap[type](item, graph);
+  },
+};
+
+// 注册图表插件
+const useGraphPlugins = () => {
+  const toolbar = new G6.ToolBar();
+  const grid = new G6.Grid();
+  const menu = new G6.Menu(menuConfig);
+
+  return {
+    toolbar,
+    grid,
+    menu,
+  };
+};
+
+export default useGraphPlugins;

+ 76 - 0
src/components/InteractiveVideoEditor/VideoLineGraph/hooks/useGrapheBehavior.js

@@ -0,0 +1,76 @@
+// 注册交互
+const useGrapheBehavior = (G6) => {
+  const behaviorList = ['drag-canvas', 'zoom-canvas']
+  // 配置点击选择
+  // const clickSelect = {
+  //   type: 'click-select',
+  //   multiple: false,
+  //   selectNode: true,
+  //   selectEdge: false,
+  //   selectCombo: false
+  // }
+  // behaviorList.push(clickSelect)
+
+  // 自定义交互 - 鼠标悬浮提示节点
+  G6.registerBehavior('tips-hover', {
+    getEvents() {
+      return {
+        'tipsNode:mouseover': 'hoverNode',
+        'tipsNode:mouseout': 'hoverNodeOut'
+      }
+    },
+    hoverNode(evt) {
+      evt.currentTarget.updateItem(
+        evt.item,
+        {
+          tipsHover: true
+        },
+        false
+      )
+    },
+    hoverNodeOut(evt) {
+      evt.currentTarget.updateItem(
+        evt.item,
+        {
+          tipsHover: false
+        },
+        false
+      )
+    }
+  })
+  // 自定义交互 - 鼠标悬浮内容节点
+  G6.registerBehavior('content-hover', {
+    getEvents() {
+      return {
+        'contentNode:mouseover': 'hoverNode',
+        'contentNode:mouseout': 'hoverNodeOut'
+      }
+    },
+    hoverNode(evt) {
+      evt.currentTarget.updateItem(
+        evt.item,
+        {
+          contentHover: true
+        },
+        false
+      )
+    },
+    hoverNodeOut(evt) {
+      evt.currentTarget.updateItem(
+        evt.item,
+        {
+          contentHover: false
+        },
+        false
+      )
+    }
+  })
+
+  behaviorList.push('tips-hover', 'content-hover')
+
+  return {
+    behaviorList
+  }
+}
+
+export default useGrapheBehavior

+ 120 - 0
src/components/InteractiveVideoEditor/VideoLineGraph/hooks/useGrapheRegisterNode.js

@@ -0,0 +1,120 @@
+// 设置自定义节点
+const useGrapheRegisterNode = (G6) => {
+  const { Util } = G6
+
+  G6.registerNode('root-node', {
+    jsx: (cfg) => {
+      // console.log('root-node', cfg)
+      const stroke = cfg.style.stroke || '#409eff'
+      const data = cfg.data || {}
+      // 内容区域的hover状态
+      const contentHover = cfg.contentHover
+      // 选中状态
+      const selected = cfg.selected
+      // 内容区域
+      const innerContent = data.name
+        ? `
+      <image name="contentNode" style={{ img: '${data.image}',marginTop:1, marginLeft:4, width: 60, height: 58,preserveAspectRatio:none  meet, cursor: pointer}} />
+      <text name="contentNode" style={{ fontSize: 12, marginLeft: 66, marginTop: -36, cursor: pointer }}>${data.name}</text>
+      `
+        : `
+      <text name="contentNode" style={{ fontSize: 12, marginLeft: 4, marginTop: 22, fill: '#4fa20a', cursor: pointer }}>请编辑剧情模块</text>
+      `
+      return `
+        <group>
+          <rect name="contentNode" action="main" draggable="true" style={{width: 180, height: 60,lineWidth:2, stroke: ${contentHover || selected ? stroke : '#e8e8e8'},fill: #fff, radius: 4, cursor: pointer, shadowColor: rgba(0,0,0,0.16), shadowBlur: 8 }} keyshape>
+            ${innerContent}
+          </rect>
+        </group>
+      `
+    },
+    getAnchorPoints() {
+      return [
+        [0, 0.5],
+        [1, 0.5]
+      ]
+    }
+  })
+
+  G6.registerNode('plot-node', {
+    jsx: (cfg) => {
+      // console.log('plot-node', cfg)
+      const stroke = cfg.style.stroke || '#409eff'
+      // 节点上存的属性
+      const data = cfg.data || {}
+      // 计算顶部描述(选项)的宽度
+      const tipsNodeWidth = Util.getTextSize(data.optionName || '点此编辑选项', 12)[0] + 48
+      // 描述的hover状态
+      const tipsHover = cfg.tipsHover
+      // 内容区域的hover状态
+      const contentHover = cfg.contentHover
+      // 选中状态
+      const selected = cfg.selected
+      // 选项的文字颜色
+      const optionColor = data.optionName ? '#999' : '#4fa20a'
+      // 选项区域的内容
+      const optionContent = `<text name="tipsNode" action="tips" style={{ fontSize: 12, marginLeft: 6, marginTop:4, fill: ${optionColor}, cursor: pointer }}>${data.optionType}:${data.optionName || '点此编辑选项'}</text>`
+      // 主内容区域
+      const innerContent = data.name
+        ? `
+      <image name="contentNode" style={{ img: '${data.image}',marginTop:1, marginLeft:4, width: 60, height: 58,preserveAspectRatio:none  meet, cursor: pointer}} />
+      <text name="contentNode" style={{ fontSize: 14, marginLeft: 70, marginTop: -35, cursor: pointer }}>${data.name}</text>
+      `
+        : `
+      <text name="contentNode" style={{ fontSize: 14, marginLeft: 4, marginTop: 23, fill: '#4fa20a', cursor: pointer }}>点击编辑剧情模块</text>
+      `
+      return `
+        <group>
+          <rect draggable="true" style={{width: ${tipsNodeWidth}, height: 24, marginTop:-4, fill: ${tipsHover ? '#e5e5e5' : 'transparent'}, radius: 4, cursor: pointer }} keyshape>
+            ${optionContent}
+          </rect>
+          <rect name="contentNode" action="main" draggable="true" style={{width: 180, height: 60,lineWidth:2, stroke: ${contentHover || selected ? stroke : '#e8e8e8'},fill: #fff, radius: 4, cursor: pointer, shadowColor: rgba(0,0,0,0.16), shadowBlur: 8 }} keyshape>
+            ${innerContent}
+          </rect>
+        </group>
+      `
+    },
+    getAnchorPoints() {
+      return [
+        [0, 0.6],
+        [1, 0.6]
+      ]
+    }
+  })
+  G6.registerNode('jump-node', {
+    jsx: (cfg) => {
+      // console.log('plot-node', cfg)
+      const data = cfg.data || {}
+      // 计算顶部描述(选项)的宽度
+      const tipsNodeWidth = Util.getTextSize(data.optionName || '点此编辑选项', 12)[0] + 48
+      // 描述的hover状态
+      const tipsHover = cfg.tipsHover
+      const fill = data.jumpNodeId ? '#999' : '#409eff'
+      const label = data.jumpNodeName ? '跳至:' + data.jumpNodeName : '选择跳转目标'
+      const nodeWidth = Util.getTextSize(label, 14)[0] + 24
+      const optionColor = data.optionName ? '#999' : '#4fa20a'
+      const optionContent = `<text name="tipsNode" action="tips" style={{ fontSize: 12, marginLeft: 6, marginTop:4, fill: ${optionColor}, cursor: pointer }}>${data.optionType}:${data.optionName || '点此编辑选项'}</text>`
+      const innerContent = `
+      <text name="contentNode" style={{ fontSize: 14, marginLeft: 6, marginTop: 6, fill: '#fff', cursor: pointer }}>${label}</text>
+      `
+      return `
+        <group>
+          <rect draggable="true" style={{width: ${tipsNodeWidth}, height: 24, marginTop:-4, fill: ${tipsHover ? '#e5e5e5' : 'transparent'}, radius: 4, cursor: pointer }} keyshape>
+            ${optionContent}
+          </rect>
+          <rect name="contentNode" draggable="true" action="main" style={{width: ${nodeWidth}, height: 28, fill: ${fill}, radius: 4, cursor: pointer }} keyshape>
+          ${innerContent}
+          </rect>
+        </group>
+      `
+    },
+    getAnchorPoints() {
+      return [
+        [0, 0.7],
+        [1, 0.7]
+      ]
+    }
+  })
+}
+
+export default useGrapheRegisterNode

+ 246 - 0
src/components/InteractiveVideoEditor/VideoLineGraph/index.vue

@@ -0,0 +1,246 @@
+<template>
+  <div id="container"></div>
+</template>
+
+<script>
+export default {
+  name: 'VideoLineGraph'
+}
+</script>
+
+<script setup>
+import { ref, watch, onMounted } from 'vue'
+import G6 from '@antv/g6'
+import useGraphHandle from './hooks/useGraphHandle.js'
+import useGraphPlugins from './hooks/useGraphPlugins.js'
+import useGrapheBehavior from './hooks/useGrapheBehavior.js'
+import useGrapheRegisterNode from './hooks/useGrapheRegisterNode.js'
+import { postMessage } from '../utils/iframePip.js'
+
+const { Util } = G6
+
+const props = defineProps({
+  // 初始化图表数据
+  graphData: {
+    type: Object,
+    default: () => ({})
+  },
+  // 是否需要对外发送信息
+  ifSendMsg: {
+    type: Boolean,
+    default: false
+  },
+  // 外部调用者传递的数据, ifSendMsg 为 false时,说明没有外部调用,此参数就无意义
+  pipData: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+// 流程图对外发送信息
+const graphPostMessage = (data) => {
+  props.ifSendMsg && postMessage(data)
+}
+
+const emits = defineEmits('onEdit', 'onSave')
+
+const _graphData = ref({})
+
+watch(
+  () => props.graphData,
+  (val) => {
+    // 外部想要切换流程图数据
+    _graphData.value = val
+    try {
+      graph.changeData(val, true)
+      graph.fitCenter()
+      graphPostMessage({
+        msgType: 'ok',
+        content: {
+          callBackType: props.pipData.msgType,
+          msg: '操作成功'
+        }
+      })
+    } catch (error) {
+      graphPostMessage({
+        msgType: 'fail',
+        content: {
+          callBackType: props.pipData.msgType,
+          msg: `操作失败:${error}`
+        }
+      })
+    }
+  }
+)
+
+watch(
+  () => props.pipData,
+  (val) => {
+    // 外部想要获取流程图数据
+    if (val.msgType === 'graph_get_data') {
+      try {
+        graphPostMessage({
+          msgType: 'graph_get_data',
+          content: JSON.parse(JSON.stringify(graph.save()))
+        })
+        graphPostMessage({
+          msgType: 'ok',
+          content: {
+            callBackType: 'graph_get_data',
+            msg: '操作成功'
+          }
+        })
+      } catch (error) {
+        graphPostMessage({
+          msgType: 'fail',
+          content: {
+            callBackType: props.pipData.msgType,
+            msg: `操作失败:${error}`
+          }
+        })
+      }
+    }
+  }
+)
+
+let graph = null
+// 注册自定义节点
+useGrapheRegisterNode(G6)
+// 注册自定义事件
+const { initHandle } = useGraphHandle(emits)
+// 注册流程图插件
+const {
+  // toolbar,
+  grid,
+  menu
+} = useGraphPlugins()
+// 注册自定义交互
+const { behaviorList } = useGrapheBehavior(G6)
+
+// 初始化流程图
+const initGraph = () => {
+  const container = document.getElementById('container')
+  const width = container.scrollWidth
+  const height = (container.scrollHeight || 500) - 20
+  graph = new G6.TreeGraph({
+    container: 'container', //容器 id
+    width,
+    height,
+    enabledStack: false, // 是否启用历史记录,这里先不用,记录的状态得手动设定,这里用自带得不太好用
+    fitCenter: true, // 初始化在中心展示
+    // renderer: 'svg',     // 渲染图形得类型
+    animate: true, // 是否用动画
+    animateCfg: {
+      //动画配置
+      duration: 300
+    },
+    modes: {
+      //交互
+      default: behaviorList
+    },
+    plugins: [grid, menu], // 插件
+    defaultNode: {
+      // 默认节点样式
+      type: 'rect',
+      size: 40,
+      anchorPoints: [
+        [0, 0.5],
+        [1, 0.5]
+      ]
+    },
+    defaultEdge: {
+      // 默认线样式
+      type: 'cubic-horizontal',
+      color: '#121212',
+      style: {
+        lineWidth: 2
+      }
+    },
+    layout: {
+      // 布局配置
+      type: 'mindmap',
+      direction: 'H',
+      // 计算图形高度
+      getHeight: (node) => {
+        return node.type === 'jump-node' ? 28 : 88
+      },
+      // 计算图形宽度
+      getWidth: (node) => {
+        const data = node.data || {}
+        const label = data.jumpNodeName || '右键选择跳转目标'
+        const jumpNodeWidth = Util.getTextSize(label, 14)[0] + 24
+        return node.type === 'jump-node' ? jumpNodeWidth : 180
+      },
+      // 垂直方向上节点的间隔
+      getVGap: () => {
+        return 20
+      },
+      // 水平方向上节点的间隔
+      getHGap: () => {
+        return 80
+      },
+      // 方向
+      getSide: () => {
+        return 'right'
+      }
+    }
+  })
+
+  graph.data(props.graphData) // 读取 Step 2 中的数据源到图上
+  graph.render() // 渲染图
+  graph.fitCenter()
+  initHandle(graph, container)
+}
+
+onMounted(() => {
+  initGraph()
+  // 外侧监听可能还没初始化好,这里等一下
+  setTimeout(() => {
+    graphPostMessage({
+      msgType: 'graph_init',
+      content: '图表初始化成功'
+    })
+  }, 250)
+})
+// 获取到流程图数据
+const getGraphData = () => {
+  return graph.save()
+}
+
+defineExpose({
+  getGraphData
+})
+</script>
+
+<style lang="scss" scoped>
+#container {
+  width: 100vw;
+  height: 100vh;
+}
+:deep(.g6-component-contextmenu) {
+  padding: 0;
+  .menu-list {
+    list-style: none;
+    padding: 0;
+    .menu-item {
+      display: flex;
+      align-items: center;
+      padding: 8px 16px;
+      cursor: pointer;
+      color: #333;
+      font-size: 14px;
+      border-radius: 4px;
+      &:hover {
+        background-color: #f7f7f7;
+        color: rgb(95, 149, 255);
+      }
+    }
+    .menu-item-split {
+      margin: 4px 0;
+      width: 100%;
+      height: 1px;
+      background-color: #d8d8d8;
+    }
+  }
+}
+</style>

+ 6 - 0
src/components/InteractiveVideoEditor/index.js

@@ -0,0 +1,6 @@
+import VideoLineEditor from './VideoLineEditor/index.vue'
+import VideoLineGraph from './VideoLineGraph/index.vue'
+
+export const InteractiveVideoGraph = VideoLineGraph
+
+export default VideoLineEditor

+ 35 - 0
src/components/InteractiveVideoEditor/utils/iframePip.js

@@ -0,0 +1,35 @@
+const origin = "http://172.16.8.4";
+
+// 发送消息
+export function postMessage(data) {
+  window.parent.postMessage(
+    {
+      ...data,
+      source: "InteractiveVideo",
+      toolType: "InteractiveVideoEditor",
+    },
+    origin
+  );
+}
+
+// 反馈操作成功
+export function postMessageOk(callBackType) {
+  postMessage({
+    msgType: "ok",
+    content: {
+      callBackType,
+      msg: "操作成功",
+    },
+  });
+}
+
+// 反馈操作失败
+export function postMessageFail(callBackType, msg) {
+  postMessage({
+    msgType: "fail",
+    content: {
+      callBackType,
+      msg: `操作失败:${msg}`,
+    },
+  });
+}

+ 161 - 0
src/components/XGVideoViewer/hooks/useInitPlayer.js

@@ -0,0 +1,161 @@
+import { ref, onMounted, watch } from 'vue'
+import { postMessage } from '../utils/iframePip.js'
+import Player, { Events } from 'xgplayer'
+import 'xgplayer/dist/index.min.css'
+// xgplay相关的
+const useInitPlayer = (props, emits) => {
+  const xgplayer = ref(null)
+  // 是否全屏
+  const isCssFullScreenRef = ref(false)
+  // 是否展示问题
+  const questionShowRef = ref(false)
+  // 视频历史记录
+  const historyListRef = ref([])
+
+  // 是否是移动端设备
+  const isMobileDevice = () => {
+    return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
+      navigator.userAgent
+    )
+  }
+  // 是否是手机
+  const isMobile = isMobileDevice()
+  // 获取全屏设置
+  const getFullscreen = () => {
+    const defaultConfig = {
+      needBackIcon: true
+    }
+    return isMobile
+      ? {
+          rotateFullscreen: true,
+          ...defaultConfig
+        }
+      : {
+          useCssFullscreen: true,
+          ...defaultConfig
+        }
+  }
+
+  // 播放器容器对外发送信息
+  const viewerPostMessage = (data) => {
+    props.ifSendMsg && postMessage(data)
+  }
+
+  // 初始化播放器
+  const initPlay = () => {
+    historyListRef.value = []
+
+    return new Player({
+      id: 'mse',
+      lang: 'zh',
+      url: props.videoUrl,
+      height: props.height,
+      width: props.width,
+      // 自动播放
+      autoplay: props.autoplay,
+      // 静音播放
+      autoplayMuted: props.autoplayMuted,
+      // 封面图片
+      poster: props.poster,
+      // 全屏插件配置
+      fullscreen: getFullscreen(),
+      // 需要忽略的插件
+      ignores: ['cssfullscreen']
+    })
+  }
+  const playNext = (url, poster) => {
+    // 关闭问题
+    questionShowRef.value = false
+    // 播放下一个视频
+    xgplayer.value.playNext({
+      url: url
+    })
+    // 设置封面,playNext里面设置有点问题,时常不生效
+    xgplayer.value.setConfig({
+      poster: poster
+    })
+  }
+  // 初始化事件
+  const initHandler = (xgplayer) => {
+    if (!xgplayer) return
+    // 全屏变化
+    xgplayer.on(Events.FULLSCREEN_CHANGE, (isFullScreen) => {
+      isCssFullScreenRef.value = isFullScreen
+      emits('onFullscreenChange', isFullScreen)
+    })
+    // 视频播放结束
+    xgplayer.on(Events.ENDED, () => {
+      questionShowRef.value = true
+    })
+    // 视频开始播放
+    xgplayer.on(Events.PLAY, () => {
+      questionShowRef.value = false
+    })
+    // 视频资源加载成功
+    xgplayer.on(Events.LOADED_DATA, () => {
+      console.log('视频资源加载成功')
+      // 表示视频切换后加载成功
+      if (historyListRef.value.length > 0) {
+        xgplayer.currentTime = 0
+        setTimeout(() => {
+          xgplayer
+            .play()
+            .then(() => {
+              // 播放成功
+              console.log('播放成功')
+            })
+            .catch((err) => {
+              console.log('播放失败', err)
+              // 播放失败,一般发生于未经用户交互时的自动播放
+            })
+        }, 50)
+      }
+      historyListRef.value.push(props.videoUrl)
+    })
+  }
+  const init = () => {
+    if (!props.videoUrl) return
+    console.log('开始初始化视频播放器')
+    try {
+      xgplayer.value = initPlay()
+      initHandler(xgplayer.value)
+      viewerPostMessage({
+        msgType: 'video_init',
+        content: '视频播放器初始化成功'
+      })
+    } catch (error) {
+      viewerPostMessage({
+        msgType: 'video_init_fail',
+        content: '视频播放器初始失败'
+      })
+    }
+  }
+
+  watch(
+    () => props.videoUrl,
+    (val, oldVal) => {
+      // 首次加载视频进行初始化
+      if (val && !oldVal) init()
+    }
+  )
+
+  onMounted(() => {
+    init()
+    setTimeout(() => {
+      viewerPostMessage({
+        msgType: 'video_ready',
+        content: '视频容器准备就绪'
+      })
+    }, 250)
+  })
+
+  return {
+    xgplayer,
+    isMobile,
+    isCssFullScreenRef,
+    questionShowRef,
+    playNext
+  }
+}
+
+export default useInitPlayer

+ 201 - 0
src/components/XGVideoViewer/index.vue

@@ -0,0 +1,201 @@
+<template>
+  <div
+    :class="[
+      'video-wrapper',
+      isMobile ? 'mobile' : '',
+      isCssFullScreenRef ? 'fullScreen' : '',
+    ]"
+    :style="{ minHeight: props.height }"
+  >
+    <div id="mse"></div>
+    <Transition name="fade">
+      <div v-show="questionShowRef && question" class="question">
+        问题:{{ question }}
+      </div>
+    </Transition>
+    <Transition name="fade">
+      <div
+        v-show="questionShowRef"
+        :class="['option-btn-box']"
+        :style="{ flexWrap: props.options.length >= 4 ? 'wrap' : 'nowrap' }"
+      >
+        <div
+          class="option-btn-item"
+          v-for="(option, index) in props.options"
+          :key="index"
+        >
+          <div class="option-btn" @click="() => emits('onClickOption', option)">
+            {{ option.optionType }}:{{ option.optionName }}
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </div>
+</template>
+<script>
+export default {
+  name: "VideoLineGraph",
+};
+</script>
+<script setup>
+import { watch } from "vue";
+import useInitPlayer from "./hooks/useInitPlayer";
+
+const emits = defineEmits("onClickOption", "onFullscreenChange");
+
+const props = defineProps({
+  // 问题
+  question: {
+    type: String,
+    default: "",
+  },
+  // 选项
+  options: {
+    type: Array,
+    default: () => [],
+  },
+  // 视频地址
+  videoUrl: {
+    type: String,
+    default: "",
+  },
+  // 视频容器宽度
+  width: {
+    type: String,
+    default: "100%",
+  },
+  // 视频容器高度
+  height: {
+    type: String,
+    default: "80vh",
+  },
+  // 是否自动播放
+  autoplay: {
+    type: Boolean,
+    default: true,
+  },
+  // 是否静音自动播放。由于浏览器策略访问权重低的页面是不可以自动播放视频的,但是可以静音自动播放。这种情况下可以使用这个
+  autoplayMuted: {
+    type: Boolean,
+    default: false,
+  },
+  // 视频封面图片
+  poster: {
+    type: String,
+    default: "",
+  },
+  // 是否需要对外发送信息
+  ifSendMsg: {
+    type: Boolean,
+    default: false,
+  },
+  // 外部调用者传递的数据, ifSendMsg 为 false时,说明没有外部调用,此参数就无意义
+  pipData: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const { xgplayer, isMobile, isCssFullScreenRef, questionShowRef, playNext } =
+  useInitPlayer(props, emits);
+
+watch(
+  () => props.videoUrl,
+  (value) => {
+    if (!xgplayer.value || !props.videoUrl) return;
+    playNext(value, props.poster);
+    // 切换poster海报, playNext里面有时候会失败,这里重新赋值一下
+    const posterDom = document.querySelector(".xgplayer-poster");
+    posterDom.style.backgroundImage = `url("${props.poster}")`;
+  }
+);
+</script>
+
+<style lang="scss">
+.xgplayer .xgplayer-replay {
+  left: unset;
+  right: 0;
+  top: -20px;
+  flex-direction: row;
+  width: 110px;
+  transform: translate(-20%, 0%);
+}
+.xgplayer.xgplayer-rotate-fullscreen {
+  width: 100vh !important;
+}
+</style>
+
+<style lang="scss" scoped>
+.fade-enter-active {
+  transition: opacity 0.8s ease;
+}
+
+.fade-enter-from {
+  opacity: 0;
+}
+
+.video-wrapper {
+  width: 100%;
+  background: #999;
+  position: relative;
+  .question {
+    width: 100%;
+    position: absolute;
+    padding: 8px 16px;
+    top: 20%;
+    left: 0;
+    transform: translateY(-50%);
+    background-color: rgba($color: #000000, $alpha: 0.6);
+    color: #fff;
+    font-size: 14px;
+    font-weight: 600;
+    z-index: 9999;
+  }
+  .option-btn-box {
+    width: 100%;
+    position: absolute;
+    bottom: 60px;
+    left: 0;
+    display: flex;
+    z-index: 9999;
+    .option-btn-item {
+      width: 50%;
+      padding: 2px;
+      .option-btn {
+        border: 1px solid #000;
+        background-color: rgba($color: #ffffff, $alpha: 0.6);
+        color: #000;
+        font-size: 8px;
+        width: 100%;
+        height: 28px;
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+        padding: 0 2px;
+        user-select: none;
+        cursor: pointer;
+      }
+    }
+  }
+  &.fullScreen .option-btn-box,
+  &.fullScreen .question {
+    position: fixed;
+  }
+  &.mobile.fullScreen .question {
+    position: fixed;
+    left: 0;
+    top: 0;
+    width: 100vh;
+    transform: rotate(90deg) translate(0, -85vw);
+    transform-origin: 0 0;
+  }
+  &.mobile.fullScreen .option-btn-box {
+    left: 0;
+    bottom: 0;
+    position: fixed;
+    transform: rotate(90deg) translate(-100%, -60px);
+    transform-origin: 0 100%;
+    width: 100vh;
+  }
+}
+</style>

+ 35 - 0
src/components/XGVideoViewer/utils/iframePip.js

@@ -0,0 +1,35 @@
+const origin = "http://127.0.0.1:5500";
+
+// 发送消息
+export function postMessage(data) {
+  window.parent.postMessage(
+    {
+      ...data,
+      source: "InteractiveVideo",
+      toolType: "InteractiveVideoViewer",
+    },
+    origin
+  );
+}
+
+// 反馈操作成功
+export function postMessageOk(callBackType) {
+  postMessage({
+    msgType: "ok",
+    content: {
+      callBackType,
+      msg: "操作成功",
+    },
+  });
+}
+
+// 反馈操作失败
+export function postMessageFail(callBackType, msg) {
+  postMessage({
+    msgType: "fail",
+    content: {
+      callBackType,
+      msg: `操作失败:${msg}`,
+    },
+  });
+}

+ 92 - 0
src/hooks/transformGraphdata.js

@@ -0,0 +1,92 @@
+// 删除无用字段
+const deletsNoUseKey = (node) => {
+  delete node.id;
+  delete node.preNodeId;
+  delete node.x;
+  delete node.y;
+  delete node.style;
+  delete node.size;
+  delete node.depth;
+  delete node.selected;
+  delete node.anchorPoints;
+  delete node.contentHover;
+  return node;
+};
+
+// 给节点添加结构id
+function setNodeIdByList(nodeList, preNodeId, nodeMap) {
+  if (!Array.isArray(nodeList) || nodeList.length <= 0) return;
+  for (let i = 0; i < nodeList.length; i++) {
+    const node = nodeList[i];
+    // 把节点存入 nodeMap
+    nodeMap.set(String(node.id), node);
+    node.nodeId = preNodeId ? preNodeId + "-" + i : i + "";
+    setNodeIdByList(node.children, String(node.nodeId), nodeMap);
+  }
+}
+
+// 修改节点的data数据中指向节点
+const setNextNodeId = (node, nodeMap) => {
+  const nodeData = node.data;
+  // 修改选项的对应节点
+  if (node.nodeType === "root-node" || node.nodeType === "plot-node") {
+    const optionsList = nodeData.optionsList || [];
+    optionsList.forEach((optionItem) => {
+      const nextNode = nodeMap.get(optionItem.nodeId) || {};
+      optionItem.nextNodeId = nextNode.nodeId;
+    });
+  } else if (node.nodeType === "jumpNodeId") {
+    const nextNode = nodeMap.get(node.jumpNodeId) || {};
+    node.nextNodeId = nextNode.nodeId;
+  }
+};
+
+// 对节点的自定义数据 data 进行修改
+function transformNodeDataByList(nodeList, nodeMap) {
+  if (!Array.isArray(nodeList) || nodeList.length <= 0) return;
+  for (let i = 0; i < nodeList.length; i++) {
+    const node = nodeList[i];
+    node.nodeType = node.type;
+    node.data.nodeName = node.data.name;
+    setNextNodeId(node, nodeMap);
+    transformNodeDataByList(node.children, nodeMap);
+  }
+}
+
+// 修改数据
+export const transformGraphdata = (graphData) => {
+  // 记录id和节点的映射
+  let nodeMap = new Map();
+  // 先给所有数据添加 nodeId
+  setNodeIdByList([graphData], "", nodeMap);
+  // 处理节点的 data 数据
+  transformNodeDataByList([graphData], nodeMap);
+  // nodeMap 里面有所有的节点,在这里把所有节点的无用字段去除一下
+  nodeMap.forEach((node) => {
+    deletsNoUseKey(node);
+  });
+  return graphData;
+};
+
+export const transfromReturnGraphdata = (graphData) => {
+  // 记录id和节点的映射
+  let nodeMap = new Map();
+  // 先给所有数据添加 nodeId
+  setNodeIdByList([graphData], "", nodeMap);
+  // 处理节点的 data 数据
+  transformReturnNodeDataByList([graphData], nodeMap);
+  return graphData;
+};
+
+// 对节点的自定义数据 data 进行修改
+function transformReturnNodeDataByList(nodeList, nodeMap) {
+  if (!Array.isArray(nodeList) || nodeList.length <= 0) return;
+  for (let i = 0; i < nodeList.length; i++) {
+    const node = nodeList[i];
+    node.id = String(node.id);
+    node.type = node.nodeType;
+    node.data.name = node.data.nodeName;
+    setNextNodeId(node, nodeMap);
+    transformReturnNodeDataByList(node.children, nodeMap);
+  }
+}

+ 86 - 0
src/hooks/useEditIframeEvent.js

@@ -0,0 +1,86 @@
+import { onMounted, onBeforeUnmount } from "vue";
+import {
+  postMessageOk,
+  postMessageFail,
+} from "@/components/InteractiveVideoEditor/utils/iframePip";
+
+// 检查素材数据的有效性
+const checkSourcesData = (list) => {
+  if (!Array.isArray(list)) return false;
+  for (let i = 0; i < list.length; i++) {
+    const item = list[i];
+    // 需要校验的字段
+    const propList = ["id", "name", "url"];
+    for (let j = 0; j < propList.length; j++) {
+      const key = propList[j];
+      if (item[key] === undefined) return false;
+    }
+  }
+  return true;
+};
+
+// 监听iframe通讯
+const useEditIframeEvent = (pipData, graphData, videoSources, imageSources) => {
+  const handleMessage = (e) => {
+    if (e.data.source === "InteractiveVideo") {
+      console.log("接收到外部调用者的数据", e);
+      // 外部传入流程图数据,此时需要更新流程图数据
+      if (
+        e.data.toolType === "InteractiveVideoUser" &&
+        e.data.msgType === "graph_change_data"
+      ) {
+        if (!e.data.content) {
+          postMessageFail("graph_change_data", "数据格式不正确");
+          return;
+        }
+        pipData.value = e.data;
+        graphData.value = e.data.content || {};
+      }
+      // 外部传入视频素材数据,并更新
+      if (
+        e.data.toolType === "InteractiveVideoUser" &&
+        e.data.msgType === "change_sources_video"
+      ) {
+        if (!e.data.content || !checkSourcesData(e.data.content)) {
+          postMessageFail("change_sources_video", "数据格式不正确");
+          return;
+        }
+        pipData.value = e.data;
+        videoSources.value = e.data.content || [];
+        postMessageOk("change_sources_video");
+      }
+
+      // 外部传入图片素材数据,并更新
+      if (
+        e.data.toolType === "InteractiveVideoUser" &&
+        e.data.msgType === "change_sources_image"
+      ) {
+        if (!e.data.content || !checkSourcesData(e.data.content)) {
+          postMessageFail("change_sources_image", "数据格式不正确");
+          return;
+        }
+        pipData.value = e.data;
+        imageSources.value = e.data.content || [];
+        postMessageOk("change_sources_image");
+      }
+
+      // 外部主动想要获取到流程图数据
+      if (
+        e.data.toolType === "InteractiveVideoUser" &&
+        e.data.msgType === "graph_get_data"
+      ) {
+        pipData.value = e.data;
+      }
+    }
+  };
+
+  onMounted(() => {
+    window.addEventListener("message", handleMessage);
+  });
+
+  onBeforeUnmount(() => {
+    window.removeEventListener("message", handleMessage);
+  });
+};
+
+export default useEditIframeEvent;

+ 47 - 0
src/hooks/useIframeEvent.js

@@ -0,0 +1,47 @@
+import { onMounted, onBeforeUnmount } from 'vue'
+import { postMessageOk, postMessageFail } from '@/utils/iframePip.js'
+
+// 检查数据字段的有效性
+const checkData = (list, propList) => {
+  if (!Array.isArray(list)) return false
+  for (let i = 0; i < list.length; i++) {
+    const item = list[i]
+    for (let j = 0; j < propList.length; j++) {
+      const key = propList[j]
+      if (item[key] === undefined) return false
+    }
+  }
+  return true
+}
+
+// 监听iframe通讯
+const useIframeEvent = (detailInfoRef) => {
+  const handleMessage = (e) => {
+    if (e.data.source === 'InteractiveVideo') {
+      console.log('接收到外部调用者的数据', e)
+      // 外部传入流程图数据,此时需要更新流程图数据
+      if (e.data.toolType === 'InteractiveVideoUser' && e.data.msgType === 'video_change_data') {
+        if (
+          !e.data.content ||
+          // 检查数据字段是否符合要求
+          checkData(e.data.content, ['id', 'image', 'name', 'materialId', 'uri', 'optionsList'])
+        ) {
+          postMessageFail('video_change_data', '数据格式不正确')
+          return
+        }
+        detailInfoRef.value = e.data.content
+        postMessageOk('video_change_data')
+      }
+    }
+  }
+
+  onMounted(() => {
+    window.addEventListener('message', handleMessage)
+  })
+
+  onBeforeUnmount(() => {
+    window.removeEventListener('message', handleMessage)
+  })
+}
+
+export default useIframeEvent