Explorar el Código

新增hotspot

gemercheung hace 2 años
padre
commit
e0615db191

+ 1 - 1
.env.development

@@ -3,7 +3,7 @@ VITE_APP_RESOURCE_URL=https://4dkk.4dage.com/
 # 静态资源地址
 VITE_APP_CDN_URL=https://4dkk.4dage.com/v4/www/
 # sdk文件地址
-VITE_APP_SDK_DIR=https://4dkk.4dage.com/v4/sdk/4.7.9
+VITE_APP_SDK_DIR=https://4dkk.4dage.com/v4/sdk/4.9.0
 # VITE_APP_SOCKET_URL=ws://127.0.0.1:8889
 VITE_APP_SOCKET_URL=wss://testws.4dkankan.com
 # VITE_APP_SOCKET_URL=wss://221.4.210.172:16666

+ 1 - 1
.env.home.prod

@@ -3,7 +3,7 @@ VITE_APP_RESOURCE_URL=https://4dkk.4dage.com/
 # 静态资源地址
 VITE_APP_CDN_URL=https://4dkk.4dage.com/v4/www/
 # sdk文件地址
-VITE_APP_SDK_DIR=https://4dkk.4dage.com/v4/sdk/4.7.9
+VITE_APP_SDK_DIR=https://4dkk.4dage.com/v4/sdk/4.9.0
 # VITE_APP_SOCKET_URL=wss://ws.gemer.xyz
 VITE_APP_SOCKET_URL=wss://ws.4dkankan.com
 # VITE_APP_SOCKET_URL=wss://221.4.210.172:16666

+ 56 - 48
src/App.vue

@@ -25,6 +25,7 @@
   import { useI18n } from '/@/hooks/useI18n';
   import BaseDialog from '/@/components/chatRoom/dialog/base.vue';
   import PasswordDialog from '/@/components/chatRoom/dialog/password.vue';
+  import Hotspot from '/@/components/hotspot/index.vue';
   import { useRoom, roomId, currentSceneIndex } from './hooks/useRoom';
   import dayjs from 'dayjs';
   import Dialog from './components/basic/dialog';
@@ -96,54 +97,54 @@
     app.use('Tag');
 
     createTourPlayer();
-    app
-      .use('TagView', {
-        render(data) {
-          // console.log('tagView', data);
-          if (data.media['image'] && data.media['image'].length) {
-            // console.log('tagView-1', data);
-            // return h(tagView, {
-            //   sid: data.sid,
-            //   url: app.resource.getUserResourceURL(data.media['image'][0].src),
-            //   content: data.content,
-            //   title: data.title,
-            // });
-          }
-          // console.log('render', render);
-          // return `<span class="tag-icon animate" style="background-image:url({{icon}})"></span>`;
-          // return
-          return `<span class="tag-icon animate" style="display:none"></span>`;
-        },
-      })
-      .then(() => {
-        // const openTags = (tag) => {
-        // let item = tags.value.find((item) => item.sid == el.data.sid);
-        // guideclicktag(item);
-        // store.commit('tag/setTagClickType', {
-        //   type: 'goodlist',
-        //   data: item,
-        // });
-        // };
-        // view.on('rendered', (_) => {
-        //   view.on('click', async (e) => {
-        // var tag = e.data;
-        // 聚焦當前點擊的熱點
-        // debugger;
-        // await view.focus(tag.sid);
-        // });
-        // view.on('focus', (e) => {
-        //   if (!e.data.media['image'] || !e.data.media['image'].length) {
-        //     return;
-        //   }
-        //   document.querySelectorAll('[xui_tags_view] >div').forEach((el) => {
-        //     el.querySelector('.tag-body').classList.remove('show');
-        //     el.style.zIndex = 'auto';
-        //   });
-        //   e.target.style.zIndex = '999';
-        //   e.target.querySelector('.tag-body').classList.add('show');
-        // });
-        // }); //dom渲染完成
-      });
+    // app
+    //   .use('TagView', {
+    //     render(data) {
+    //       // console.log('tagView', data);
+    //       if (data.media['image'] && data.media['image'].length) {
+    //         // console.log('tagView-1', data);
+    //         // return h(tagView, {
+    //         //   sid: data.sid,
+    //         //   url: app.resource.getUserResourceURL(data.media['image'][0].src),
+    //         //   content: data.content,
+    //         //   title: data.title,
+    //         // });
+    //       }
+    //       // console.log('render', render);
+    //       // return `<span class="tag-icon animate" style="background-image:url({{icon}})"></span>`;
+    //       // return
+    //       return `<span class="tag-icon animate" style="display:none"></span>`;
+    //     },
+    //   })
+    //   .then(() => {
+    //     // const openTags = (tag) => {
+    //     // let item = tags.value.find((item) => item.sid == el.data.sid);
+    //     // guideclicktag(item);
+    //     // store.commit('tag/setTagClickType', {
+    //     //   type: 'goodlist',
+    //     //   data: item,
+    //     // });
+    //     // };
+    //     // view.on('rendered', (_) => {
+    //     //   view.on('click', async (e) => {
+    //     // var tag = e.data;
+    //     // 聚焦當前點擊的熱點
+    //     // debugger;
+    //     // await view.focus(tag.sid);
+    //     // });
+    //     // view.on('focus', (e) => {
+    //     //   if (!e.data.media['image'] || !e.data.media['image'].length) {
+    //     //     return;
+    //     //   }
+    //     //   document.querySelectorAll('[xui_tags_view] >div').forEach((el) => {
+    //     //     el.querySelector('.tag-body').classList.remove('show');
+    //     //     el.style.zIndex = 'auto';
+    //     //   });
+    //     //   e.target.style.zIndex = '999';
+    //     //   e.target.querySelector('.tag-body').classList.add('show');
+    //     // });
+    //     // }); //dom渲染完成
+    //   });
 
     // 暂时isTours url frag 做为 1自由观看模式与带看模式0
 
@@ -166,6 +167,12 @@
       //   store.commit("showShoppingguide", true);
       // }
     });
+
+    // sdk热点加载
+    // app.store.on('tags', ({ tags }) => {
+    //   console.warn('tags', tags);
+    //   sceneStore.loadTags(tags);
+    // });
     app.store.on('metadata', (metadata: KankanMetaDataType) => {
       sceneStore.load(metadata);
       if (!metadata.controls.showMap) {
@@ -367,6 +374,7 @@
       />
       <!-- panel end -->
     </template>
+    <Hotspot />
   </div>
   <BaseDialog />
 </template>

+ 16 - 0
src/components/hotspot/common.ts

@@ -0,0 +1,16 @@
+import { getApp } from '/@/hooks/userApp';
+
+export const changeUrl = (name: string) => {
+  if (name.indexOf('http') != -1) {
+    return name;
+  } else {
+    if (
+      (typeof name === 'string' && name.substring(0, 4) == 'blob') ||
+      (typeof name === 'string' && name.substring(0, 10) == 'data:image')
+    ) {
+      return name;
+    } else {
+      return getApp().resource.getUserResourceURL(name);
+    }
+  }
+};

+ 69 - 0
src/components/hotspot/constant.js

@@ -0,0 +1,69 @@
+import i18n from '@/i18n';
+const { t } = i18n.global;
+
+export const custom = () => {
+  return {
+    image: {
+      icon: 'pic',
+      upload: true,
+      uploadPlace: t('common.upload') + t('common.image'),
+      accept: `.jpg,.png`,
+      multiple: true,
+      name: t('common.image'),
+      maxSize: 5 * 1024 * 1024,
+      maxNum: 9,
+      // othPlaceholder: '支持JPG、PNG图片格式,单张不超过5MB,最多支持上传9张。',
+      othPlaceholder: t('tag.toolbox.metaImageTips', {
+        form: 'jpg/png/gif',
+        size: '5MB',
+        maxlength: '9',
+      }),
+    },
+    video: {
+      icon: 'video',
+      upload: true,
+      uploadPlace: t('common.upload') + t('common.video'),
+      accept: `.mp4, .mov`,
+      multiple: false,
+      name: t('common.video'),
+      maxSize: 20 * 1024 * 1024,
+      // othPlaceholder: '支持MP4、MOV视频格式,码率小于2Mbps,不超过20MB',
+      othPlaceholder: t('tag.toolbox.metaVideoTips', {
+        form: 'mp4/mov',
+        size: '20MB',
+        bit: '2Mbps',
+      }),
+    },
+    audio: {
+      icon: 'music',
+      upload: true,
+      uploadPlace: t('common.upload') + t('common.audio'),
+      accept: '.mp3, .wav',
+      multiple: false,
+      name: t('common.audio'),
+      maxSize: 5 * 1024 * 1024,
+      // othPlaceholder: '支持MP3、WAV格式,不超过5MB',
+      othPlaceholder: t('tag.toolbox.metaAudioTips', { form: 'mp3/wav', size: '5MB' }),
+    },
+    link: {
+      icon: 'web',
+      name: t('common.link'),
+    },
+    text: {
+      icon: 'pic',
+      upload: true,
+      uploadPlace: t('common.upload') + t('common.image'),
+      accept: `.jpg,.png`,
+      multiple: true,
+      name: t('common.image'),
+      maxSize: 5 * 1024 * 1024,
+      maxNum: 9,
+      // othPlaceholder: '支持JPG、PNG图片格式,单张不超过5MB,最多支持上传9张。',
+      othPlaceholder: t('tag.toolbox.metaImageTips', {
+        form: 'jpg/png/gif',
+        size: '5MB',
+        maxlength: '9',
+      }),
+    },
+  };
+};

+ 178 - 0
src/components/hotspot/index.vue

@@ -0,0 +1,178 @@
+<template>
+  <teleport :to="tags$" v-if="tags$">
+    <template v-for="(tag, index) in tags" :key="tag.sid">
+      <div
+        :tag-sid="tag.sid"
+        @mouseleave.prevent="onMouseLeave($event, tag)"
+        :style="{
+          transform: `translate3d(${tag.x}px,${tag.y}px,0)`,
+          'z-index': 1,
+        }"
+        :class="{
+          visible: true,
+        }"
+      >
+        <span
+          class="point zoom"
+          @mouseenter.prevent="onMouseEnter($event, tag, index)"
+          @click.stop="goTag($event, tag, index)"
+          :style="{ 'background-image': 'url(' + getUrl(tag.icon) + ')' }"
+        ></span>
+        <div class="content">
+          <div class="trans">
+            <!-- :class="{
+              fixed: isPlay,
+              active:
+                (isFixed && hotData && tag.sid == hotData.sid) ||
+                (showInfo && hotData && tag.sid == hotData.sid),
+            }" -->
+            <template v-if="tag && !showMsg">
+              <div class="arrow" :id="`arrow_${tag.sid}`">
+                <!-- <ui-icon @click.stop="closeTag" type="close" /> -->
+              </div>
+              <!-- <ShowTag @click.stop="" v-if="tag" @open="openInfo" /> -->
+            </template>
+          </div>
+        </div>
+
+        <!-- <TagView @click.stop="" v-if="showMsg && toggleIndex == index" @close="closeInfo" /> -->
+      </div>
+    </template>
+  </teleport>
+</template>
+<script setup lang="ts">
+  import { ref, onMounted, nextTick } from 'vue';
+  import { useSceneStore } from '/@/store/modules/scene';
+  import { computed } from 'vue';
+  import { getApp, useApp } from '/@/hooks/userApp';
+  import browser from '/@/utils/browser';
+  // import { getApp, useApp } from '@/app';
+  // import { useStore } from 'vuex';
+  import { changeUrl } from './common';
+  import { watchEffect } from 'vue';
+  // import TagView from './tag-view.vue';
+  // import ShowTag from './show-tag.vue';
+  // import { useRoute } from 'vue-router';
+  // import { useMusicPlayer } from '@/utils/sound';
+  // import browser from '@/utils/browser';
+  // import { Track, startTrack, endTrack } from "@/utils/track.js";
+  // const musicPlayer = useMusicPlayer();
+  const sceneStore = useSceneStore();
+  const tags$ = ref('');
+  const tags = computed(() => sceneStore.tags);
+
+  const showInfo = ref(false);
+  const showMsg = ref(false);
+  // const toggleIndex = ref(null)
+  const openInfo = () => {
+    showMsg.value = true;
+    store.commit('tag/setData', { isFixed: false, isClick: true });
+    showInfo.value = false;
+  };
+  const closeInfo = () => {
+    showMsg.value = false;
+    if (isClick.value) {
+      //只有点击定位的才恢复显示
+      store.commit('tag/show', toggleIndex.value);
+      store.commit('tag/setFixed', true);
+      // showInfo.value = true
+      showInfo.value = false;
+    }
+    // store.commit('tag/setClick', false)
+  };
+  const closeTag = async () => {
+    const app = getApp();
+    const player = await app.TourManager.player;
+
+    //关闭热点面板时候,继续播放之前暂停的音频
+    if (!app.Scene.isCurrentPanoHasVideo && !player.isPlaying) {
+      if (hotData.value.type == 'audio' || hotData.value.type == 'video') {
+        // console.log('resume')
+        window.parent.postMessage(
+          {
+            source: 'qjkankan',
+            event: 'toggleBgmStatus',
+            params: {
+              status: true,
+            },
+          },
+          '*',
+        );
+      }
+    }
+    // store.commit('tag/setData', { isFixed: false, isClick: false, flyClose: false })
+    store.commit('tag/setData', { isFixed: false, isClick: false });
+    store.commit('tag/closeTag');
+
+    // store.commit('tag/setFixed', false)
+    // store.commit('tag/closeTag')
+    // store.commit('tag/setClick', false)
+    showInfo.value = false;
+  };
+  const goTag = async (event, item, index) => {};
+  const onMouseLeave = (...arg) => {
+    console.log(arg);
+  };
+  const onMouseEnter = (...arg) => {
+    console.log(arg);
+  };
+
+  onMounted(async () => {
+    const app = await useApp();
+    app.TagManager.on('loaded', (data: any) => {
+      console.log('data', data);
+      sceneStore.loadTags(data);
+    });
+    watchEffect(() => {
+      if (tags.value?.length) {
+        app.TagManager.load(tags.value);
+      }
+    });
+    app.Camera.on('flying.started', (pano) => {
+      // if (!pano.isTagFlying && hotData.value && !isEdit.value) {
+      //   closeTag();
+      // }
+      // if (flyClose.value && hotData.value && !isEdit.value) {
+      //     getApp().TagManager.close(hotData.value.sid)
+      //     closeTag()
+      // }
+    });
+    app.Scene.on('loadeddata', () => {
+      if (browser.hasURLParam('t_id')) {
+        let t_id = browser.getURLParam('t_id');
+        for (let i = 0; i < tags.value.length; i++) {
+          if (tags.value[i].sid == t_id) {
+            goTag({}, tags.value[i], i);
+          }
+        }
+      }
+    });
+    // app.Camera.on('flying.ended', ({ targetPano }) => {
+    // })
+    await app.TagManager.tag();
+    // init = false;
+    tags$.value = '[xui_tags]';
+
+    app.TagManager.updatePosition(tags.value);
+    if (app.config.mobile) {
+      nextTick(() => {
+        // let player = document.querySelector('.player')
+        // player.addEventListener('touchstart', onClickHandler)
+      });
+    } else {
+      // window.addEventListener('click', onClickHandler)
+    }
+  });
+  const getUrl = (icon) => {
+    let url =
+      icon == '' || !icon ? getApp().resource.getAppURL('images/tag_icon_default.svg') : icon;
+    // debugger;
+    return changeUrl(url);
+  };
+
+  const onClickHandler = () => {
+    // if (!isEdit.value && !positionInfo.value && isFixed.value) {
+    //   closeTag();
+    // }
+  };
+</script>

+ 158 - 0
src/components/hotspot/metas/image-view.vue

@@ -0,0 +1,158 @@
+<!--  -->
+<template>
+  <div class="image-view">
+    <div class="swiper mySwiper">
+      <div class="swiper-wrapper">
+        <div class="swiper-slide" v-for="(i, index) in imageList" :key="index">
+          <div class="swiper-zoom-container">
+            <div
+              :id="`vmRef_${index}`"
+              class="swiper-zoom-target"
+              :style="`background-image: url(${common.changeUrl(i.src)})`"
+            ></div>
+          </div>
+        </div>
+      </div>
+      <div class="swiper-pagination"></div>
+      <!-- <div class="swiper-button-next"></div>
+      <div class="swiper-button-prev"></div> -->
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  // import Swiper from './swiper/swiper.js'
+  // import './swiper/swiper.css'
+  // import './scale/previewImage.min'
+  // import { zoomElement } from './scale/index.js'
+  import { onMounted, computed, ref, nextTick } from 'vue';
+  import { useStore } from 'vuex';
+  import common from '@/utils/common';
+  const store = useStore();
+  const hotData = computed(() => store.getters['tag/hotData']);
+  const imageList = computed(() => {
+    let list = hotData.value.media.image;
+
+    return list;
+  });
+  const vmZoom = ref([]);
+  const zoomList = [];
+  onMounted(() => {
+    let urls = [];
+    var swiper = new Swiper('.mySwiper', {
+      zoom: {
+        toggle: false,
+        maxRatio: 5,
+      },
+      pagination: {
+        el: '.swiper-pagination',
+      },
+      on: {
+        init: function (swiper) {
+          // for (let i = 0; i < imageList.value.length; i++) {
+          //     vmZoom.value[i] = document.getElementById(`vmRef_${i}`)
+          //     zoomElement(vmZoom.value[i])
+          // }
+        },
+        transitionStart: function (swiper) {
+          // alert(swiper.previousIndex)
+          // console.log(vmZoom.value[swiper.previousIndex].style.transform)
+          // let scale = getTransform(vmZoom.value[swiper.previousIndex])
+        },
+
+        touchStart: function (swiper, event) {
+          // console.log(swiper.previousIndex)
+        },
+      },
+      navigation: {
+        nextEl: '.swiper-button-next',
+        prevEl: '.swiper-button-prev',
+      },
+    });
+    // const getTransform = el => {
+    //     var st = window.getComputedStyle(el, null)
+    //     var tr =
+    //         st.getPropertyValue('-webkit-transform') ||
+    //         st.getPropertyValue('-moz-transform') ||
+    //         st.getPropertyValue('-ms-transform') ||
+    //         st.getPropertyValue('-o-transform') ||
+    //         st.getPropertyValue('transform') ||
+    //         'FAIL'
+
+    //     var values = tr.split('(')[1].split(')')[0].split(',')
+    //     var a = values[0]
+    //     var b = values[1]
+
+    //     var scale = Math.sqrt(a * a + b * b)
+    //     console.log('Scale: ' + scale)
+    //     return scale
+    //     // var angle = Math.round(Math.atan2(b, a) * (180 / Math.PI))
+    //     // console.log('Rotate: ' + angle + 'deg')
+    // }
+    nextTick(() => {
+      for (let i = 0; i < imageList.value.length; i++) {
+        // console.log(vmZoom.value[i])
+        // zoomElement(vmZoom.value[i])
+      }
+    });
+    // for (let i = 0; i < imageList.value.length; i++) {
+    //     urls.push(common.changeUrl(imageList.value[i].src))
+    // }
+    // var obj = {
+    //     urls,
+    //     current: urls[0],
+    // }
+    // console.log(previewImage)
+    // previewImage.start(obj)
+  });
+</script>
+
+<style lang="scss" scoped>
+  .image-view {
+    width: 100%;
+    height: 100%;
+    // position: fixed;
+    // top: 0;
+    // left: 0;
+    // transform: translate3d(0, 0, 0);
+    // overflow: hidden;
+
+    .swiper {
+      width: 100%;
+      height: 100%;
+
+      .swiper-slide {
+        transform: translate3d(0, 0, 0);
+        overflow: hidden;
+      }
+      .swiper-zoom-container {
+        width: 100%;
+        height: 100%;
+        transform: translate3d(0, 0, 0);
+        // overflow: hidden;
+        .swiper-zoom-target {
+          width: 100%;
+          height: 100%;
+          background-position: center;
+          background-size: contain;
+          margin: 0 auto;
+          transform: translate3d(0, 0, 0);
+          // img {
+          //     width: 100%;
+          //     height: 100%;
+          // }
+        }
+      }
+    }
+  }
+</style>
+<style lang="scss">
+  .image-view {
+    .swiper-pagination-bullet {
+      background: #f2f2f2;
+    }
+    .swiper-pagination-bullet-active {
+      background: var(--editor-main-color);
+    }
+  }
+</style>

+ 67 - 0
src/components/hotspot/metas/metas-audio.vue

@@ -0,0 +1,67 @@
+<template>
+  <div class="audio-box">
+    <div class="del-btn" @click="delAudio()">
+      <ui-icon type="del" />
+    </div>
+    <div v-for="(i, index) in audioInfo" class="audio-msg" :key="index">
+      <ui-icon type="music" />
+      <span>{{ i.name }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import {ref, computed } from 'vue';
+  import { useStore } from 'vuex';
+  import { Cropper, Loading, Dialog } from '@/global_components';
+  import { custom } from '../constant.js';
+  import common from '@/utils/common';
+  const store = useStore();
+  const audioNum = ref(0);
+  const type = ref('audio');
+  const hotData = computed(() => store.getters['tag/hotData']);
+  const audioInfo = computed(() => {
+    return hotData.value.media.audio;
+  });
+  const delAudio = () => {
+    store.commit('tag/delMetas', { type: type.value, index: audioNum.value });
+  };
+</script>
+<style lang="scss" scoped>
+  .del-btn {
+    width: 24px;
+    height: 24px;
+    background: rgba(0, 0, 0, 0.3);
+    border-radius: 50%;
+    position: absolute;
+    cursor: pointer;
+    top: 10px;
+    right: 10px;
+    z-index: 10;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  .audio-box {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    position: absolute;
+    border-radius: 4px;
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    background: rgba(255, 255, 255, 0.1);
+    top: 0;
+    left: 0;
+    z-index: 10;
+    .audio-msg {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      .iconfont {
+        margin-right: 6px;
+      }
+    }
+  }
+</style>

+ 647 - 0
src/components/hotspot/metas/metas-image.vue

@@ -0,0 +1,647 @@
+<!--  -->
+<template>
+  <!-- <div v-if="imageList.length > 0 && type == 'IMAGE'" class="pic-box"> -->
+  <div
+    class="pic-box"
+    :class="{ show: viewer }"
+    :style="metasHeight ? `height:${metasHeight}px;` : ''"
+  >
+    <div>
+      <div class="ctrl-btn left-btn" v-if="imageNum != 0" @click.stop="chengeImgae('pre')">
+        <ui-icon type="left"></ui-icon>
+      </div>
+      <div
+        class="ctrl-btn right-btn"
+        v-if="imageNum < imageList.length - 1"
+        @click.stop="chengeImgae('next')"
+      >
+        <ui-icon type="right"></ui-icon>
+      </div>
+    </div>
+    <div class="over-box" ref="containerRef">
+      <div
+        v-show="!loading"
+        class="image-list"
+        :style="`transform:translateX(${-100 * imageNum}%);`"
+      >
+        <!-- <div
+                    @click="openScale(i.src)"
+                    :style="`transform:translateX(${100 * index}%);background-image:url(${common.changeUrl(i.src)});`"
+                    class="image-item"
+                    v-for="(i, index) in imageList"
+                ></div> -->
+        <div
+          @click="openScale(i.src)"
+          :style="`transform:translateX(${100 * index}%);`"
+          class="image-item"
+          v-for="(i, index) in imageList"
+        >
+          <img :id="`domImg${index}`" :src="common.changeUrl(i.src)" alt="" />
+        </div>
+
+        <!-- <div v-else :style="`transform:translateX(${100 * index}%);`" class="image-item" v-for="(i, index) in imageList">
+                    <img @error="filesError(index)" :src="common.changeUrl(i.src)" alt="" />
+                </div> -->
+      </div>
+      <ui-icon v-show="loading" class="loading-icon" type="_loading_"></ui-icon>
+
+      <div v-if="isEdit" class="del-btn" @click="delPic()">
+        <ui-icon type="del"></ui-icon>
+      </div>
+    </div>
+    <div class="continue" v-if="(!isEdit && imageList.length > 1) || isEdit">
+      <ui-input
+        v-if="imageList.length < customer[type].maxNum && isEdit"
+        type="file"
+        :placeholder="customer[type].uploadPlace"
+        :disable="customer[type].upload"
+        :scale="customer[type].scale"
+        :accept="customer[type].accept"
+        :multiple="customer[type].multiple"
+        :maxSize="customer[type].maxSize"
+        :maxLen="customer[type].maxNum"
+        :othPlaceholder="customer[type].othPlaceholder"
+        @update:modelValue="(data) => hanlderFiles(data)"
+      >
+        <template v-slot:replace>
+          <span class="continue-tips">{{ $t('tag.toolbox.continueAdd') }}</span>
+        </template>
+      </ui-input>
+      <span v-if="isEdit" class="pic-num">
+        <span class="cur">{{ imageList.length }}</span>
+        <span> / {{ customer[type].maxNum }}</span>
+      </span>
+      <span v-else class="pic-num">
+        <span class="cur">{{ imageNum + 1 }}</span>
+        <span><span>&nbsp;</span>/<span>&nbsp;</span></span>
+        <span>{{ imageList.length }}</span>
+      </span>
+    </div>
+    <!-- 移动端缩放 -->
+    <!-- swiper新增后,此功能无用 -->
+    <!-- <teleport to="body">
+            <div class="showPicBox" v-if="zoomInImg">
+                <span class="close" @click="closePhoto">
+                    <ui-icon type="close"></ui-icon>
+                </span>
+                <ui-icon v-show="loading" class="loading-icon" type="_loading_"></ui-icon>
+                <div @click="closePhoto" class="imgbox" ref="topology" :style="`background-image:url(${zoomInImg});`"></div>
+            </div>
+        </teleport> -->
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, nextTick, ref, computed, defineProps, defineEmits } from 'vue';
+  import { useStore } from 'vuex';
+  import common from '@/utils/common';
+  import { custom } from '../constant.js';
+  const customer = custom();
+  import { getApp, useApp } from '@/app';
+  import { Dialog } from '@/global_components';
+  import { useI18n } from '../../../i18n';
+  const { t } = useI18n({ useScope: 'global' });
+  // import { zoomElement } from './scale/index.js'
+  // import Hammer from 'hammerjs' // 引用hammerjs
+  const isMobile = ref(false);
+  const store = useStore();
+  const type = ref('image');
+  const emit = defineEmits(['close']);
+  const props = defineProps({
+    metasHeight: {
+      type: Number,
+      default: null,
+    },
+    viewer: {
+      type: Boolean,
+      default: false,
+    },
+    scale: {
+      type: Boolean,
+      default: false,
+    },
+  });
+  const isEdit = computed(() => store.getters['tag/isEdit']);
+  const hotData = computed(() => store.getters['tag/hotData']);
+  const topology = ref(null);
+  const imageList = computed(() => {
+    return hotData.value.media.image;
+  });
+  const loading = ref(true);
+  const imageNum = ref(0);
+  const delPic = () => {
+    store.commit('tag/delMetas', { index: imageNum.value, type: type.value });
+    if (imageNum.value > 0) {
+      imageNum.value--;
+    }
+  };
+  const chengeImgae = (type) => {
+    if (type == 'pre') {
+      imageNum.value--;
+    } else {
+      imageNum.value++;
+    }
+    if (props.viewer && !isMobile.value) {
+      resetScale();
+    }
+  };
+  const hanlderFiles = (data) => {
+    // store.commit('tag/setImageList', data[0])
+    setImageList(data[0]);
+    // if (imageNum.value < imageList.value.length + data[0].length - 1) {
+    //     imageNum.value = imageList.value.length + data[0].length - 1
+    // }
+    imageNum.value = imageList.value.length - 1;
+  };
+
+  const setImageList = (data) => {
+    let picLength = 0;
+    let list = JSON.parse(JSON.stringify(imageList.value));
+    if (list.length > 0) {
+      picLength = list.length;
+    }
+    for (let i = 0; i < data.length; i++) {
+      if (list.length < customer['image'].maxNum) {
+        list.push('');
+        var index = i + picLength;
+        list[index] = { src: URL.createObjectURL(data[i]), file: data[i] };
+      } else {
+        Dialog.toast({
+          type: 'error',
+          content: `${t('limit.maxLengthFile', { length: customer['image'].maxNum })}`,
+        });
+        break;
+      }
+    }
+    store.commit('tag/setImageList', list);
+  };
+  let result = { width: 0, height: 0 },
+    x,
+    y,
+    scale = 1,
+    minScale = 0.5,
+    maxScale = 4,
+    isPointerdown = false, // 按下标识
+    diff = { x: 0, y: 0 }, // 相对于上一次pointermove移动差值
+    lastPointermove = { x: 0, y: 0 }, // 用于计算diff
+    transform = { x: 0, y: 0 };
+
+  const drag = (image) => {
+    // 绑定 pointerdown
+    image.addEventListener('pointerdown', function (e) {
+      isPointerdown = true;
+      image.setPointerCapture(e.pointerId);
+      lastPointermove = { x: e.clientX, y: e.clientY };
+    });
+    // 绑定 pointermove
+    image.addEventListener('pointermove', function (e) {
+      if (isPointerdown) {
+        const current = { x: e.clientX, y: e.clientY };
+        diff.x = current.x - lastPointermove.x;
+        diff.y = current.y - lastPointermove.y;
+        lastPointermove = { x: current.x, y: current.y };
+        x += diff.x;
+        y += diff.y;
+
+        image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(' + scale + ')';
+        // log.innerHTML = `x = ${x.toFixed(0)}<br>y = ${y.toFixed(0)}<br>scale = ${scale.toFixed(5)}`
+      }
+      e.preventDefault();
+    });
+    // 绑定 pointerup
+    image.addEventListener('pointerup', function (e) {
+      if (isPointerdown) {
+        isPointerdown = false;
+      }
+    });
+    // 绑定 pointercancel
+    image.addEventListener('pointercancel', function (e) {
+      if (isPointerdown) {
+        isPointerdown = false;
+      }
+    });
+  };
+  const containerRef = ref(null);
+
+  const zoomFun = (e) => {
+    let ratio = 1.1;
+    // 缩小
+    if (e.deltaY > 0) {
+      ratio = 1 / 1.1;
+    }
+    const _scale = scale * ratio;
+    if (_scale > maxScale) {
+      ratio = maxScale / scale;
+      scale = maxScale;
+    } else if (_scale < minScale) {
+      ratio = minScale / scale;
+      scale = minScale;
+    } else {
+      scale = _scale;
+    }
+    // 目标元素是img说明鼠标在img上,以鼠标位置为缩放中心,否则默认以图片中心点为缩放中心
+    console.log(e.target.tagName);
+    if (e.target.tagName === 'IMG') {
+      const origin = {
+        x: (ratio - 1) * result.width * 0.5,
+        y: (ratio - 1) * result.height * 0.5,
+      };
+      // 计算偏移量
+      x -= (ratio - 1) * (e.clientX - x) - origin.x;
+      y -= (ratio - 1) * (e.clientY - y) - origin.y;
+    }
+    imgEle.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(' + scale + ')';
+    // log.innerHTML = `x = ${x.toFixed(0)}<br>y = ${y.toFixed(0)}<br>scale = ${scale.toFixed(5)}`
+    e.preventDefault();
+  };
+
+  // 滚轮缩放
+  const initWheelZoom = (imgEle) => {
+    containerRef.value.addEventListener('wheel', zoomFun);
+  };
+  const removeWheelZoom = () => {
+    containerRef.value.removeEventListener('wheel', zoomFun);
+  };
+
+  const resetScale = () => {
+    result = { width: 0, height: 0 };
+    x = null;
+    y = null;
+    scale = 1;
+    minScale = 0.5;
+    maxScale = 4;
+    isPointerdown = false; // 按下标识
+    diff = { x: 0, y: 0 }; // 相对于上一次pointermove移动差值
+    lastPointermove = { x: 0, y: 0 }; // 用于计算diff
+
+    nextTick(() => {
+      removeWheelZoom();
+      initScale();
+    });
+  };
+  let imgEle = null;
+  const initScale = () => {
+    imgEle = document.getElementById(`domImg${imageNum.value}`);
+
+    x = 0;
+    y = 0;
+    imgEle.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(1)';
+
+    result.width = imgEle.width;
+    result.height = imgEle.height;
+
+    nextTick(() => {
+      // 拖拽查看
+      drag(imgEle);
+      // 滚轮缩放
+      initWheelZoom(imgEle);
+    });
+  };
+  onMounted(async () => {
+    const app = await useApp();
+    isMobile.value = app.config.mobile;
+
+    nextTick(() => {
+      let img = new Image();
+      img.onload = () => {
+        loading.value = false;
+
+        if (props.viewer && !isMobile.value) {
+          //pc端缩放
+          nextTick(() => {
+            initScale();
+          });
+        }
+      };
+      img.src = common.changeUrl(imageList.value[0].src);
+      // if (imageList.value.length > 1) {
+      //     //监听移动端手势
+      //     if (props.scale) {
+      //         var el = document.getElementById('image-list')
+      //         // var hammer = new Hammer(square)
+      //         var mc = new Hammer.Manager(el)
+      //         mc.add(new Hammer.Pinch({ threshold: 0 }))
+      //         mc.on('pinchstart', ev => {
+      //             // 双指缩放
+      //             console.log('双指缩放')
+      //             openScale(imageList.value[imageNum.value].src)
+      //         })
+      //     }
+      // }
+      if (props.scale) {
+        if (imageList.value.length == 1) {
+          openScale(imageList.value[0].src);
+        }
+      }
+    });
+  });
+  const filesError = (index) => {
+    loading.value = false;
+  };
+  const zoomInImg = ref(null);
+  const closePhoto = () => {
+    if (imageList.value.length == 1) {
+      emit('close');
+    } else {
+      zoomInImg.value = null;
+    }
+    // $('#tag-billboards').css({ 'z-index': '101', 'pointer-events': 'none' });
+  };
+  const openScale = (src) => {
+    if (isMobile.value) {
+      zoomInImg.value = common.changeUrl(src);
+
+      let img = new Image();
+      img.onload = () => {
+        loading.value = false;
+      };
+      img.src = zoomInImg.value;
+      // nextTick(() => {
+      //     zoomElement(topology.value)
+      // })
+    }
+  };
+</script>
+<style lang="scss" scoped>
+  .showPicBox {
+    width: 100%;
+    height: 100%;
+    position: fixed;
+    z-index: 10000;
+    background: rgb(24, 22, 22);
+    top: 0;
+    left: 0;
+    .close {
+      position: absolute;
+      top: 10px;
+      right: 10px;
+      width: 20px;
+      height: 20px;
+      z-index: 100;
+      color: #fff;
+      .iconfont {
+        font-size: 20px;
+      }
+    }
+    .loading {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+    }
+    .imgbox {
+      width: 100%;
+      height: 100%;
+      background-repeat: no-repeat;
+      background-size: contain;
+      background-position: center center;
+      #eleImg {
+        // position: absolute;
+
+        // top: 50%;
+        // left: 50%;
+        // transform: translate(-50%, -50%);
+        margin: 0 auto;
+        display: block;
+        &.s {
+          height: 100%;
+          width: auto;
+        }
+        &.h {
+          height: auto;
+          width: 100%;
+        }
+      }
+    }
+  }
+  .del-btn {
+    width: 24px;
+    height: 24px;
+    background: rgba(0, 0, 0, 0.3);
+    border-radius: 50%;
+    position: absolute;
+    cursor: pointer;
+    top: 10px;
+    right: 10px;
+    z-index: 10;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  .loading-icon {
+    color: var(--editor-main-color);
+    animation: rotate 2s infinite linear;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    font-size: 30px;
+  }
+  .pic-box {
+    width: 100%;
+    height: 100%;
+    position: absolute;
+    border-radius: 4px;
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    top: 0;
+    left: 0;
+    z-index: 10;
+
+    @keyframes rotate {
+      0% {
+        transform: translate(-50%, -50%) rotate(0deg);
+      }
+      100% {
+        transform: translate(-50%, -50%) rotate(360deg);
+      }
+    }
+    .over-box {
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+    .continue {
+      width: 100%;
+      height: 32px;
+      background: linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, #000000 200%);
+      border-radius: 0px 0px 4px 4px;
+      position: absolute;
+      bottom: 0;
+      left: 0;
+
+      .ui-input {
+        width: 100%;
+      }
+      .continue-tips {
+        font-size: 12px;
+      }
+      .pic-num {
+        position: absolute;
+        right: 10px;
+        top: 50%;
+        transform: translateY(-50%);
+        font-size: 12px;
+        .cur {
+          color: var(--editor-main-color);
+        }
+      }
+    }
+
+    .ctrl-btn {
+      width: 32px;
+      height: 32px;
+      background: rgba(0, 0, 0, 0.2);
+      border-radius: 50%;
+      position: absolute;
+      cursor: pointer;
+      top: 50%;
+      transform: translateY(-50%);
+      z-index: 10;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      .iconfont {
+        font-size: 14px;
+      }
+      &.left-btn {
+        left: 5px;
+      }
+      &.right-btn {
+        right: 5px;
+      }
+    }
+    .image-list {
+      width: 100%;
+      height: 100%;
+      position: relative;
+      transition: all 0.3s linear;
+      .image-item {
+        width: 100%;
+        height: 100%;
+        // background: red;
+        position: absolute;
+        transform: translateX(0);
+        text-align: center;
+        background-repeat: no-repeat;
+        background-size: contain;
+        background-position: center;
+        overflow: hidden;
+        img {
+          height: 100%;
+          width: 100%;
+          object-fit: contain;
+          touch-action: none;
+        }
+      }
+    }
+    &.show {
+      .ctrl-btn {
+        width: 40px;
+        height: 80px;
+        background: rgba(0, 0, 0, 0.6);
+        .iconfont {
+          font-size: 20px;
+        }
+        &.left-btn {
+          left: 0px;
+          border-radius: 0 40px 40px 0;
+          .icon {
+            margin-right: 5px;
+          }
+        }
+        &.right-btn {
+          right: 0px;
+          border-radius: 40px 0 0 40px;
+          .icon {
+            margin-left: 8px;
+          }
+        }
+      }
+      .continue {
+        width: 76px;
+        height: 36px;
+        background: rgba(0, 0, 0, 0.6);
+        border-radius: 20px;
+        position: absolute;
+        bottom: -5%;
+        left: 50%;
+        transform: translateX(-50%);
+
+        .pic-num {
+          width: 76px;
+          height: 36px;
+          display: inline-block;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          font-size: 20px;
+          top: 50%;
+          left: 50%;
+          transform: translate(-50%, -50%);
+          span {
+            display: flex;
+            align-items: center;
+            justify-content: center;
+          }
+        }
+      }
+    }
+  }
+
+  [is-mobile] {
+    .pic-box {
+      &.show {
+        .ctrl-btn {
+          width: 40px;
+          height: 80px;
+          background: rgba(0, 0, 0, 0.6);
+          .iconfont {
+            font-size: 20px;
+          }
+          &.left-btn {
+            left: 0px;
+            border-radius: 0 40px 40px 0;
+            .icon {
+              margin-right: 5px;
+            }
+          }
+          &.right-btn {
+            right: 0px;
+            border-radius: 40px 0 0 40px;
+            .icon {
+              margin-left: 8px;
+            }
+          }
+        }
+        .continue {
+          width: 76px;
+          height: 36px;
+          background: rgba(0, 0, 0, 0.6);
+          border-radius: 20px;
+          position: absolute;
+          bottom: -6%;
+          left: 50%;
+          transform: translateX(-50%);
+
+          .pic-num {
+            width: 76px;
+            height: 36px;
+            display: inline-block;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            font-size: 20px;
+            top: 50%;
+            left: 50%;
+            transform: translate(-50%, -50%);
+            span {
+              display: flex;
+              align-items: center;
+              justify-content: center;
+            }
+          }
+        }
+      }
+    }
+  }
+</style>

+ 127 - 0
src/components/hotspot/metas/metas-video.vue

@@ -0,0 +1,127 @@
+<!--  -->
+<template>
+    <!-- <div class="video-box" :style="metasHeight ? `height:${metasHeight}px;` : ''"> -->
+    <div class="video-box">
+        <div v-if="isEdit" class="del-btn" @click="delVideo()">
+            <ui-icon type="del"></ui-icon>
+        </div>
+        <ui-icon v-show="loading" class="loading-icon" type="_loading_"></ui-icon>
+        <video
+            @error="filesError"
+            v-show="!loading"
+            id="video"
+            v-for="(i, index) in videoSrc"
+            class="video-item"
+            x5-video-player-type="h5-page"
+            controlslist="nodownload"
+            disablepictureinpicture=""
+            webkit-playsinline=""
+            x-webkit-airplay=""
+            playsinline=""
+            :controls="controls"
+            autoplay
+            :src="common.changeUrl(i.src)"
+        ></video>
+    </div>
+</template>
+
+<script setup>
+import { reactive, toRefs, onBeforeMount, onMounted, nextTick, ref, computed, defineProps } from 'vue'
+import { useStore } from 'vuex'
+import { custom } from '../constant.js'
+import { getApp, useApp } from '@/app'
+import common from '@/utils/common'
+import { Dialog } from '@/global_components'
+const videoNum = ref(0)
+const store = useStore()
+const type = ref('video')
+const hotData = computed(() => store.getters['tag/hotData'])
+const props = defineProps({
+    controls: {
+        type: Boolean,
+        default: true,
+    },
+    metasHeight: {
+        type: Number,
+        default: null,
+    },
+})
+const loading = ref(true)
+
+const isEdit = computed(() => store.getters['tag/isEdit'])
+const videoSrc = computed(() => {
+    return hotData.value.media.video
+})
+const delVideo = () => {
+    store.commit('tag/delMetas', { type: type.value, index: videoNum.value })
+}
+
+const initVideo = file => {
+    store.commit('tag/setVideo', file)
+}
+const filesError = file => {
+    loading.value = false
+    // Dialog.toast({
+    //     content: '视频文件加载失败',
+    //     type: 'warn',
+    // })
+}
+
+onMounted(() => {
+    nextTick(() => {
+        let myVideo = document.getElementById('video')
+        myVideo.oncanplay = function () {
+            loading.value = false
+        }
+    })
+})
+</script>
+<style lang="scss" scoped>
+.del-btn {
+    width: 24px;
+    height: 24px;
+    background: rgba(0, 0, 0, 0.3);
+    border-radius: 50%;
+    position: absolute;
+    cursor: pointer;
+    top: 10px;
+    right: 10px;
+    z-index: 10;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+.video-box {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    position: absolute;
+    border-radius: 4px;
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    top: 0;
+    left: 0;
+    z-index: 10;
+    .loading-icon {
+        color: var(--editor-main-color);
+        animation: rotate 2s infinite linear;
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        font-size: 30px;
+    }
+    @keyframes rotate {
+        0% {
+            transform: translate(-50%, -50%) rotate(0deg);
+        }
+        100% {
+            transform: translate(-50%, -50%) rotate(360deg);
+        }
+    }
+    .video-item {
+        width: 100%;
+        height: 100%;
+        object-fit: contain;
+    }
+}
+</style>

+ 161 - 0
src/components/hotspot/metas/metas-web.vue

@@ -0,0 +1,161 @@
+<!--  -->
+<template>
+    <div class="web-box" :style="metasHeight ? `height:${metasHeight}px;` : ''">
+        <div class="show-tips" v-if="link.length == 0">
+            <span>{{ $t('tag.toolbox.webShow') }}</span>
+        </div>
+        <div class="iframe-box" v-if="link.length > 0">
+            <div v-if="isEdit" class="del-btn" @click="delWeb()">
+                <ui-icon type="del"></ui-icon>
+            </div>
+            <iframe v-for="(i, index) in link" :src="i.src" frameborder="0"></iframe>
+        </div>
+        <div v-if="isEdit" class="input-web" :class="{ disabled: link.length > 0 }">
+            <input type="text" v-model="inputValue" placeholder="https://" autocomplete="off" />
+            <div class="ok-web" v-if="link.length <= 0" @click="confirmWeb">
+                <ui-icon type="checkbox1"></ui-icon>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { reactive, toRefs, onBeforeMount, onMounted, ref, computed, defineProps } from 'vue'
+import { useStore } from 'vuex'
+import { Cropper, Loading, Dialog } from '@/global_components'
+import { custom } from '../constant.js'
+const store = useStore()
+const linkNum = ref(0)
+const hotData = computed(() => store.getters['tag/hotData'])
+const link = computed(() => {
+    return hotData.value.media.link
+})
+const props = defineProps({
+    metasHeight: {
+        type: Number,
+        default: null,
+    },
+})
+const inputLink = computed(() => store.getters['tag/inputLink'])
+const isEdit = computed(() => store.getters['tag/isEdit'])
+const inputValue = computed({
+    get() {
+        if (inputLink.value && inputLink.value != void 0) {
+            return inputLink.value
+        }
+        return inputLink.value || ''
+    },
+    set(value) {
+        store.commit('tag/setLink', value)
+    },
+})
+const type = ref('link')
+const showIframe = ref(false)
+
+const delWeb = () => {
+    store.commit('tag/delMetas', { type: type.value, index: linkNum.value })
+    showIframe.value = false
+    inputValue.value = null
+}
+const confirmWeb = () => {
+    if (inputValue.value.indexOf('http' || 'https') == -1) {
+        inputValue.value = 'https://' + inputValue.value
+    }
+    console.log(inputValue.value)
+    store.commit('tag/setWeb', inputValue.value)
+    showIframe.value = false
+    showIframe.value = true
+}
+</script>
+<style lang="scss" scoped>
+.del-btn {
+    width: 24px;
+    height: 24px;
+    background: rgba(0, 0, 0, 0.3);
+    border-radius: 50%;
+    position: absolute;
+    cursor: pointer;
+    top: 10px;
+    right: 10px;
+    z-index: 10;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+.web-box {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    position: absolute;
+    border-radius: 4px;
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    background: rgba(255, 255, 255, 0.1);
+    top: 0;
+    left: 0;
+    z-index: 10;
+    .show-tips {
+        width: 100%;
+        height: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        span {
+            color: rgba(255, 255, 255, 0.3);
+            font-size: 16px;
+            font-weight: bold;
+        }
+    }
+    .iframe-box {
+        width: 100%;
+        height: 100%;
+        position: absolute;
+        top: 0;
+        left: 0;
+        iframe {
+            width: 100%;
+            height: 100%;
+        }
+    }
+    .input-web {
+        height: 32px;
+        background: linear-gradient(180deg, rgba(0, 0, 0, 0.25) 0%, #000000 200%) !important;
+        border-radius: 0px 0px 4px 4px;
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        width: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding: 0 10px 0 0;
+        &.disabled {
+            background: linear-gradient(180deg, rgba(0, 0, 0, 0.5) 0%, #000000 200%) !important;
+            opacity: 0.8 !important;
+        }
+        input {
+            background: none;
+            border: none;
+            padding: 0 0 0 10px;
+            width: 94%;
+            box-sizing: border-box;
+            &:focus {
+                border: none;
+            }
+            &::placeholder {
+                color: rgba(255, 255, 255, 0.6);
+            }
+        }
+        .ok-web {
+            width: 16px;
+            height: 16px;
+            border-radius: 50%;
+            background: rgba(255, 255, 255, 0.6);
+            color: rgba(0, 0, 0, 0.6);
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            cursor: pointer;
+        }
+    }
+}
+</style>

+ 145 - 0
src/components/hotspot/metas/scale/index.js

@@ -0,0 +1,145 @@
+import Hammer from 'hammerjs' // 引用hammerjs
+// 定义缩放方法,接收一个element参数:使用export暴露该方法
+export function zoomElement(el) {
+    function point2D(x, y) {
+        return { x: x, y: y }
+    }
+    // 判断 正数,负数,不是数字
+    function checkNumType(num) {
+        var reg = new RegExp('^-?[0-9]*.?[0-9]*$')
+        if (reg.test(num)) {
+            // 用于检测一个字符串是否匹配某个模式
+            var absVal = Math.abs(num) // 如果参数是非负数,则返回该参数;如果参数是负数,则返回该参数的相反数。
+            return num == absVal ? true : false
+        } else {
+            console.log('this is not number')
+        }
+    }
+    function exChangeNum(num, reNum) {
+        let flag = checkNumType(num)
+        let reFlag = checkNumType(reNum)
+        let realNum = 0
+        if (!flag && reFlag) {
+            realNum = Number('-' + reNum)
+        } else {
+            realNum = Number(reNum)
+        }
+        return realNum
+    }
+    var reqAnimationFrame = (function () {
+        return (
+            window[Hammer.prefixed(window, 'requestAnimationFrame')] ||
+            function (callback) {
+                window.setTimeout(callback, 1000 / 60)
+            }
+        )
+    })()
+    var ticking = false
+    var tMatrix = [1, 0, 0, 1, 0, 0] //x缩放,无,无,y缩放,x平移,y平移
+    var initScale = 1 //初始化scale
+    el.addEventListener('touchmove', function (e) {
+        e.preventDefault()
+    })
+    var mc = new Hammer.Manager(el)
+    var nowScale = 0
+    var poscenter = point2D(0, 0) // 缓存双指的中心坐标
+    var duration = '' // 设置过渡效果,用于双击缩放效果
+    var lastTranslate = point2D(0, 0) // 记录上次的偏移值
+    var lastcenter = point2D(el.offsetWidth / 2, el.offsetHeight / 2) // 图像的中心点,用于对比双指中心点
+    var center = lastcenter // 初始化为图片中心点
+    // 添加缩放事件
+    mc.add(new Hammer.Pan({ threshold: 0, pointers: 1 }))
+    mc.add(new Hammer.Pinch({ threshold: 0 }))
+    mc.add(new Hammer.Tap({ event: 'doubletap', taps: 2 }))
+    mc.on('pinchstart', onPinchStart) // 双指缩放
+    mc.on('pinchmove', onPinch) // 双指移动
+    mc.on('panmove', onPan)
+    mc.on('panstart', onPanStart)
+    // 缩放开始
+    function onPinchStart(ev) {
+        duration = ''
+        lastTranslate = point2D(tMatrix[4], tMatrix[5]) //记录上一次的偏移值 0 0
+        initScale = tMatrix[0] || 1
+        // 手势中心点
+        poscenter = point2D(ev.center.x, ev.center.y)
+        // 图像中心点  = 初始化图像中心点  + 上一次偏移量的中心点
+        lastcenter = point2D(center.x + lastTranslate.x, center.y + lastTranslate.y) //重新计算放大后的中心坐标
+        // 手势中心点 = 缩放中心点 - 图像中心点
+        poscenter = point2D(ev.center.x - lastcenter.x, ev.center.y - lastcenter.y)
+        requestElementUpdate('onpinchStart')
+    }
+    // 缩放途中
+    function onPinch(ev) {
+        // 缩放倍数 这里的缩放倍数
+        nowScale = initScale * ev.scale
+        // 如果倍数小于1 则等于1
+        if (nowScale < 1) {
+            nowScale = 1
+        }
+        // 缩放倍数
+        tMatrix[0] = tMatrix[3] = nowScale
+        let x = Number((1 - ev.scale) * poscenter.x + lastTranslate.x)
+        let y = Number((1 - ev.scale) * poscenter.y + lastTranslate.y)
+        let tempPosX = el.getBoundingClientRect().width / 2 - point2D(el.offsetWidth / 2, el.offsetHeight / 2).x
+        let tempPosY = el.getBoundingClientRect().height / 2 - point2D(el.offsetWidth / 2, el.offsetHeight / 2).y
+        if (Math.abs(x) > Math.abs(tempPosX)) {
+            x = exChangeNum(x, tempPosX)
+        }
+
+        if (Math.abs(y) > Math.abs(tempPosY)) {
+            y = exChangeNum(y, tempPosY)
+        }
+        tMatrix[4] = x
+        tMatrix[5] = y
+        requestElementUpdate('onpinch')
+    }
+    // 开始拖动
+    function onPanStart() {
+        lastTranslate = point2D(tMatrix[4], tMatrix[5]) // 缓存上一次的偏移值
+    }
+    // 拖动过程
+    function onPan(ev) {
+        tMatrix[0] = tMatrix[3] = nowScale || initScale
+        // 拖动的动画 1.6
+        duration = '1.6'
+        let x = Number(lastTranslate.x + ev.deltaX)
+        let y = Number(lastTranslate.y + ev.deltaY)
+        let tempPosX = el.getBoundingClientRect().width / 2 - point2D(el.offsetWidth / 2, el.offsetHeight / 2).x
+        let tempPosY = el.getBoundingClientRect().height / 2 - point2D(el.offsetWidth / 2, el.offsetHeight / 2).y
+        if (Math.abs(x) > Math.abs(tempPosX)) {
+            x = exChangeNum(x, tempPosX)
+        }
+        if (Math.abs(y) > Math.abs(tempPosY)) {
+            y = exChangeNum(y, tempPosY)
+        }
+        tMatrix[4] = x
+        tMatrix[5] = y
+        requestElementUpdate('onpan')
+    }
+
+    // 每次都会·更新 因为是在移动端 所以都采用rem 否则可以直接用matrix
+    function updateElementTransform() {
+        el.style.transition = duration
+        let x = tMatrix[4] + 'px'
+        let y = tMatrix[5] + 'px'
+        el.style.transform = 'translate(' + x + ',' + y + ') ' + 'scale(' + tMatrix[0] + ',' + tMatrix[3] + ')'
+        el.style.WebkitTransform = 'translate(' + x + ',' + y + ') ' + 'scale(' + tMatrix[0] + ',' + tMatrix[3] + ')'
+        el.style.msTransform = 'translate(' + x + ',' + y + ') ' + 'scale(' + tMatrix[0] + ',' + tMatrix[3] + ')'
+        // var tmp = tMatrix.join(',')
+        // el.style.transform = 'matrix(' + tmp + ')'
+        // el.style.WebkitTransform = 'matrix(' + tmp + ')'
+        // el.style.msTransform = 'matrix(' + tmp + ')'
+        ticking = false
+    }
+    function requestElementUpdate() {
+        if (!ticking) {
+            reqAnimationFrame(updateElementTransform)
+            ticking = true
+        }
+    }
+    /**
+  初始化设置
+  */
+
+    requestElementUpdate()
+}

+ 283 - 0
src/components/hotspot/show-tag.vue

@@ -0,0 +1,283 @@
+<!--  -->
+<template>
+  <div class="show-tag" :id="`tagBox_${hotData.sid}`">
+    <div class="tag-title">
+      <h2>
+        {{ hotData.title }}
+        <ui-audio
+          v-if="hotData.type == 'audio' && audioInfo.length > 0"
+          class="audio"
+          ref="audio"
+          :src="common.changeUrl(audioInfo[0].src)"
+        />
+      </h2>
+    </div>
+    <div class="desc" v-if="hotData.content != ''">
+      <div class="text" v-html="hotData.content"></div>
+    </div>
+    <div
+      class="tag-metas"
+      @click.stop="open"
+      :class="{ mask: hotData.type == 'link', nocursor: hotData.type == 'video' }"
+      v-if="hotData.media[hotData.type].length > 0 && hotData.type != 'audio'"
+    >
+      <metasImage v-if="hotData.type == 'image'" />
+      <metasVideo v-if="hotData.type == 'video'" />
+      <metasWeb v-if="hotData.type == 'link'" />
+    </div>
+    <!-- <div class="edit-btn" v-if="routerName && routerName == 'tag' && !editModule">
+            <span @click="edit()"><ui-icon type="edit"></ui-icon> 修改</span>
+        </div> -->
+  </div>
+</template>
+
+<script setup>
+  import {
+    reactive,
+    toRefs,
+    onBeforeMount,
+    onMounted,
+    ref,
+    watchEffect,
+    computed,
+    watch,
+    defineEmits,
+  } from 'vue';
+  import metasImage from './metas/metas-image';
+  import metasVideo from './metas/metas-video';
+  import metasAudio from './metas/metas-audio';
+  import metasWeb from './metas/metas-web';
+  import common from '@/utils/common';
+  import { useStore } from 'vuex';
+  import { useMusicPlayer } from '@/utils/sound';
+  const musicPlayer = useMusicPlayer();
+  const editModule = computed(() => store.getters['editModule']);
+  const store = useStore();
+  const emit = defineEmits(['open']);
+  const hotData = computed(() => {
+    let data = store.getters['tag/hotData'];
+    if (data.type == 'audio' || data.type == 'video') {
+      // musicPlayer.pause(true)
+      // console.log('1qwdq');
+      window.parent.postMessage(
+        {
+          source: 'qjkankan',
+          event: 'toggleBgmStatus',
+          params: {
+            status: false,
+          },
+        },
+        '*',
+      );
+    }
+    return data;
+  });
+
+  const audioInfo = computed(() => {
+    return hotData.value.media.audio;
+  });
+  const router = computed(() => store.getters['router']);
+  const routerName = computed(() => {
+    let name = router.value.name || null;
+    return name;
+  });
+  const audio = ref(null);
+  watchEffect(() => {
+    if (audio.value) {
+      audio.value.play();
+    }
+  });
+  const open = () => {
+    if (hotData.value.type != 'video') {
+      emit('open');
+      window.parent.postMessage(
+        {
+          source: 'qjkankan',
+          event: 'toggleFdkkHotspot',
+          params: {
+            status: 'open',
+          },
+        },
+        '*',
+      );
+      console.log(111111);
+    }
+  };
+  // const edit = () => {
+  //     store.commit('tag/edit')
+  //     store.commit('tag/gotoTag', hotData.value)
+  // }
+  onMounted(() => {});
+</script>
+<style lang="scss" scoped>
+  .show-tag {
+    pointer-events: auto;
+    background: rgba(27, 27, 28, 0.8);
+    border-radius: 4px;
+    // border: 1px solid #000000;
+    // backdrop-filter: blur(4px);
+    min-width: 400px;
+    // min-height: 100px;
+    padding: 30px 20px;
+
+    .edit-btn {
+      margin-top: 20px;
+      text-align: right;
+
+      span {
+        font-size: 14px;
+        color: rgba(255, 255, 255, 0.6);
+        cursor: pointer;
+
+        &:hover {
+          color: #fff;
+        }
+      }
+    }
+
+    .tag-metas {
+      width: 100%;
+      height: 225px;
+      background: rgba(255, 255, 255, 0.1);
+      border-radius: 4px;
+      overflow: hidden;
+      position: relative;
+      cursor: -webkit-zoom-in;
+      margin-top: 20px;
+
+      &.nocursor {
+        cursor: auto;
+      }
+
+      &.mask {
+        &::after {
+          content: '';
+          position: absolute;
+          top: 0;
+          left: 0;
+          width: 100%;
+          height: 100%;
+          z-index: 100;
+        }
+      }
+    }
+
+    .tag-title {
+      word-break: break-all;
+
+      h2 {
+        font-size: 20px;
+        // margin-bottom: 10px;
+        line-height: 30px;
+        color: #ffffff;
+        position: relative;
+
+        .ui-audio {
+          float: right;
+
+          &.audio {
+            display: inline-block;
+            cursor: pointer;
+          }
+        }
+      }
+    }
+
+    .desc {
+      margin-top: 10px;
+
+      .text {
+        font-size: 14px;
+        color: #999999;
+        line-height: 20px;
+        text-align: justify;
+        word-break: break-all;
+      }
+    }
+  }
+
+  [is-mobile] {
+    .show-tag {
+      pointer-events: auto;
+      background: rgba(27, 27, 28, 0.8);
+      border-radius: 0.0533rem;
+      // border: 1px solid #000000;
+      // backdrop-filter: blur(0.0533rem);
+      min-width: 7.4667rem;
+      // min-height: 4rem;
+      padding: 0.4rem 0.2667rem;
+
+      .edit-btn {
+        margin-top: 0.2667rem;
+        text-align: right;
+
+        span {
+          font-size: 0.1867rem;
+          color: rgba(255, 255, 255, 0.6);
+          cursor: pointer;
+
+          &:hover {
+            color: #fff;
+          }
+        }
+      }
+
+      .tag-metas {
+        width: 100%;
+        height: 4.2667rem;
+        background: rgba(255, 255, 255, 0.1);
+        border-radius: 0.0533rem;
+        overflow: hidden;
+        position: relative;
+        cursor: -webkit-zoom-in;
+        margin-top: 0.4rem;
+
+        &.mask {
+          &::after {
+            content: '';
+            position: absolute;
+            top: 0;
+            left: 0;
+            width: 100%;
+            height: 100%;
+            z-index: 100;
+          }
+        }
+      }
+
+      .tag-title {
+        h2 {
+          font-size: 0.5333rem;
+          line-height: 0.8rem;
+          color: #ffffff;
+          position: relative;
+
+          .ui-audio {
+            float: right;
+
+            &.audio {
+              display: inline-block;
+              cursor: pointer;
+            }
+          }
+        }
+      }
+
+      .desc {
+        margin-bottom: 0.2933rem;
+
+        .text {
+          font-size: 0.3733rem;
+          color: #999999;
+          line-height: 0.2533rem;
+          text-align: justify;
+          line-height: 0.5333rem;
+
+          p {
+            line-height: 0.5333rem;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 166 - 0
src/components/hotspot/tag-view.vue

@@ -0,0 +1,166 @@
+<!--  -->
+<template>
+  <teleport to="body">
+    <div
+      class="tag-layer"
+      @click.stop=""
+      :class="{ mobile: isMobile, full: hotData.type == 'image' }"
+    >
+      <div class="tag-info" @click.stop="" id="tag-info">
+        <ui-icon class="close-btn" @click.stop="close" type="close" />
+        <div
+          class="tag-metas"
+          v-if="hotData.media[hotData.type].length > 0 && hotData.type != 'audio'"
+        >
+          <metasImage
+            @close="close"
+            :scale="true"
+            :viewer="true"
+            v-if="hotData.type == 'image' && !isMobile"
+          />
+          <imageView v-if="hotData.type == 'image' && isMobile" />
+          <metasWeb v-if="hotData.type == 'link'" />
+        </div>
+      </div>
+    </div>
+  </teleport>
+</template>
+
+<script setup lang="ts">
+  import { defineEmits, onMounted, ref, watchEffect, computed, nextTick } from 'vue';
+  // import { Dialog } from '@/global_components';
+  import metasImage from './metas/metas-image';
+  import imageView from './metas/image-view';
+
+  import metasWeb from './metas/metas-web';
+  import common from '@/utils/common';
+  import { useStore } from 'vuex';
+  import { getApp, useApp } from '@/app';
+  const isMobile = ref(false);
+  const metasHeight = ref(0);
+  onMounted(async () => {
+    const app = await useApp();
+    isMobile.value = app.config.mobile;
+    nextTick(() => {
+      let Layer = document.getElementById('tag-info');
+      let layerHeight = Layer.getBoundingClientRect().height;
+      metasHeight.value = layerHeight * 0.85;
+    });
+  });
+  import { useMusicPlayer } from '@/utils/sound';
+  const musicPlayer = useMusicPlayer();
+  const store = useStore();
+  const emit = defineEmits(['close']);
+  const hotData = computed(() => {
+    let data = store.getters['tag/hotData'];
+    if ((data && data.type == 'audio') || (data && data.type == 'video')) {
+      // musicPlayer.pause(true)
+    }
+    return store.getters['tag/hotData'];
+  });
+
+  const audioInfo = computed(() => {
+    return hotData.value.media.audio;
+  });
+  const audio = ref(null);
+  watchEffect(() => {
+    if (audio.value) {
+      audio.value.play();
+    }
+  });
+
+  const close = () => {
+    emit('close');
+  };
+  onMounted(() => {});
+</script>
+<style lang="scss">
+  .tag-layer {
+    width: 100vw;
+    height: 100vh;
+    z-index: 10000;
+    top: 0;
+    position: fixed;
+    left: 0;
+    // padding: calc(var(--editor-head-height) + 20px) calc(var(--editor-toolbox-width) + 20px) 60px calc(var(--editor-menu-width) + 20px);
+    background-color: rgba(0, 0, 0, 0.7);
+
+    .tag-info {
+      color: #fff;
+      width: 100%;
+      height: 85%;
+      position: absolute;
+      top: 7.5%;
+      left: 0;
+      .close-btn {
+        position: fixed;
+        right: 36px;
+        top: 36px;
+        font-size: 18px;
+        cursor: pointer;
+        z-index: 100;
+      }
+      .tag-metas {
+        width: 100%;
+        height: 100%;
+        position: relative;
+
+        .pic-box {
+          width: 100%;
+          height: 100%;
+          border: none;
+
+          .image-list {
+            .image-item {
+              background-size: contain;
+            }
+          }
+        }
+        .video-box {
+          height: auto;
+          border: none;
+          video {
+            width: 100%;
+            height: auto;
+            object-fit: contain;
+          }
+        }
+        .web-box {
+          // height: 500px;
+          width: 91%;
+          height: 100%;
+          border: none;
+          left: 50%;
+          transform: translateX(-50%);
+        }
+      }
+    }
+  }
+  [is-mobile] {
+    .tag-layer {
+      .tag-info {
+        .close-btn {
+          position: absolute;
+          right: 20px;
+          top: -30px;
+          font-size: 18px;
+          cursor: pointer;
+        }
+      }
+      &.full {
+        background-color: #141414;
+        .tag-info {
+          height: 100%;
+          top: 0;
+          .close-btn {
+            position: absolute;
+            right: 20px;
+            top: 20px;
+            font-size: 18px;
+            cursor: pointer;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 31 - 1
src/store/modules/scene.ts

@@ -10,8 +10,33 @@ export interface FloorsType {
   tagging?: any[];
   ['vertex-xy']?: any[];
 }
+interface sourceType {
+  src: string;
+}
+interface vector3Type {
+  x: number;
+  y: number;
+  z: number;
+}
+interface tagType {
+  sid: string;
+  x: number;
+  y: number;
+  type: string;
+  visible: boolean;
+  content: string;
+  createTime: number;
+  floorIndex: number;
+  icon: string;
+  media: {
+    image?: sourceType[];
+  };
+  panoId: number;
+  position: vector3Type;
+  visiblePanos: any[];
+}
 interface SceneState {
-  tags: any[];
+  tags: tagType[];
   floors: FloorsType[];
   metadata: KankanMetaDataType;
 }
@@ -62,5 +87,10 @@ export const useSceneStore = defineStore({
         }) as FloorsType[];
       }
     },
+    loadTags(tags: tagType[]): void {
+      if (tags?.length) {
+        this.tags = tags;
+      }
+    },
   },
 });