浏览代码

init commit

tremble 3 年之前
当前提交
cab0ac2ba0
共有 100 个文件被更改,包括 26315 次插入0 次删除
  1. 14 0
      .env
  2. 13 0
      .env.development
  3. 23 0
      .gitignore
  4. 24 0
      README.md
  5. 3 0
      babel.config.js
  6. 1 0
      jsconfig.json
  7. 17474 0
      package-lock.json
  8. 24 0
      package.json
  9. 45 0
      public/index.html
  10. 二进制
      public/viewer/favicon.ico
  11. 11 0
      public/viewer/static/lib/animate/animate.min.css
  12. 109 0
      public/viewer/static/lib/flexible.min.js
  13. 7 0
      public/viewer/static/lib/flv.min.js
  14. 1 0
      public/viewer/static/lib/jweixin-1.0.0.js
  15. 22 0
      public/viewer/static/lib/mobile-detect.js
  16. 13 0
      public/viewer/static/lib/swiper/swiper-bundle.min.css
  17. 14 0
      public/viewer/static/lib/swiper/swiper-bundle.min.js
  18. 13 0
      public/viewer/static/lib/swiper/swiper.min.css
  19. 14 0
      public/viewer/static/lib/swiper/swiper.min.js
  20. 1 0
      public/viewer/static/lib/swiper/swiper.min.js.map
  21. 10 0
      public/viewer/static/lib/vconsole.js
  22. 二进制
      public/viewer/static/music/01.mp3
  23. 二进制
      public/viewer/static/music/02.mp3
  24. 二进制
      public/viewer/static/music/03.mp3
  25. 二进制
      public/viewer/static/music/04.mp3
  26. 二进制
      public/viewer/static/music/05.mp3
  27. 二进制
      public/viewer/static/music/06.mp3
  28. 二进制
      public/viewer/static/music/07.mp3
  29. 二进制
      public/viewer/static/music/08.mp3
  30. 19 0
      src/apis/index.js
  31. 32 0
      src/app.js
  32. 491 0
      src/app.vue
  33. 二进制
      src/assets/images/floorlogo/0.png
  34. 二进制
      src/assets/images/floorlogo/1.png
  35. 二进制
      src/assets/images/floorlogo/2.png
  36. 二进制
      src/assets/images/floorlogo/en/0.png
  37. 二进制
      src/assets/images/floorlogo/en/1.png
  38. 二进制
      src/assets/images/floorlogo/en/2.png
  39. 二进制
      src/assets/images/floorlogo/enter-style-default.png
  40. 二进制
      src/assets/images/floorlogo/enter-style-down.png
  41. 二进制
      src/assets/images/floorlogo/enter-style-up.png
  42. 二进制
      src/assets/images/floorlogo/icon-corner-24.png
  43. 二进制
      src/assets/images/floorlogo/icon-corner.png
  44. 17 0
      src/assets/images/icon/1.svg
  45. 17 0
      src/assets/images/icon/2.svg
  46. 17 0
      src/assets/images/icon/3.svg
  47. 二进制
      src/assets/images/icon/bg.png
  48. 二进制
      src/assets/images/icon/gifts_off.png
  49. 二进制
      src/assets/images/icon/gifts_on.png
  50. 二进制
      src/assets/images/icon/logo.png
  51. 二进制
      src/assets/images/icon/top5.png
  52. 二进制
      src/assets/images/loading.jpg
  53. 二进制
      src/assets/images/tag/style-tag.png
  54. 332 0
      src/assets/theme.editor.scss
  55. 80 0
      src/components/Controls/BottomControl.vue
  56. 360 0
      src/components/Controls/Control.Mobile.vue
  57. 284 0
      src/components/Controls/FloorSwitch.vue
  58. 461 0
      src/components/Controls/LeftButtons.vue
  59. 456 0
      src/components/Controls/Panel/Guide.vue
  60. 390 0
      src/components/Controls/Panel/Main.vue
  61. 106 0
      src/components/Controls/RightButtons.vue
  62. 77 0
      src/components/Header/index.vue
  63. 118 0
      src/components/Header/state.js
  64. 767 0
      src/components/Information/View.Mobile.vue
  65. 137 0
      src/components/Information/View.Pc.vue
  66. 64 0
      src/components/Information/index.vue
  67. 37 0
      src/components/Tags/constant.js
  68. 200 0
      src/components/Tags/goods-list.vue
  69. 203 0
      src/components/Tags/index.vue
  70. 130 0
      src/components/Tags/link-manage.vue
  71. 104 0
      src/components/Tags/metas-upload.vue
  72. 67 0
      src/components/Tags/metas/metas-audio.vue
  73. 386 0
      src/components/Tags/metas/metas-image.vue
  74. 127 0
      src/components/Tags/metas/metas-video.vue
  75. 161 0
      src/components/Tags/metas/metas-web.vue
  76. 222 0
      src/components/Tags/show-tag.vue
  77. 332 0
      src/components/Tags/style-icon.vue
  78. 230 0
      src/components/Tags/tag-info.vue
  79. 141 0
      src/components/Tags/tag-view.vue
  80. 76 0
      src/components/Tags/treasure.vue
  81. 108 0
      src/components/Tags/waterfall.vue
  82. 52 0
      src/components/dialog/Alert.vue
  83. 58 0
      src/components/dialog/Confirm.vue
  84. 17 0
      src/components/dialog/Dialog-content.vue
  85. 27 0
      src/components/dialog/Dialog.vue
  86. 70 0
      src/components/dialog/Toast.vue
  87. 63 0
      src/components/dialog/Window.vue
  88. 93 0
      src/components/dialog/index.js
  89. 0 0
      src/components/layout/index.vue
  90. 302 0
      src/components/layout/mobile/mobile-guides.vue
  91. 35 0
      src/components/loading/Loading.vue
  92. 36 0
      src/components/loading/index.js
  93. 542 0
      src/components/shared/Loading.vue
  94. 79 0
      src/components/shared/Password.vue
  95. 二进制
      src/global_components/assets/img/icons/toast-error.png
  96. 二进制
      src/global_components/assets/img/icons/toast-success.png
  97. 二进制
      src/global_components/assets/img/icons/toast-warn.png
  98. 63 0
      src/global_components/assets/scss/_base-vars.scss
  99. 293 0
      src/global_components/assets/scss/_base.scss
  100. 0 0
      src/global_components/assets/scss/_components.scss

+ 14 - 0
.env

@@ -0,0 +1,14 @@
+
+# 场景资源地址
+VUE_APP_RESOURCE_URL=https://eurs3.4dkankan.com/
+# 静态资源地址
+VUE_APP_CDN_URL=https://4dkk.4dage.com/v4/www/
+# sdk文件地址
+VUE_APP_SDK_DIR=https://eurs3.4dkankan.com/v4/cdfg/sdk
+
+
+# 静态资源目录
+VUE_APP_STATIC_DIR=viewer
+
+# 云存储环境
+VUE_APP_REGION_URL=aws

+ 13 - 0
.env.development

@@ -0,0 +1,13 @@
+
+# 场景资源地址
+VUE_APP_RESOURCE_URL=https://4dkk.4dage.com/
+# 静态资源地址
+VUE_APP_CDN_URL=https://4dkk.4dage.com/v4/www/
+# sdk文件地址
+VUE_APP_SDK_DIR=http://192.168.0.45:3099/dist/sdk
+# 静态资源目录
+VUE_APP_STATIC_DIR=viewer
+
+
+# 云存储环境
+VUE_APP_REGION_URL=

+ 23 - 0
.gitignore

@@ -0,0 +1,23 @@
+.DS_Store
+node_modules
+/dist
+
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 24 - 0
README.md

@@ -0,0 +1,24 @@
+# cdfg_vue3
+
+## Project setup
+```
+npm install
+```
+
+### Compiles and hot-reloads for development
+```
+npm run serve
+```
+
+### Compiles and minifies for production
+```
+npm run build
+```
+
+### Lints and fixes files
+```
+npm run lint
+```
+
+### Customize configuration
+See [Configuration Reference](https://cli.vuejs.org/config/).

+ 3 - 0
babel.config.js

@@ -0,0 +1,3 @@
+module.exports = {
+  presets: ["@vue/cli-plugin-babel/preset"],
+};

+ 1 - 0
jsconfig.json

@@ -0,0 +1 @@
+{}

文件差异内容过多而无法显示
+ 17474 - 0
package-lock.json


+ 24 - 0
package.json

@@ -0,0 +1,24 @@
+{
+  "name": "cdfg_vue3",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint"
+  },
+  "dependencies": {
+    "core-js": "^3.8.3",
+    "vue": "^3.2.36",
+    "axios": "^0.21.1",
+    "clipboard": "^2.0.8",
+    "vuex": "^4.0.2"
+  },
+  "devDependencies": {
+    "@vue/cli-plugin-babel": "~5.0.0",
+    "@vue/cli-service": "~5.0.0",
+    "@vue/compiler-sfc": "^3.0.0",
+    "sass": "^1.26.5",
+    "sass-loader": "^8.0.2"
+  }
+}

+ 45 - 0
public/index.html

@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html lang="">
+    <head>
+        <meta charset="utf-8" />
+        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
+        <link rel="icon" href="<%= BASE_URL %><%= VUE_APP_STATIC_DIR %>/favicon.ico" />
+        <link rel="stylesheet" href="//at.alicdn.com/t/font_2596172_ejg30arrayu.css" />
+        <link rel="stylesheet" href="//at.alicdn.com/t/font_3423899_m7c62apktz.css" />
+        <link rel="stylesheet" href="<%= BASE_URL %><%= VUE_APP_STATIC_DIR %>/static/lib/animate/animate.min.css" />
+        <link rel="stylesheet" href="<%= BASE_URL %><%= VUE_APP_STATIC_DIR %>/static/lib/swiper/swiper-bundle.min.css" />
+
+        <script src="<%= BASE_URL %><%= VUE_APP_STATIC_DIR %>/static/lib/mobile-detect.js"></script>
+        <script src="<%= BASE_URL %><%= VUE_APP_STATIC_DIR %>/static/lib/flexible.min.js"></script>
+        <title>中免VR商城</title>
+    </head>
+
+    <body>
+        <noscript>
+            <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+        </noscript>
+        <div id="app"></div>
+        <script src="<%= BASE_URL %><%= VUE_APP_STATIC_DIR %>/static/lib/flv.min.js"></script>
+        <script src="<%= BASE_URL %><%= VUE_APP_STATIC_DIR %>/static/lib/vconsole.js"></script>
+        <script src="<%= BASE_URL %><%= VUE_APP_STATIC_DIR %>/static/lib/swiper/swiper-bundle.min.js"></script>
+
+
+        <script src="<%= BASE_URL %><%= VUE_APP_STATIC_DIR %>/static/lib/jweixin-1.0.0.js"></script>
+        
+        <script src="<%= VUE_APP_SDK_DIR %>/kankan-sdk-deps.js?v=4.0.0-alpha.45"></script>
+        <script src="<%= VUE_APP_SDK_DIR %>/kankan-sdk.js?v=4.0.0-alpha.45"></script>
+
+        <!-- <script src="https://4dkk.4dage.com/v4/www/sdk/kankan-sdk-deps.js?v=4.0.0-alpha.44"></script>
+        <script src="https://4dkk.4dage.com/v4/www/sdk/kankan-sdk.js?v=4.0.0-alpha.44"></script> -->
+
+        <!-- <script src="https://eurs3.4dkankan.com/v4/cdfg/sdk/kankan-sdk-deps.js?v=4.0.0-alpha.45"></script>
+        <script src="https://eurs3.4dkankan.com/v4/cdfg/sdk/kankan-sdk.js?v=4.0.0-alpha.45"></script> -->
+        <!-- built files will be auto injected -->
+        <script>
+            if ((query = window.location.href.indexOf('vlog') != -1)) {
+                var vConsole = new window.VConsole()
+            }
+        </script>
+    </body>
+</html>

二进制
public/viewer/favicon.ico


文件差异内容过多而无法显示
+ 11 - 0
public/viewer/static/lib/animate/animate.min.css


+ 109 - 0
public/viewer/static/lib/flexible.min.js

@@ -0,0 +1,109 @@
+(function(win, lib) {
+    var doc = win.document;
+    var docEl = doc.documentElement;
+    var metaEl = doc.querySelector('meta[name="viewport"]');
+    var flexibleEl = doc.querySelector('meta[name="flexible"]');
+    var dpr = 0;
+    var scale = 0;
+    var tid;
+    var flexible = lib.flexible || (lib.flexible = {});
+     
+    if (metaEl) {
+        console.warn('将根据已有的meta标签来设置缩放比例');
+        var match = metaEl.getAttribute('content').match(/initial\-scale=([\d\.]+)/);
+        if (match) {
+            scale = parseFloat(match[1]);
+            dpr = parseInt(1 / scale);
+        }
+    } else if (flexibleEl) {
+        var content = flexibleEl.getAttribute('content');
+        if (content) {
+            var initialDpr = content.match(/initial\-dpr=([\d\.]+)/);
+            var maximumDpr = content.match(/maximum\-dpr=([\d\.]+)/);
+            if (initialDpr) {
+                dpr = parseFloat(initialDpr[1]);
+                scale = parseFloat((1 / dpr).toFixed(2));   
+            }
+            if (maximumDpr) {
+                dpr = parseFloat(maximumDpr[1]);
+                scale = parseFloat((1 / dpr).toFixed(2));   
+            }
+        }
+    }
+    if (!dpr && !scale) {
+        var isAndroid = win.navigator.appVersion.match(/android/gi);
+        var isIPhone = win.navigator.appVersion.match(/iphone/gi);
+        var devicePixelRatio = win.devicePixelRatio;
+        if (isIPhone) {
+            // iOS下,对于2和3的屏,用2倍的方案,其余的用1倍方案
+            if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {               
+                dpr = 3;
+            } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
+                dpr = 2;
+            } else {
+                dpr = 1;
+            }
+        } else {
+            // 其他设备下,仍旧使用1倍的方案
+            dpr = 1;
+        }
+        scale = 1 / dpr;
+    }
+    docEl.setAttribute('data-dpr', dpr);
+    if (!metaEl) {
+        metaEl = doc.createElement('meta');
+        metaEl.setAttribute('name', 'viewport');
+        metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
+        if (docEl.firstElementChild) {
+            docEl.firstElementChild.appendChild(metaEl);
+        } else {
+            var wrap = doc.createElement('div');
+            wrap.appendChild(metaEl);
+            doc.write(wrap.innerHTML);
+        }
+    }
+    function refreshRem(){
+        var width = docEl.getBoundingClientRect().width;
+        if (width / dpr > 540) {
+            width = 540 * dpr;
+        }
+        var rem = width / 10;
+        docEl.style.fontSize = rem + 'px';
+        flexible.rem = win.rem = rem;
+    }
+    win.addEventListener('resize', function() {
+        clearTimeout(tid);
+        tid = setTimeout(refreshRem, 300);
+    }, false);
+    win.addEventListener('pageshow', function(e) {
+        if (e.persisted) {
+            clearTimeout(tid);
+            tid = setTimeout(refreshRem, 300);
+        }
+    }, false);
+    if (doc.readyState === 'complete') {
+        doc.body.style.fontSize = 12 * dpr + 'px';
+    } else {
+        doc.addEventListener('DOMContentLoaded', function(e) {
+            doc.body.style.fontSize = 12 * dpr + 'px';
+        }, false);
+    }
+     
+    refreshRem();
+    flexible.dpr = win.dpr = dpr;
+    flexible.refreshRem = refreshRem;
+    flexible.rem2px = function(d) {
+        var val = parseFloat(d) * this.rem;
+        if (typeof d === 'string' && d.match(/rem$/)) {
+            val += 'px';
+        }
+        return val;
+    }
+    flexible.px2rem = function(d) {
+        var val = parseFloat(d) / this.rem;
+        if (typeof d === 'string' && d.match(/px$/)) {
+            val += 'rem';
+        }
+        return val;
+    }
+})(window, window['lib'] || (window['lib'] = {}));

文件差异内容过多而无法显示
+ 7 - 0
public/viewer/static/lib/flv.min.js


文件差异内容过多而无法显示
+ 1 - 0
public/viewer/static/lib/jweixin-1.0.0.js


+ 22 - 0
public/viewer/static/lib/mobile-detect.js

@@ -0,0 +1,22 @@
+(function(win) {
+    var orgLink = win.location.href
+    var newLink = ''
+    if (orgLink.indexOf('&mobile=true') != -1) {
+        Object.defineProperty(navigator, 'userAgent', {
+            value: "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1",
+            writable: false
+        })
+    }
+    else if (/iPhone|iPad|Android/i.test(win.navigator.userAgent)) {
+        if (orgLink.indexOf('pc.html') !== -1) {
+            newLink = orgLink.replace('pc.html', 'mobile.html')
+        }
+    } else {
+        if (orgLink.indexOf('mobile.html') !== -1) {
+            newLink = orgLink.replace('mobile.html', 'pc.html')
+        }
+    }
+    if (newLink) {
+        win.location.href = newLink
+    }
+}(window))

文件差异内容过多而无法显示
+ 13 - 0
public/viewer/static/lib/swiper/swiper-bundle.min.css


文件差异内容过多而无法显示
+ 14 - 0
public/viewer/static/lib/swiper/swiper-bundle.min.js


文件差异内容过多而无法显示
+ 13 - 0
public/viewer/static/lib/swiper/swiper.min.css


文件差异内容过多而无法显示
+ 14 - 0
public/viewer/static/lib/swiper/swiper.min.js


文件差异内容过多而无法显示
+ 1 - 0
public/viewer/static/lib/swiper/swiper.min.js.map


文件差异内容过多而无法显示
+ 10 - 0
public/viewer/static/lib/vconsole.js


二进制
public/viewer/static/music/01.mp3


二进制
public/viewer/static/music/02.mp3


二进制
public/viewer/static/music/03.mp3


二进制
public/viewer/static/music/04.mp3


二进制
public/viewer/static/music/05.mp3


二进制
public/viewer/static/music/06.mp3


二进制
public/viewer/static/music/07.mp3


二进制
public/viewer/static/music/08.mp3


+ 19 - 0
src/apis/index.js

@@ -0,0 +1,19 @@
+import { http } from '@/utils/request'
+
+
+export const get_goods_list = data => {
+  return http.get('/back/product/list', data)
+}
+
+export const get_category_list = data => {
+  return http.get('/api/getShopCategory', data)
+}
+
+
+export const get_shop_list = data => {
+  return http.get('/api/getShopByCategory', data)
+}
+
+export const get_tags_list = data => {
+  return http.post('/service/scene/edit/tag/getHotJson', data)
+}

+ 32 - 0
src/app.js

@@ -0,0 +1,32 @@
+let _app
+let _num
+let deferred = KanKan.Deferred()
+
+export function createApp(opitons = {}) {
+    if (_app) {
+        return
+    }
+    opitons.region = process.env.VUE_APP_REGION_URL
+    opitons.resource = process.env.VUE_APP_RESOURCE_URL
+    _num = opitons.num
+    _app = new KanKan(opitons)
+    deferred.resolve(_app)
+    window.__sdk = _app
+    document.body.setAttribute('is-mobile', true)
+    return _app
+}
+
+export function useApp() {
+    if (_app) {
+        return Promise.resolve(_app)
+    }
+    return deferred
+}
+
+export function getApp() {
+    return _app
+}
+
+export function getNum() {
+    return _num
+}

+ 491 - 0
src/app.vue

@@ -0,0 +1,491 @@
+<template>
+  <LoadingLogo :thumb="true" />
+
+  <div class="ui-view-layout" :class="{ show: show }" is-mobile="true">
+    <div class="scene" ref="scene$"></div>
+    <template v-if="dataLoaded">
+      <Information />
+      <Control />
+      <teleport v-if="refMiniMap && player.showWidgets" :to="refMiniMap">
+        <span class="button-switch" @click.stop="toggleMap">
+          <ui-icon type="show_map_collect"></ui-icon>
+        </span>
+
+        <p class="change">
+          <ui-icon type="show_3d_normal"></ui-icon>
+          3D模型
+        </p>
+      </teleport>
+      <template v-if="refMiniMap && player.showWidgets">
+        <div :class="{ disabled: flying }" v-show="mode != 'panorama'" v-if="controls.showFloorplan && controls.showDollhouse" class="tab-layer">
+          <div class="tabs" v-if="controls.showMap">
+            <span :class="{ active: mode === 'floorplan' }" @click="changeMode('floorplan', $event)">
+              <ui-icon :type="mode == 'floorplan' ? 'show_plane_selected' : 'show_plane_normal'"></ui-icon>
+              二维
+            </span>
+            <span :class="{ active: mode === 'dollhouse' }" @click="changeMode('dollhouse', $event)">
+              <ui-icon :type="mode == 'dollhouse' ? 'show_3d_selected' : 'show_3d_normal'"></ui-icon>
+
+              三维
+            </span>
+            <div class="background" ref="background"></div>
+          </div>
+        </div>
+      </template>
+    </template>
+    <!-- <UiTags /> -->
+  </div>
+
+  <GoodsList @close="closetagtype" />
+  <Treasure @close="closetagtype" />
+  <Waterfall @close="closetagtype" />
+</template>
+
+<script setup>
+import { useMusicPlayer } from "@/utils/sound";
+// import UiTags from "@/components/Tags";
+import GoodsList from "@/components/Tags/goods-list.vue";
+import Treasure from "@/components/Tags/treasure.vue";
+import Waterfall from "@/components/Tags/waterfall.vue";
+
+import Information from "@/components/Information";
+import Control from "@/components/Controls/Control.Mobile.vue";
+import LoadingLogo from "@/components/shared/Loading.vue";
+
+import { createApp } from "@/app";
+import { ref, onMounted, computed, watch } from "vue";
+import { useStore } from "vuex";
+import browser from "@/utils/browser";
+import { useApp, getApp } from "@/app";
+import * as apis from "@/apis/index.js";
+
+const musicPlayer = useMusicPlayer();
+
+const closetagtype = () => {
+  store.commit("tag/setTagClickType", "");
+};
+
+const store = useStore();
+const tags = computed(() => {
+  return store.getters["tag/tags"] || [];
+});
+const player = computed(() => store.getters["player"]);
+const flying = computed(() => store.getters["flying"]);
+const metadata = computed(() => store.getters["scene/metadata"]);
+const controls = computed(() => {
+  return metadata.value.controls;
+});
+const mode = computed(() => store.getters["mode"]);
+const showNavigations = computed(() => store.getters["showNavigations"]);
+const scene$ = ref(null);
+const show = ref(false);
+const dataLoaded = ref(false);
+const refMiniMap = ref(null);
+const isCollapse = ref(false);
+const background = ref(null);
+const resize = () => {
+  if (this.$refs.background && (this.mode == "floorplan" || this.mode == "dollhouse")) {
+    this.$nextTick(() => {
+      let $active = $(this.$el).find(".tabs .active");
+      background.value.style.width = $active[0].getBoundingClientRect().width + "px";
+      background.value.style.left = $active.position().left + "px";
+    });
+  }
+};
+watch(
+  () => player.value.showMap,
+  (val, old) => {
+    if (!isCollapse.value) {
+      let $minmap = document.querySelector("[xui_min_map]");
+      if ($minmap) {
+        if (val) {
+          $minmap.classList.remove("collapse");
+        } else {
+          $minmap.classList.add("collapse");
+        }
+      }
+    }
+  },
+  {
+    deep: true,
+  }
+);
+watch(
+  () => player.value.showWidgets,
+  (val, old) => {
+    let $minmap = document.querySelector("[xui_min_map]");
+    if ($minmap) {
+      if (val) {
+        $minmap.classList.remove("collapse");
+      } else {
+        $minmap.classList.add("collapse");
+      }
+    }
+  },
+  {
+    deep: true,
+  }
+);
+const changeMode = (name, e) => {
+  if (!flying.value) {
+    background.value.style.width = e.srcElement.getBoundingClientRect().width + "px";
+    background.value.style.left = e.srcElement.offsetLeft + "px";
+    store.commit("setMode", name);
+  }
+};
+const toggleMap = () => {
+  isCollapse.value = !isCollapse.value;
+  let $minmap = document.querySelector("[xui_min_map]");
+  if ($minmap) {
+    if (!isCollapse.value) {
+      $minmap.classList.remove("collapse");
+    } else {
+      $minmap.classList.add("collapse");
+    }
+  }
+};
+
+const onClickTagInfo = (el) => {
+  el.stopPropagation();
+  let item = tags.value.find((item) => item.sid == el.target.dataset.id);
+  console.log(item);
+  if (item.type == "commodity") {
+    store.commit("tag/setTagClickType", "goodlist");
+  } else if (item.type == "waterfall") {
+    store.commit("tag/setTagClickType", "waterfall");
+  }
+};
+
+onMounted(() => {
+  const app = createApp({
+    num: browser.getURLParam("m"),
+    dom: scene$.value,
+    mobile: true,
+  });
+  app.use("MinMap", { theme: { camera_fillStyle: "#ED5D18" } });
+  app.use("Tag");
+  app
+    .use("TagView", {
+      render(data) {
+        console.log(data, data.type);
+        if (data.type == "waterfall") {
+          return `<span class="tag-icon animate" style="background-image:url({{icon}})"></span>
+                      <div class="tag-body">
+                        <div data-id="${data.sid}" class="tag-commodity">
+                          <div  style="background-image:url({{icon}})" class='tag-avatar'>
+                          </div>
+                          <p class="tag-title">這裏是商品標題</p>
+                          <p class="tag-info">¥ 198 | 查看 ></p>
+                        </div>
+                      </div>
+                  `;
+        } else if (data.type == "coupon") {
+          return `<span class="tag-icon coupon animate" style="background-image:url({{icon}})"></span>`;
+        } else if (data.type == "applet_link") {
+          return `<span class="tag-icon applet_link" style="background-image:url({{icon}})"></span>`;
+        } else if (data.type == "link_scene") {
+          return `<span class="tag-icon animate" style="background-image:url({{icon}})"></span>
+                                <div class="tag-body">sdfsdf</div>
+                            `;
+        } else if (data.type == "commodity") {
+          return `<span class="tag-icon animate" style="background-image:url({{icon}})"></span>
+                      <div class="tag-body">
+                        <div data-id="${data.sid}" class="tag-commodity">
+                          <div  style="background-image:url({{icon}})" class='tag-avatar'>
+                          </div>
+                          <p class="tag-title">這裏是商品標題</p>
+                          <p class="tag-info">¥ 198 | 查看 ></p>
+                        </div>
+                      </div>
+                  `;
+        } else {
+          return `<span class="tag-icon animate" style="background-image:url({{icon}})"></span>`;
+        }
+      },
+    })
+    .then((view) => {
+      console.log(view, "viewviewviewviewviewviewviewview");
+      view.on("click", (e) => {
+        var tag = e.data;
+        // 聚焦当前点击的热点
+        view.focus(tag.sid).then(() => {
+          if (tag.type == "coupon") {
+
+          } else if (tag.type == "applet_link") {
+          }
+        });
+      });
+
+      view.on("focus", (e) => {
+        document.querySelectorAll("[xui_tags_view] >div").forEach((el) => {
+          if (el.getAttribute("data-tag-type") == "commodity" || el.getAttribute("data-tag-type") == "waterfall") {
+            el.querySelector(".tag-body").classList.remove("show");
+            el.style.zIndex = "auto";
+          }
+        });
+        if (e.data.type == "commodity" || e.data.type == "waterfall") {
+          e.target.style.zIndex = "999";
+          e.target.querySelector(".tag-body").classList.add("show");
+          e.target.querySelector(".tag-commodity").removeEventListener("click", onClickTagInfo);
+          e.target.querySelector(".tag-commodity").addEventListener("click", onClickTagInfo);
+        }
+      });
+    });
+
+  app.use("TourPlayer");
+  app.Scene.on("ready", () => {
+    show.value = true;
+  });
+  app.Scene.on("loaded", (pano) => {
+    refMiniMap.value = "[xui_min_map]";
+    store.commit("setFloorId", pano.floorIndex);
+    useMusicPlayer();
+  });
+  app.Scene.on("panorama.videorenderer.resumerender", () => {
+    musicPlayer.pause(true);
+  });
+
+  app.Scene.on("panorama.videorenderer.suspendrender", async () => {
+    let player = await getApp().TourManager.player;
+    if (!player.isPlaying) {
+      musicPlayer.resume();
+    }
+  });
+  app.store.on("metadata", (metadata) => {
+    store.commit("scene/load", metadata);
+    if (!metadata.controls.showMap) {
+      app.MinMap.hide(true);
+    }
+    dataLoaded.value = true;
+  });
+  app.store.on("tags", async (tags) => {
+    // let res = await apis.get_tags_list({
+    //   num: browser.getURLParam("m"),
+    // });
+    // console.log(res, "============tags");
+    store.commit("tag/load", tags);
+  });
+  app.Camera.on("mode.beforeChange", ({ fromMode, toMode, floorIndex }) => {
+    if (fromMode) {
+      store.commit("setFlying", true);
+    }
+  });
+  app.Camera.on("mode.afterChange", ({ toMode, floorIndex }) => {
+    store.commit("setFlying", false);
+  });
+  app.Camera.on("flying.started", (pano) => {
+    store.commit("setFlying", true);
+  });
+  app.Camera.on("flying.ended", ({ targetPano }) => {
+    store.commit("setFlying", false);
+    store.commit("setPanoId", targetPano.id);
+  });
+  app.store.on("tour", async (tour) => {
+    app.TourManager.load(tour);
+    store.commit("tour/setData", {
+      tours: JSON.parse(
+        JSON.stringify(app.TourManager.tours, (key, val) => {
+          if (key === "audio") {
+            return null;
+          } else {
+            return val;
+          }
+        })
+      ),
+    });
+    store.commit("tour/setBackUp", {
+      tours: JSON.parse(
+        JSON.stringify(app.TourManager.tours, (key, val) => {
+          if (key === "audio") {
+            return null;
+          } else {
+            return val;
+          }
+        })
+      ),
+    });
+  });
+  app.store.on("floorcad", (floor) => store.commit("scene/loadFloorData", floor));
+
+  app.render();
+});
+</script>
+<style lang="scss">
+.tab-layer {
+  width: 100%;
+  text-align: center;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 10;
+  position: fixed;
+  left: 50%;
+  transform: translateX(-50%);
+  top: 2.3rem;
+  pointer-events: none;
+}
+.tabs {
+  pointer-events: auto;
+  position: relative;
+  display: flex;
+  background: #222222;
+  border-radius: 6px;
+  padding: 2px;
+  justify-content: center;
+  align-items: center;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  box-shadow: inset 0px 0px 6px 0px rgba(0, 0, 0, 0.5);
+  .background {
+    position: absolute;
+    left: 2px;
+    top: 2px;
+    bottom: 2px;
+    width: 50%;
+    border-radius: 4px;
+    background: #444444;
+    box-shadow: 2px 0px 4px 0px rgba(0, 0, 0, 0.3);
+    z-index: 0;
+    transition: left 0.3s;
+  }
+  span {
+    flex: 1;
+    color: #fff;
+    opacity: 0.5;
+    border-radius: 6px;
+    height: 0.94737rem;
+    font-size: 0.36842rem;
+    transition: all 0.3s ease;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding-left: 10px;
+    padding-right: 10px;
+    white-space: nowrap;
+    z-index: 1;
+    i {
+      font-size: 0.47368rem;
+      margin-right: 4px;
+      pointer-events: none;
+    }
+  }
+
+  span.active {
+    opacity: 1;
+  }
+}
+
+[xui_tags_view] {
+  .tag-body {
+    /* display: none; */
+    position: absolute;
+    left: 50%;
+    bottom: 50px;
+
+    transform: translateX(-50%) scale(0);
+    transform-origin: bottom;
+    transition: all 0.3s cubic-bezier(0.35, 0.32, 0.65, 0.63);
+    // pointer-events: none;
+    .tag-commodity {
+      width: 210px;
+      height: 76px;
+      background: rgba(255, 255, 255, 0.8);
+      box-shadow: 0px 3px 6px 0px rgba(0, 0, 0, 0.16);
+      border-radius: 2px;
+      position: relative;
+      margin-bottom: 30px;
+      &::before {
+        content: "";
+        display: inline-block;
+        left: 50%;
+        transform: translateX(-50%);
+        width: 2px;
+        height: 28px;
+        bottom: -30px;
+        background: linear-gradient(145deg, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0));
+        position: absolute;
+      }
+      .tag-avatar {
+        position: absolute;
+        z-index: 99;
+        width: 80px;
+        height: 80px;
+        background: #ffffff;
+        box-shadow: 0px 3px 6px 0px rgb(0 0 0 / 16%);
+        border-radius: 2px;
+        top: -14px;
+        left: -12px;
+        background-size: cover;
+        pointer-events: none;
+      }
+      > p {
+        color: #131d34;
+        font-size: 16px;
+        pointer-events: none;
+      }
+      .tag-title {
+        padding: 10px 0 10px 76px;
+        width: 200px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .tag-info {
+        padding: 0 0 0 76px;
+        font-size: 14px;
+      }
+    }
+    &.show {
+      transform: translateX(-50%) scale(1);
+    }
+  }
+
+  .coupon {
+    width: 64px !important;
+    height: 64px !important;
+    &::after {
+      content: "发现好礼";
+      width: 100%;
+      color: #ed5d18;
+      position: absolute;
+      bottom: -24px;
+      text-align: center;
+      font-size: 14px;
+    }
+  }
+
+  .applet_link {
+    width: 64px !important;
+    height: 64px !important;
+    border-radius: 50%;
+    background-color: #fff;
+    border: 1px solid #ed5d18;
+    position: relative;
+    overflow: hidden;
+    &::after {
+      content: "直播中";
+      width: 100%;
+      height: 20px;
+      background: #ed5d18;
+      position: absolute;
+      bottom: 0;
+      text-align: center;
+      line-height: 1.2;
+      font-size: 12px;
+      border-radius: 26%;
+    }
+  }
+}
+
+@media (orientation: landscape) {
+  .tab-layer {
+    top: 1.2rem;
+    .tabs {
+      height: 0.7rem;
+      > span {
+        height: 0.7rem;
+        font-size: 0.25rem;
+      }
+    }
+  }
+}
+</style>

二进制
src/assets/images/floorlogo/0.png


二进制
src/assets/images/floorlogo/1.png


二进制
src/assets/images/floorlogo/2.png


二进制
src/assets/images/floorlogo/en/0.png


二进制
src/assets/images/floorlogo/en/1.png


二进制
src/assets/images/floorlogo/en/2.png


二进制
src/assets/images/floorlogo/enter-style-default.png


二进制
src/assets/images/floorlogo/enter-style-down.png


二进制
src/assets/images/floorlogo/enter-style-up.png


二进制
src/assets/images/floorlogo/icon-corner-24.png


二进制
src/assets/images/floorlogo/icon-corner.png


文件差异内容过多而无法显示
+ 17 - 0
src/assets/images/icon/1.svg


文件差异内容过多而无法显示
+ 17 - 0
src/assets/images/icon/2.svg


文件差异内容过多而无法显示
+ 17 - 0
src/assets/images/icon/3.svg


二进制
src/assets/images/icon/bg.png


二进制
src/assets/images/icon/gifts_off.png


二进制
src/assets/images/icon/gifts_on.png


二进制
src/assets/images/icon/logo.png


二进制
src/assets/images/icon/top5.png


二进制
src/assets/images/loading.jpg


二进制
src/assets/images/tag/style-tag.png


+ 332 - 0
src/assets/theme.editor.scss

@@ -0,0 +1,332 @@
+// 资源图片目录地址(必要)
+$img-base-path: '~@/global_components/assets/img/';
+@import '~@/global_components/assets/scss/theme-editor.scss';
+
+:root {
+    --editor-main-color: #ED5D18;
+    --editor-font-color: #999;
+
+    --editor-toolbox-top: 0;
+    --editor-toolbox-left: 0;
+    --editor-toolbox-width: 240px;
+    --editor-toolbox-padding: 0 10px;
+    --editor-menu-active: rgba(0, 200, 175, 0.16);
+
+    --colors-primary-base: var(--editor-main-color);
+    --colors-primary-click: #005046;
+}
+
+::-webkit-scrollbar {
+    width: 4px;
+    height: 1px;
+}
+
+::-webkit-scrollbar-thumb {
+    border-radius: 4px;
+    box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
+    background: #ccc;
+}
+
+::-webkit-scrollbar-thumb:hover {
+    background: #999;
+}
+
+::-webkit-scrollbar-track {
+    box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
+    border-radius: 4px;
+    background: #000000;
+}
+ul,
+li {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+html {
+    line-height: 1.15;
+    -webkit-text-size-adjust: 100%;
+}
+html,
+body {
+    width: 100%;
+    height: 100%;
+    margin: 0;
+    font-size: 14px;
+    overflow: hidden;
+    user-select: none;
+}
+#app {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    color: #fff;
+    background-color: #232323;
+}
+
+.disable * {
+    opacity: 0.85;
+    pointer-events: none;
+}
+
+.enable {
+    opacity: 1 !important;
+    pointer-events: all !important;
+    * {
+        opacity: 1 !important;
+        pointer-events: all !important;
+    }
+}
+
+.hidden {
+    visibility: hidden !important;
+    pointer-events: none !important;
+    z-index: -1;
+}
+
+.scene {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    z-index: 1;
+}
+.slide-right-enter-active,
+.slide-right-leave-active {
+    will-change: transform;
+    transition: all 0.2s ease-in-out;
+}
+.slide-right-enter-from {
+    opacity: 0;
+    transform: translate3d(100%, 0, 0);
+}
+.slide-right-enter {
+    opacity: 1;
+    transform: translate3d(-100%, 0, 0);
+}
+.slide-right-leave-active {
+    opacity: 0;
+    transform: translate3d(100%, 0, 0);
+}
+// .slide-right-enter-active,
+// .slide-right-leave-active,
+// .slide-left-enter-active,
+// .slide-left-leave-active {
+//     will-change: transform;
+//     transition: all 0.35s ease-in-out;
+//     position: absolute;
+// }
+
+.fade-enter,
+.fade-leave-to {
+    opacity: 0;
+}
+.fade-enter-to,
+.fade-leave {
+    opacity: 1;
+}
+.fade-enter-active,
+.fade-leave-active {
+    transition: all 3s;
+}
+
+.slide-right-enter {
+    opacity: 1;
+    transform: translate3d(-100%, 0, 0);
+}
+.slide-right-leave-active {
+    opacity: 1;
+    transform: translate3d(100%, 0, 0);
+}
+.slide-left-enter {
+    opacity: 1;
+    transform: translate3d(-100%, 0, 0);
+}
+.slide-left-leave-active {
+    opacity: 1;
+    transform: translate3d(100%, 0, 0);
+}
+
+.ui-view-layout {
+    width: 100%;
+    height: 100%;
+    visibility: hidden;
+    &.show {
+        visibility: visible;
+    }
+}
+input[type="password"]::-ms-reveal{
+  display:none
+}
+.ui-editor-toolbox {
+    .edit-list {
+        li {
+            margin-top: 20px;
+            color: var(--editor-font-color);
+            border-bottom: solid 1px rgba(255, 255, 255, 0.16);
+            &:last-child {
+                border-bottom: none;
+            }
+            > div {
+                margin-bottom: 14px;
+            }
+            label {
+                color: #fff;
+                i {
+                    cursor: pointer;
+                }
+            }
+            .title {
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                span {
+                    font-weight: bold;
+                    font-size: 16px;
+                }
+            }
+
+            .between {
+                display: flex;
+                align-items: center;
+                justify-content: space-between;
+            }
+        }
+    }
+}
+
+.ql-editor {
+    padding: 0;
+}
+a {
+    color: #ED5D18;
+}
+
+[xui_min_map] {
+    top: 20px !important;
+    right: 20px !important;
+}
+.slider-box {
+    position: fixed;
+    top: 0;
+    right: 0;
+    width: var(--editor-toolbox-width);
+    height: 100%;
+    background: rgba(20, 20, 20, 1);
+    z-index: 1;
+    .slider-content {
+        color: rgba(255, 255, 255, 0.6);
+        .content-item {
+            padding: 20px 10px;
+            box-sizing: border-box;
+            border-bottom: 1px solid rgba(255, 255, 255, 0.16);
+            box-sizing: border-box;
+            &:last-of-type {
+                border-bottom: 1px solid transparent;
+            }
+        }
+
+        .item-title {
+            font-size: 16px;
+            font-weight: bold;
+            color: #999;
+            margin-bottom: 14px;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+        }
+    }
+    .slider-title {
+        height: 64px;
+        border-bottom: 1px solid rgba(255, 255, 255, 0.16);
+        color: #999;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding: 20px 10px;
+        box-sizing: border-box;
+        span {
+            font-size: 18px;
+            color: #999;
+            font-weight: bold;
+        }
+        i {
+            cursor: pointer;
+        }
+    }
+}
+
+[is-mobile] {
+    [xui_min_map] {
+        width: 100px;
+        height: 100px;
+        top: 2.3rem !important;
+        right: 15px !important;
+        background-color: rgba(0, 0, 0, 0.2) !important;
+        border-radius: 4px 4px 0 0  !important;
+        border-top-left-radius: 0 !important;
+        transition: all .3s;
+        &.collapse{
+          transform: translateX(calc(100% + 15px));
+          .button-switch{
+            >.iconfont{
+              font-size: 12px;
+              transform: rotate(180deg) scale(.7);
+            }
+       
+          }
+        }
+        .button-switch {
+            position: absolute;
+            top: 0;
+            left: -16px;
+            background-color: rgba(0, 0, 0, 0.2);
+            height: 32px;
+            width: 16px;
+            border-radius: 32px 0 0 32px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            transition: rotate .3s;
+            >.iconfont{
+              font-size: 12px;
+              transform: scale(.7);
+            }
+        }
+        .change{
+            position: absolute;
+            bottom: -29px;
+            left: 0;
+            height: 28px;
+            background-color: rgba(0, 0, 0, 0.2);
+            width: 100%;
+            text-align: center;
+            margin: 0;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            border-radius: 0 0 4px 4px!important;
+            >i{
+                margin-right: 4px;
+            }
+        }
+    }
+    .ui-dialog{
+        .ui-dialog__box{
+            min-width: 100px;
+            width: 90%;
+        }
+    }
+}
+
+.v-enter,
+.v-leave-to {
+    opacity: 0;
+    transform: translateY(100px);
+}
+
+.v-enter-active,
+.v-leave-active {
+    transition: all 0.25s ease;
+}

+ 80 - 0
src/components/Controls/BottomControl.vue

@@ -0,0 +1,80 @@
+<template>
+    <div class="bottom-controls" :class="{ hidden: isHidden }" :style="{ bottom }">
+        <FloorSwitch />
+        <LeftButtons />
+        <RightButtons />
+    </div>
+</template>
+<script setup>
+import { computed } from 'vue'
+import { useStore } from 'vuex'
+import { ref } from 'vue'
+import FloorSwitch from './FloorSwitch'
+import LeftButtons from './LeftButtons'
+import RightButtons from './RightButtons'
+const store = useStore()
+const isHidden = ref(false)
+const bottom = computed(() => {
+    return store.getters.controlsBottom
+})
+</script>
+<style lang="scss" scoped>
+.bottom-controls {
+    width: 100%;
+    position: absolute;
+    left: 0;
+    right: var(--editor-toolbox-width);
+    bottom: 20px;
+    height: 34px;
+    display: flex;
+    justify-content: space-between;
+    transition: bottom 0.3s ease;
+    z-index: 100;
+}
+
+:deep(.buttons) {
+    pointer-events: all;
+    display: flex;
+    align-items: center;
+    justify-content: space-around;
+    height: 34px;
+    border-radius: 17px;
+    background-color: rgba(0, 0, 0, 0.3);
+    > div {
+        position: relative;
+        margin-left: 20px;
+        margin-right: 20px;
+        cursor: pointer;
+        &.active {
+            color: var(--editor-main-color);
+        }
+        > i {
+            font-size: 18px;
+        }
+        span {
+            cursor: pointer;
+            display: none;
+            position: absolute;
+            top: -20px;
+            right: -15px;
+            width: 24px;
+            height: 24px;
+            background-color: rgba(0, 0, 0, 0.5);
+            border-radius: 50%;
+            align-items: center;
+            justify-content: center;
+            color: var(--editor-main-color);
+            transition: all 0.1s;
+
+            &:hover {
+                transform: scale(1.2);
+            }
+            &.disable {
+                i {
+                    opacity: 0.5;
+                }
+            }
+        }
+    }
+}
+</style>

+ 360 - 0
src/components/Controls/Control.Mobile.vue

@@ -0,0 +1,360 @@
+<template>
+  <FloorSwitch />
+  <transition mode="out-in">
+      <component class="limitwidth" :is="panelPage"></component>
+  </transition>
+</template>
+
+<script setup>
+import { useStore } from "vuex";
+import { onMounted, watch, computed, ref, shallowRef, nextTick } from "vue";
+import { useApp, getApp } from "@/app";
+import FloorSwitch from "./FloorSwitch";
+
+import vMain from "./Panel/Main";
+import Guide from "./Panel/Guide";
+
+import { Scrollbar, Dialog } from "@/global_components/";
+const store = useStore();
+import common from "@/utils/common";
+import { useMusicPlayer } from "@/utils/sound";
+const musicPlayer = useMusicPlayer();
+const metadata = computed(() => store.getters["scene/metadata"]);
+const flying = computed(() => store.getters["flying"]);
+const controls = computed(() => {
+  return metadata.value.controls;
+});
+const player = computed(() => store.getters["player"]);
+const mode = computed(() => store.getters["mode"]);
+let timer = null;
+
+const panelPage = computed(() => {
+  let status = store.getters["tour/isPlay"] ? Guide :  vMain;
+  return status;
+});
+
+
+
+const modeTips = ref("");
+const isCollapse = ref(false);
+const progressNum = ref(0);
+const isInit = ref(false);
+const tours = computed(() => store.getters["tour/tours"]);
+
+const isSelect = ref(false);
+const labelTimer = ref(null);
+const showTours = computed(() => store.getters["tour/showTours"]);
+const partId = computed(() => {
+  let id = store.getters["tour/partId"];
+  return id;
+});
+
+const frameId = computed(() => {
+  let id = store.getters["tour/frameId"];
+  return id;
+});
+const isPlay = computed(() => {
+  let status = store.getters["tour/isPlay"];
+  let map = document.querySelector(".kankan-app div[xui_min_map]");
+  if (map) {
+    if (status) {
+      map.classList.add("disabled");
+    } else {
+      map.classList.remove("disabled");
+    }
+  }
+  return status;
+});
+const onModeSwitch = (mode) => {
+  if (labelTimer.value) {
+    modeTips.value = "";
+    clearTimeout(labelTimer.value);
+  }
+  modeTips.value = mode;
+  labelTimer.value = setTimeout(() => {
+    modeTips.value = "";
+  }, 2000);
+  console.log(mode);
+  // flyToNewMode(mode)
+  store.commit("setMode", mode);
+};
+
+const onCollapse = () => {
+  if (player.value.showToolbar == false) {
+    isCollapse.value = false;
+    let show = !player.value.showToolbar;
+    store.commit("SetPlayerOptions", {
+      showMore: false,
+      showMap: show == false,
+      showToolbar: show,
+      showDescription: false,
+    });
+  } else {
+    isCollapse.value = !isCollapse.value;
+    store.commit("tour/setData", { showTours: false });
+  }
+};
+
+
+const hanlderTourPartPlay = (time) => {
+  if (!timer) {
+    timer = KanKan.Animate.transitions.start((progress) => {
+      progressNum.value = progress * 100;
+    }, time);
+  }
+};
+const cancelTimer = () => {
+  if (timer) {
+    KanKan.Animate.transitions.cancel(timer);
+    timer = null;
+  }
+};
+
+const hanlderTour = async () => {
+  let player = await getApp().TourManager.player;
+  player.on("play", (data) => {
+    musicPlayer.pause(true);
+    // if (tours.value.length > 1) {
+    //     let time = getPartTime(data.partId)
+    //     hanlderTourPartPlay(time)
+    // }
+  });
+  player.on("pause", (tours) => {
+    console.log("pause", player);
+    musicPlayer.resume();
+
+    progressNum.value = 0;
+    cancelTimer();
+    store.commit("tour/setData", { isPlay: false });
+  });
+  player.on("end", (tours) => {
+    musicPlayer.resume();
+    progressNum.value = 100;
+    store.commit("tour/setData", { isPlay: false });
+    cancelTimer();
+  });
+  let currPartId = null;
+  let currProgress = 0;
+  let currFrames = 0;
+  player.on("progress", (data) => {
+    if (tours.value.length == 1) {
+      progressNum.value = data.progress * 100;
+    } else {
+      // let time = getPartTime(data.partId)
+
+      // hanlderTourPartPlay(time)
+
+      if (currPartId != data.partId) {
+        currPartId = data.partId;
+        currFrames = tours.value[data.partId].list.length;
+        currProgress = 0;
+      } else {
+        currProgress += data.progress / currFrames;
+        if (currProgress >= 100) {
+          currProgress = 100;
+        }
+
+        progressNum.value = currProgress;
+      }
+    }
+
+    store.commit("tour/setData", { partId: data.partId, frameId: data.frameId, isPlay: true });
+  });
+
+  // nextTick(() => {
+  //     editorMain.value = document.querySelector('.ui-editor-main')
+  // })
+};
+const getPartTime = (partId) => {
+  cancelTimer();
+  let time = 0;
+  // for (let i = 0; i < tours.value[partId].list.length - 1; i++) {
+  //     time += tours.value[partId].list[i].time - 0
+  // }
+  // if (tours.value[partId].list[tours.value[partId].list.length - 1]._end) {
+  //     if (tours.value[partId].list.length > 2) {
+  //         time += (tours.value[partId].list.length - 1) * 1000
+  //     }
+  // } else {
+  //     time += (tours.value[partId].list.length - 1) * 1000
+  // }
+  for (let i = 0; i < tours.value[partId].list.length; i++) {
+    if (!tours.value[partId].list[i]._end) {
+      time += tours.value[partId].list[i].time - 0;
+      if (!tours.value[partId].list[i]._notrans) {
+        time += 1000;
+      }
+    }
+  }
+
+  return time;
+};
+
+const openTours = () => {
+  // showTours.value = !showTours.value
+
+  store.commit("tour/setData", { showTours: !showTours.value });
+ 
+};
+const changeFrame = async (type, id) => {
+  if (flying.value || isSelect.value) {
+    return;
+  }
+  progressNum.value = 0;
+  // recorder.selectFrame(id)
+  let player = await getApp().TourManager.player;
+  // player.selectFrame(id)
+  isSelect.value = true;
+  if (type == 1) {
+    player.selectPart(id);
+    let f_id = 0;
+    if (tours.value[id].frameId) {
+      f_id = tours.value[id].frameId;
+    }
+    player.selectFrame(f_id).then(() => {
+      isSelect.value = false;
+    });
+    store.commit("tour/setData", {
+      frameId: f_id,
+      partId: id,
+    });
+  } else {
+    player.selectFrame(id).then(() => {
+      isSelect.value = false;
+    });
+    store.commit("tour/setData", {
+      frameId: id,
+    });
+  }
+
+};
+const onClickHandler = async () => {
+  if (isPlay.value) {
+    let player = await getApp().TourManager.player;
+    player.pause();
+    musicPlayer.resume();
+  }
+};
+onMounted(() => {
+  useApp().then(async (sdk) => {
+    hanlderTour();
+  });
+
+  nextTick(() => {
+    let player = document.querySelector('.player[name="main"]');
+    player.addEventListener("touchstart", onClickHandler);
+  });
+});
+</script>
+
+<style lang="scss" scoped>
+.disable {
+  opacity: 1;
+}
+
+.limitwidth{
+  max-width: 500px;
+  margin: 0 auto;
+}
+
+.switch {
+  position: absolute;
+  right: 0;
+  top: 0;
+  display: flex;
+  width: 1rem;
+  height: 100%;
+  align-items: center;
+  i {
+    margin-left: 0.2rem;
+    font-size: 0.28rem;
+  }
+}
+.guide {
+  display: flex;
+  height: 100%;
+  width: calc(100vw - 2rem);
+  flex-shrink: 0;
+  &.flex {
+    width: auto;
+  }
+  > div {
+    height: 100%;
+    width: 100%;
+    &.play {
+      display: none;
+    }
+    &.play {
+      position: relative;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      width: 0.8rem;
+      margin: 0 0.3rem;
+      &::after {
+        content: "";
+        position: absolute;
+        top: 50%;
+        right: -0.3rem;
+        width: 1px;
+        height: 35%;
+        transform: translateY(-50%);
+        background-color: rgba(255, 255, 255, 0.6);
+        box-shadow: 1px 0px 1px 0px rgba(0, 0, 0, 0.2);
+      }
+    }
+
+    &.rooms {
+      flex: 1;
+      width: 100%;
+      overflow: hidden;
+      padding: 4px 0;
+      margin-left: 0.3rem;
+    }
+  }
+}
+
+@media (orientation: landscape) {
+  .toolbar {
+    padding-left: 0.5rem;
+    bottom: 0.5rem;
+    height: 0.8rem;
+    font-size: 0.25rem;
+    i {
+      font-size: 0.25rem;
+    }
+  }
+  .guide {
+    width: calc(100vw - 4rem);
+  }
+  .ctrls {
+    > div {
+      height: 0.6rem;
+      &.play {
+        padding: 0.3rem 0;
+        b,
+        span {
+          height: 0.8rem;
+        }
+      }
+      &.rooms {
+        left: 1rem;
+        right: 1rem;
+        bottom: 1.6rem;
+        height: 0.8rem;
+        transform-origin: 1.2rem 100%;
+        &::after {
+          left: 1.12rem;
+        }
+      }
+    }
+  }
+  .switch {
+    i {
+      font-size: 0.2rem;
+    }
+  }
+}
+</style>
+

+ 284 - 0
src/components/Controls/FloorSwitch.vue

@@ -0,0 +1,284 @@
+<template>
+    <div class="floor-switch" :class="{ disable: flying, showTours: showTours }" v-if="floors.length > 1 && mode != 'panorama'">
+        <ul>
+            <li v-if="mode != 'floorplan'" :class="{ active: 'all' == floorId }" @click.stop="onGotoFloor('all')"><b></b><span>全部</span></li>
+            <li v-for="item in floors" :key="item.id" :class="{ active: item.id == floorId }" @click.stop="onGotoFloor(item.id)">
+                <b></b><span>{{ item.name }}</span>
+            </li>
+        </ul>
+    </div>
+</template>
+<script setup>
+import { computed } from 'vue'
+import { useStore } from 'vuex'
+import { useApp } from '@/app'
+const store = useStore()
+const mode = computed(() => store.getters.mode)
+const flying = computed(() => store.getters['flying'])
+const floors = computed(() => store.getters['scene/floors'].reverse())
+const floorId = computed(() => store.getters.floorId)
+const showTours = computed(() => store.getters['tour/showTours'])
+const onGotoFloor = id => {
+    store.commit('setFloor', id)
+}
+useApp().then(sdk =>
+    sdk.Camera.on('mode.beforeChange', ({ toMode, floorIndex }) => {
+        store.commit('mode', toMode)
+        if (toMode != 'dollhouse') {
+            store.commit('setFloorId', floorIndex)
+        }
+    })
+)
+</script>
+
+<style lang="scss" scoped>
+.floor-switch {
+    pointer-events: all;
+    position: absolute;
+    bottom: calc(100% + 5px);
+    left: 20px;
+    z-index: 10;
+    transition: bottom 0.1s;
+    ul,
+    li {
+        padding: 0;
+        margin: 0;
+    }
+    ul {
+        position: relative;
+        z-index: 2;
+    }
+    li {
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        position: relative;
+        height: 50px;
+        &:first-child {
+            b {
+                &::before {
+                    display: none;
+                }
+            }
+            &.active {
+                b {
+                    &::after {
+                        bottom: -10px;
+                    }
+                }
+            }
+        }
+        &:last-child {
+            b {
+                &::after {
+                    display: none;
+                }
+            }
+            &.active {
+                b {
+                    &::before {
+                        top: -10px;
+                    }
+                }
+            }
+        }
+        b {
+            position: relative;
+            width: 16px;
+            height: 16px;
+            background-color: #1c1c1c;
+            border-radius: 50%;
+            box-shadow: 0px 0px 2px 1px #404040;
+            cursor: pointer;
+            &::before {
+                content: '';
+                position: absolute;
+                top: -5px;
+                left: 50%;
+                margin-left: -3px;
+                background: #1c1c1c;
+                width: 6px;
+                height: 6px;
+            }
+            &::after {
+                content: '';
+                position: absolute;
+                bottom: -5px;
+                left: 50%;
+                margin-left: -3px;
+                background: #1c1c1c;
+                width: 6px;
+                height: 6px;
+            }
+        }
+        span {
+            margin-left: 10px;
+            font-size: 14px;
+            //color: #939393;
+            cursor: pointer;
+            color: #eeeeee;
+            text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.5);
+        }
+        &.active {
+            b {
+                left: -6px;
+                width: 28px;
+                height: 28px;
+                border: solid 6px #1c1c1c;
+                background-color: #404040;
+                box-shadow: 0px 0px 2px 1px #404040;
+                &::before {
+                    top: -10px;
+                }
+                &::after {
+                    bottom: -10px;
+                }
+            }
+            span {
+                margin-left: 0;
+                position: relative;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                height: 32px;
+                color: rgba(255, 255, 255, 0.88);
+                border: solid 4px #1c1c1c;
+                background-color: #404040;
+                border-radius: 32px;
+                box-shadow: 0px 0px 2px 1px #404040;
+                padding: 0px 10px;
+                text-shadow: none;
+                &::before {
+                    content: '';
+                    position: absolute;
+                    left: -10px;
+                    background: #1c1c1c;
+                    width: 7px;
+                    height: 4px;
+                    box-shadow: -2px 0px 2px 1px #404040;
+                }
+                &::after {
+                    content: '';
+                    position: absolute;
+                    left: -16px;
+                    background: #1c1c1c;
+                    width: 6px;
+                    height: 11px;
+                    border-radius: 50%;
+                }
+            }
+        }
+    }
+    &::after {
+        content: '';
+        position: absolute;
+        left: 5px;
+        top: 20px;
+        bottom: 20px;
+        width: 6px;
+        background: #1c1c1c;
+        box-shadow: 0px 0px 2px 1px #404040;
+        z-index: 1;
+    }
+}
+[is-mobile] {
+    .floor-switch {
+        bottom: 2.5rem;
+        &.showTours {
+            bottom: 5rem;
+        }
+        li {
+            height: 45px;
+            &:first-child {
+                &.active {
+                    b {
+                        &::after {
+                            bottom: -6px;
+                        }
+                    }
+                }
+            }
+            &:last-child {
+                &.active {
+                    b {
+                        &::before {
+                            top: -6px;
+                        }
+                    }
+                }
+            }
+            b {
+                width: 14px;
+                height: 14px;
+                &::before {
+                    top: -3px;
+                    left: 50%;
+                    margin-left: -2px;
+                    width: 4px;
+                    height: 4px;
+                }
+                &::after {
+                    bottom: -3px;
+                    left: 50%;
+                    margin-left: -2px;
+                    width: 4px;
+                    height: 4px;
+                }
+            }
+            span {
+                font-size: 0.36842rem;
+            }
+            &.active {
+                b {
+                    left: -3px;
+                    width: 20px;
+                    height: 20px;
+                    border: solid 4px #1c1c1c;
+                    &::before {
+                        top: -6px;
+                    }
+                    &::after {
+                        bottom: -6px;
+                    }
+                }
+                span {
+                    height: 30px;
+                    border: solid 3px #1c1c1c;
+                    border-radius: 30px;
+                    padding: 0px 10px;
+                    margin-left: 5px;
+                    &::before {
+                        left: -11px;
+                        width: 10px;
+                        height: 4px;
+                        box-shadow: -2px 0px 1px 1px #404040;
+                    }
+                    &::after {
+                        left: -14px;
+                        width: 3px;
+                        height: 6px;
+                        border-radius: 40%;
+                    }
+                }
+            }
+        }
+        &::after {
+            left: 5px;
+            top: 20px;
+            bottom: 20px;
+            width: 4px;
+        }
+    }
+
+    @media (orientation: landscape) {
+        .floor-switch {
+            bottom: 1rem;
+            li {
+                span {
+                    font-size: 0.25rem;
+                }
+            }
+        }
+    }
+}
+</style>

+ 461 - 0
src/components/Controls/LeftButtons.vue

@@ -0,0 +1,461 @@
+<template>
+    <div class="controls-left-buttons">
+        <!-- <div class="buttons tour">
+            <div @click="onModeChange('panorama')">
+                <i class="iconfont" :class="['icon-preview']"></i>
+            </div>
+            <div class="show-list">
+                <i class="iconfont" :class="['icon-pull-down']"></i>
+            </div>
+        </div> -->
+        <div class="buttons mode" :class="{ disabled: isPlay || flying }">
+            <div v-if="controls.showPanorama" :class="{ active: viewmode == 'panorama' }" @click="onModeChange('panorama')">
+                <ui-icon :tip="$t('mode.panorama')" tipV="top" :type="viewmode == 'panorama' ? 'show_roaming_selected' : 'show_roaming_normal'"></ui-icon>
+            </div>
+            <div v-if="controls.showFloorplan" :class="{ active: viewmode == 'floorplan' }" @click="onModeChange('floorplan')">
+                <ui-icon :tip="$t('mode.floorplan')" tipV="top" :type="viewmode == 'floorplan' ? 'show_plane_selected' : 'show_plane_normal'"></ui-icon>
+            </div>
+            <div v-if="controls.showDollhouse" :class="{ active: viewmode == 'dollhouse' }" @click="onModeChange('dollhouse')">
+                <ui-icon :tip="$t('mode.dollhouse')" tipV="top" :type="viewmode == 'dollhouse' ? 'show_3d_selected' : 'show_3d_normal'"></ui-icon>
+            </div>
+        </div>
+        <!-- <div class="play-control" v-if="tours.length > 0" :class="{ disabled: flying || isSelect }"> -->
+        <div class="play-control" v-if="tours.length > 0">
+            <div class="play-btn tour-btn" @click="playTour">
+                <ui-icon :tip="isPlay ? $t('common.pauseTour') : $t('common.playTour')" tipV="top" :type="isPlay ? 'pause' : 'preview'"></ui-icon>
+            </div>
+
+            <div class="tour-btn bor" @click="openTours">
+                <ui-icon type="pull-down" :class="{ active: showTours }"></ui-icon>
+            </div>
+            <!-- <teleport :to="editorMain"> -->
+            <div class="tour-list" :class="{ ban: flying || isSelect }" :style="`height:${showTours ? '120px' : '0px'};`">
+                <div class="part-content" ref="tourScroll">
+                    <!-- 多个片段 -->
+                    <div class="part-list" v-if="tours.length > 1">
+                        <div
+                            @click="changeFrame(1, index)"
+                            :class="{ disabled: isPlay && partId != index }"
+                            class="part-item"
+                            :name="index"
+                            v-for="(i, index) in tours"
+                            :style="`background-image:url(${i.frameId ? common.changeUrl(i.list[i.frameId].enter.cover) : common.changeUrl(i.list[0].enter.cover)});`"
+                        >
+                            <div class="part-title">{{ i.name }}</div>
+                            <div v-if="partId == index && progressNum > 0" class="precent" :style="`width:${progressNum}%;`"></div>
+                        </div>
+                    </div>
+                    <!-- 只有一个片段 -->
+                    <div class="part-list frame-list" v-else>
+                        <div
+                            @click="changeFrame(2, index)"
+                            :class="{ disabled: isPlay && frameId != index }"
+                            class="part-item"
+                            v-for="(i, index) in tours[0].list"
+                            :name="index"
+                            :style="`background-image:url(${common.changeUrl(i.enter.cover)});`"
+                        >
+                            <div class="part-title"></div>
+                            <div v-if="frameId == index && progressNum > 0" class="precent" :style="`width:${progressNum}%;`"></div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <!-- </teleport> -->
+        </div>
+    </div>
+</template>
+<script setup>
+import { computed, defineProps, onMounted, ref, nextTick } from 'vue'
+import { Scrollbar, Dialog } from '@/global_components/'
+import { useApp, getApp } from '@/app'
+import { useStore } from 'vuex'
+import common from '@/utils/common'
+import { useMusicPlayer } from '@/utils/sound'
+const musicPlayer = useMusicPlayer()
+const props = defineProps({
+    isEdit: Boolean,
+})
+let timer = null
+const isSelect = ref(false)
+const store = useStore()
+const tourScroll = ref(null)
+const viewmode = computed(() => store.getters.mode)
+const flying = computed(() => store.getters['flying'])
+const controls = computed(() => store.getters['scene/metadata'].controls || {})
+const showTours = computed(() => store.getters['tour/showTours'])
+const partId = computed(() => {
+    let id = store.getters['tour/partId']
+    if (isPlay.value) {
+        slideScroll()
+    }
+    return id
+})
+
+const frameId = computed(() => {
+    let id = store.getters['tour/frameId']
+    if (isPlay.value) {
+        slideScroll()
+    }
+    return id
+})
+const progressNum = ref(0)
+const isPlay = computed(() => {
+    let status = store.getters['tour/isPlay']
+    let map = document.querySelector('.kankan-app div[xui_min_map]')
+    if (map) {
+        if (status) {
+            map.classList.add('disabled')
+        } else {
+            map.classList.remove('disabled')
+        }
+    }
+    return status
+})
+const isInit = ref(false)
+const tours = computed(() => {
+    let tours = store.getters['tour/tours']
+    if (tours.length > 0) {
+        if (tourScroll.value && !isInit.value) {
+            isInit.value = true
+            new Scrollbar(tourScroll.value, { onlyHorizontal: true })
+        }
+    }
+    return tours
+})
+const onModeChange = name => {
+    store.commit('setMode', name)
+}
+const playTour = async () => {
+    let player = await getApp().TourManager.player
+    if (isPlay.value) {
+        store.commit('tour/setData', { isPlay: true })
+        player.pause()
+    } else {
+        store.commit('tour/setData', { isPlay: true })
+        player.play(partId.value)
+    }
+}
+
+const hanlderTourPartPlay = time => {
+    if (!timer) {
+        timer = KanKan.Animate.transitions.start(progress => {
+            // console.log(progress)
+            progressNum.value = progress * 100
+        }, time)
+    }
+}
+const cancelTimer = () => {
+    if (timer) {
+        KanKan.Animate.transitions.cancel(timer)
+        timer = null
+    }
+}
+const slideScroll = () => {
+    nextTick(() => {
+        let t = setTimeout(() => {
+            clearTimeout(t)
+            let id = tours.value.length > 1 ? partId.value : frameId.value
+            let item = document.querySelector(`.part-item[name="${id}"]`)
+            // let itemLeft = (item.offsetWidth + 10) * (id + 1)
+            // let Scroll = document.querySelector('.part-content .x-scrollbar__container')
+            // // let contentW = slideType == 1 ? document.querySelector('.part-content').offsetWidth : document.querySelector('.iframe-content').offsetWidth
+            // let ScrollW = Scroll.offsetWidth
+            // Scroll.scrollTo({ left: itemLeft - ScrollW + 10, behavior: 'smooth' })
+            item.scrollIntoView({ block: 'center', behavior: 'smooth', inline: 'center' })
+        }, 100)
+    })
+}
+const hanlderTour = async () => {
+    let player = await getApp().TourManager.player
+    player.on('play', data => {
+        musicPlayer.pause(true)
+        // if (tours.value.length > 1) {
+        //     let time = getPartTime(data.partId)
+        //     console.log(time)
+        //     hanlderTourPartPlay(time)
+        // }
+    })
+    player.on('pause', tours => {
+        console.log('pause', player)
+        musicPlayer.resume()
+
+        progressNum.value = 0
+        cancelTimer()
+        store.commit('tour/setData', { isPlay: false })
+    })
+    player.on('end', tours => {
+        musicPlayer.resume()
+        progressNum.value = 100
+        slideScroll()
+        store.commit('tour/setData', { isPlay: false })
+        cancelTimer()
+    })
+
+    let currPartId = null
+    let currProgress = 0
+    let currFrames = 0
+
+    player.on('progress', data => {
+        if (tours.value.length == 1) {
+            progressNum.value = data.progress * 100
+        } else {
+            // let time = getPartTime(data.partId)
+
+            // hanlderTourPartPlay(time)
+
+            if (currPartId != data.partId) {
+                currPartId = data.partId
+                currFrames = tours.value[data.partId].list.length
+                currProgress = 0
+            } else {
+                currProgress += data.progress / currFrames
+                if (currProgress >= 100) {
+                    currProgress = 100
+                }
+
+                progressNum.value = currProgress
+            }
+        }
+        store.commit('tour/setData', { partId: data.partId, frameId: data.frameId, isPlay: true })
+    })
+
+    // nextTick(() => {
+    //     editorMain.value = document.querySelector('.ui-editor-main')
+    // })
+}
+const getPartTime = partId => {
+    cancelTimer()
+    let time = 0
+    // for (let i = 0; i < tours.value[partId].list.length - 1; i++) {
+    //     time += tours.value[partId].list[i].time - 0
+    // }
+    // if (tours.value[partId].list[tours.value[partId].list.length - 1]._end) {
+    //     if (tours.value[partId].list.length > 2) {
+    //         time += (tours.value[partId].list.length - 1) * 1000
+    //     }
+    // } else {
+    //     time += (tours.value[partId].list.length - 1) * 1000
+    // }
+    // for (let i = 0; i < tours.value[partId].list.length; i++) {
+    //     time += tours.value[partId].list[i].time - 0 + 1000
+    // }
+    for (let i = 0; i < tours.value[partId].list.length; i++) {
+        if (!tours.value[partId].list[i]._end) {
+            time += tours.value[partId].list[i].time - 0
+            if (!tours.value[partId].list[i]._notrans) {
+                time += 1000
+            }
+        }
+    }
+    return time
+}
+
+const openTours = () => {
+    // showTours.value = !showTours.value
+    store.commit('tour/setData', { showTours: !showTours.value })
+    let bot = '20px'
+    if (showTours.value) {
+        bot = '140px'
+    } else {
+        bot = '20px'
+    }
+    store.commit('setControlsBottom', bot)
+    nextTick(() => {
+        if (isPlay.value) {
+            slideScroll()
+        }
+    })
+}
+const changeFrame = async (type, id) => {
+    progressNum.value = 0
+    // recorder.selectFrame(id)
+    let player = await getApp().TourManager.player
+    // player.selectFrame(id)
+    isSelect.value = true
+    if (type == 1) {
+        player.selectPart(id)
+        console.log(tours.value[id].frameId)
+        let f_id = 0
+        if (tours.value[id].frameId) {
+            f_id = tours.value[id].frameId
+        }
+        player.selectFrame(f_id).then(() => {
+            isSelect.value = false
+        })
+        store.commit('tour/setData', {
+            frameId: f_id,
+            partId: id,
+        })
+    } else {
+        player.selectFrame(id).then(() => {
+            isSelect.value = false
+        })
+        store.commit('tour/setData', {
+            frameId: id,
+        })
+    }
+
+    slideScroll()
+}
+const onClickHandler = async () => {
+    if (isPlay.value) {
+        let player = await getApp().TourManager.player
+        player.pause()
+        musicPlayer.resume()
+    }
+}
+onMounted(() => {
+    useApp().then(async sdk => {
+        hanlderTour()
+    })
+
+    nextTick(() => {
+        let player = document.querySelector('.player[name="main"]')
+        player.addEventListener('click', onClickHandler)
+    })
+})
+</script>
+
+<style lang="scss" scoped>
+.controls-left-buttons {
+    margin-left: 20px;
+    margin-bottom: 20px;
+    display: flex;
+}
+.buttons.tour {
+    margin-right: 10px;
+    > div {
+        margin-left: 0px;
+        margin-right: 0px;
+        padding: 0 10px;
+        &.show-list {
+            border-left: solid 1px var(--editor-font-color);
+        }
+        .icon-pull-down {
+            font-size: 12px;
+        }
+        span {
+            right: -10px;
+        }
+    }
+}
+.tour-list {
+    width: 100%;
+    position: fixed;
+    bottom: 0;
+    left: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex: 1;
+    // height: var(--editor-toolbar-height);
+    height: 120px;
+    background-color: var(--editor-menu-back);
+    pointer-events: all;
+    left: var(--editor-toolbox-left);
+    z-index: 1;
+    transition: all 0.3s ease;
+    &.ban {
+        pointer-events: none;
+    }
+    .part-content {
+        display: flex;
+        flex-direction: row;
+        overflow: hidden;
+        width: 100%;
+        height: 100%;
+        .part-list {
+            width: 100%;
+            height: 120px;
+            display: flex;
+            align-items: center;
+            justify-content: flex-start;
+            padding: 0 10px;
+            box-sizing: border-box;
+            .part-item {
+                width: 120px;
+                height: 80px;
+                position: relative;
+                cursor: pointer;
+                margin-right: 10px;
+                background-repeat: no-repeat;
+                background-size: 100%;
+                background-position: center;
+                &:last-of-type {
+                    margin-right: 0px;
+                }
+                &:hover {
+                    opacity: 0.6;
+                }
+                .precent {
+                    width: 0%;
+                    height: 24px;
+                    position: absolute;
+                    bottom: 0;
+                    left: 0;
+                    background: #00c8af;
+                    opacity: 0.4;
+                    z-index: 1;
+                    // transition: width 0.1s;
+                }
+                .part-title {
+                    width: 100%;
+                    height: 24px;
+                    background: linear-gradient(180deg, rgba(0, 0, 0, 0.25) 0%, rgba(0, 0, 0, 0.5) 100%);
+                    position: absolute;
+                    bottom: 0;
+                    left: 0;
+                    display: flex;
+                    align-items: center;
+                    justify-content: center;
+                    font-size: 14px;
+                    z-index: 10;
+                }
+            }
+        }
+    }
+}
+.play-control {
+    pointer-events: all;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 34px;
+    border-radius: 17px;
+    background-color: rgba(0, 0, 0, 0.3);
+    min-width: 34px;
+    border-radius: 17px;
+    margin-left: 10px;
+
+    .tour-btn {
+        width: 34px;
+        height: 22px;
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+
+        > .iconfont {
+            font-size: 14px;
+        }
+        &.play-btn {
+            // border-right: 1px solid rgba(255, 255, 255, 0.2);
+            > .iconfont {
+                font-size: 20px;
+                transition: rotate 0.3s;
+            }
+        }
+        &.bor {
+            border-left: 1px solid rgba(255, 255, 255, 0.2);
+
+            > .iconfont {
+                transform: rotate(180deg);
+                &.active {
+                    transform: rotate(0deg);
+                }
+            }
+        }
+    }
+}
+</style>

+ 456 - 0
src/components/Controls/Panel/Guide.vue

@@ -0,0 +1,456 @@
+<template>
+  <div v-show="player.showWidgets" class="root-panel">
+    <div class="guide-panel" :style="{ '--urlbg': `url(${require('@/assets/images/icon/bg.png')})` }">
+      <div class="g-con">
+        <div class="back" @click.stop="playTour">
+          <ui-icon type="back"></ui-icon>
+          <div>返回</div>
+        </div>
+        <div class="swiper-container" id="sw-guide">
+          <ul class="swiper-wrapper"  v-if="tours.length > 1">
+            <li
+              class="swiper-slide"
+              :style="`background-image:url(${i.frameId ? common.changeUrl(i.list[i.frameId].enter.cover) : common.changeUrl(i.list[0].enter.cover)});`"
+              :class="{ active: isPlay && partId == index }"
+              @click="changeFrame(1, index)"
+              v-for="(i, index) in tours"
+              :key="index"
+            >
+              <div>{{ i.name }}</div>
+
+              <span v-if="partId == index && progressNum > 0" class="bar" :style="{ '--w': progressNum + '%' }"></span>
+            </li>
+          </ul>
+          <ul class="swiper-wrapper" v-else>
+            <li
+              class="swiper-slide"
+              :style="`background-image:url(${common.changeUrl(i.enter.cover)});`"
+              :class="{ active: isPlay && frameId == index }"
+              @click="changeFrame(2, index)"
+              v-for="(i, index) in tours[0].list"
+              :key="index"
+            >
+              <div>{{ i.name }}</div>
+
+              <span v-if="frameId == index && progressNum > 0" class="bar" :style="{ '--w': progressNum + '%' }"></span>
+            </li>
+          </ul>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { useStore } from "vuex";
+import common from "@/utils/common";
+import { onMounted, watch, computed, ref, nextTick } from "vue";
+import { useApp, getApp } from "@/app";
+import { useMusicPlayer } from "@/utils/sound";
+let timer = null
+
+const store = useStore();
+const musicPlayer = useMusicPlayer();
+const flying = computed(() => store.getters['flying'])
+const isSelect = ref(false)
+
+const isPlay = computed(() => {
+  let status = store.getters["tour/isPlay"];
+  let map = document.querySelector(".kankan-app div[xui_min_map]");
+  if (map) {
+    if (status) {
+      map.classList.add("disabled");
+    } else {
+      map.classList.remove("disabled");
+    }
+  }
+  return status;
+});
+
+const partId = computed(() => store.getters["tour/partId"]);
+const frameId = computed(() => store.getters["tour/frameId"]);
+
+const playTour = async () => {
+  let player = await getApp().TourManager.player;
+  if (isPlay.value) {
+    store.commit("tour/setData", { isPlay: true });
+    player.pause();
+  } else {
+    store.commit("tour/setData", { isPlay: true });
+    player.play(partId.value);
+  }
+};
+
+const progressNum = ref(0);
+
+const metadata = computed(() => store.getters["scene/metadata"]);
+
+const controls = computed(() => metadata.value.controls);
+
+const player = computed(() => store.getters["player"]);
+
+const tours = computed(() => store.getters["tour/tours"]);
+
+const menulist = ref([
+  {
+    icon: "customer_service",
+    name: "客服",
+  },
+  {
+    icon: "guided_shopping",
+    name: "导购",
+  },
+  {
+    icon: "shopping",
+    name: "购物",
+  },
+]);
+
+const categorylist = ref([
+  {
+    id: "all",
+    name: "全部",
+  },
+  {
+    id: "all",
+    name: "香化",
+  },
+  {
+    id: "all",
+    name: "香化",
+  },
+  {
+    id: "all",
+    name: "香化",
+  },
+  {
+    id: "all",
+    name: "香化",
+  },
+  {
+    id: "all",
+    name: "全部",
+  },
+  {
+    id: "all",
+    name: "全部",
+  },
+]);
+
+const brandlist = ref([
+  {
+    img: "show_3d_normal",
+    name: "GAP 盖璞",
+  },
+  {
+    img: "show_3d_normal",
+    name: "MOOST·理MOOST·理MOOST·理MOOST·理MOOST·理",
+  },
+  {
+    img: "show_3d_normal",
+    name: "H&M",
+  },
+  {
+    img: "show_3d_normal",
+    name: "GAP 盖璞",
+  },
+  {
+    img: "show_3d_normal",
+    name: "MOOST·理",
+  },
+  {
+    img: "show_3d_normal",
+    name: "H&M",
+  },
+  {
+    img: "show_3d_normal",
+    name: "GAP 盖璞",
+  },
+  {
+    img: "show_3d_normal",
+    name: "MOOST·理",
+  },
+  {
+    img: "show_3d_normal",
+    name: "H&M",
+  },
+  {
+    img: "show_3d_normal",
+    name: "GAP 盖璞",
+  },
+  {
+    img: "show_3d_normal",
+    name: "MOOST·理",
+  },
+  {
+    img: "show_3d_normal",
+    name: "H&M",
+  },
+]);
+
+const brandScroll = () => {
+  nextTick(() => {
+    let t = setTimeout(() => {
+      clearTimeout(t);
+      new Swiper("#sw-guide", {
+        freeMode: true,
+        slidesPerView: "auto",
+        spaceBetween: 4,
+        on: {
+          touchMove(swiper, e) {
+            e.stopPropagation();
+            e.preventDefault();
+          },
+        },
+      });
+    }, 100);
+  });
+};
+
+const changeFrame = async (type, id) => {
+    if (flying.value || isSelect.value) {
+        return
+    }
+    progressNum.value = 0
+    // recorder.selectFrame(id)
+    let player = await getApp().TourManager.player
+    // player.selectFrame(id)
+    isSelect.value = true
+    if (type == 1) {
+        player.selectPart(id)
+        let f_id = 0
+        if (tours.value[id].frameId) {
+            f_id = tours.value[id].frameId
+        }
+        player.selectFrame(f_id).then(() => {
+            isSelect.value = false
+        })
+        store.commit('tour/setData', {
+            frameId: f_id,
+            partId: id,
+        })
+    } else {
+        player.selectFrame(id).then(() => {
+            isSelect.value = false
+        })
+        store.commit('tour/setData', {
+            frameId: id,
+        })
+    }
+
+}
+const onClickHandler = async () => {
+    if (isPlay.value) {
+        let player = await getApp().TourManager.player
+        player.pause()
+        musicPlayer.resume()
+    }
+}
+const cancelTimer = () => {
+    if (timer) {
+        KanKan.Animate.transitions.cancel(timer)
+        timer = null
+    }
+}
+
+const hanlderTour = async () => {
+  let player = await getApp().TourManager.player;
+  player.on("play", (data) => {
+    musicPlayer.pause(true);
+    // if (tours.value.length > 1) {
+    //     let time = getPartTime(data.partId)
+    //     hanlderTourPartPlay(time)
+    // }
+  });
+  player.on("pause", (tours) => {
+    console.log("pause", player);
+    musicPlayer.resume();
+
+    progressNum.value = 0;
+    cancelTimer();
+    store.commit("tour/setData", { isPlay: false });
+  });
+  player.on("end", (tours) => {
+    musicPlayer.resume();
+    progressNum.value = 100;
+    store.commit("tour/setData", { isPlay: false });
+    cancelTimer();
+  });
+  let currPartId = null;
+  let currProgress = 0;
+  let currFrames = 0;
+  player.on("progress", (data) => {
+    if (tours.value.length == 1) {
+      progressNum.value = data.progress * 100;
+    } else {
+      // let time = getPartTime(data.partId)
+
+      // hanlderTourPartPlay(time)
+
+      if (currPartId != data.partId) {
+        currPartId = data.partId;
+        currFrames = Math.max(tours.value[data.partId].list.length-1,1);
+        currProgress = 0;
+      } else {
+        console.log(currFrames);
+        currProgress += data.progress / currFrames;
+        if (currProgress >= 100) {
+          currProgress = 100;
+        }
+
+        console.log(currProgress);
+
+        progressNum.value = currProgress;
+      }
+    }
+
+    store.commit("tour/setData", { partId: data.partId, frameId: data.frameId, isPlay: true });
+  });
+
+  // nextTick(() => {
+  //     editorMain.value = document.querySelector('.ui-editor-main')
+  // })
+};
+
+onMounted(() => {
+  useApp().then(async (sdk) => {
+    hanlderTour();
+    brandScroll();
+  });
+  nextTick(() => {
+        let player = document.querySelector('.player[name="main"]')
+        player.addEventListener('touchstart', onClickHandler)
+    })
+});
+</script>
+
+<style lang="scss" scoped>
+.root-panel {
+  position: absolute;
+  bottom: 60px;
+  left: 0.2rem;
+  right: 0.2rem;
+  z-index: 99;
+  .guide-panel {
+    width: 100%;
+    height: 50px;
+    position: relative;
+    &::before {
+      position: absolute;
+      bottom: -34px;
+      content: "";
+      background-image: var(--urlbg);
+      background-size: 100% auto;
+      display: inline-block;
+      width: 100%;
+      height: 30px;
+    }
+    .g-con {
+      background: rgba(0, 0, 0, 0.3);
+      border-radius: 4px;
+      border: 1px solid rgba(255, 255, 255, 0.2);
+      width: 90%;
+      height: 100%;
+      margin: 0 auto;
+      padding: 6px 10px;
+      box-sizing: border-box;
+      display: flex;
+      align-items: center;
+      .back {
+        text-align: center;
+        width: 40px;
+        font-size: 0;
+        margin-right: 10px;
+        > div {
+          font-size: 12px;
+          margin-top: 2px;
+        }
+      }
+      #sw-guide {
+        width: 100%;
+        flex: auto;
+        overflow: hidden;
+        padding-right: 2px;
+        position: relative;
+        &::after {
+          position: absolute;
+          right: -1px;
+          bottom: 0;
+          content: "";
+          display: inline-block;
+          height: 100%;
+          z-index: 99;
+          width: 17px;
+          background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #000000 100%);
+          opacity: 0.4;
+          pointer-events: none;
+        }
+        > ul {
+          > li {
+            width: 70px;
+            height: 36px;
+            border-radius: 2px;
+            position: relative;
+            font-size: 0;
+            overflow: hidden;
+            background-size: cover;
+            > img {
+              width: 100%;
+            }
+            > div {
+              width: 100%;
+              position: absolute;
+              font-size: 12px;
+              left: 50%;
+              top: 50%;
+              transform: translate(-50%, -50%);
+              text-overflow: ellipsis;
+              white-space: nowrap;
+              overflow: hidden;
+              text-align: center;
+              padding: 0 4px;
+              box-sizing: border-box;
+              word-break: break-all;
+            }
+            &.active {
+              &::before {
+                position: absolute;
+                top: 0;
+                left: 0;
+                width: 100%;
+                height: 100%;
+                content: "";
+                display: inline-block;
+                background: rgba(24, 24, 24, 0.5);
+              }
+              color: var(--editor-main-color);
+
+              .bar {
+                display: inline-block;
+                width: 70%;
+                height: 2px;
+                background: rgba(0, 0, 0, 0.5);
+                border-radius: 2px;
+                position: absolute;
+                z-index: 9;
+                bottom: 10%;
+                left: 50%;
+                transform: translateX(-50%);
+                &::after {
+                  content: "";
+                  width: var(--w);
+                  background: var(--editor-main-color);
+                  height: 100%;
+                  position: absolute;
+                  left: 0;
+                  top: 0;
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 390 - 0
src/components/Controls/Panel/Main.vue

@@ -0,0 +1,390 @@
+<template>
+  <Panel v-show="player.showWidgets">
+    <div class="menu color">
+      <div class="logo">
+        <img :src="require('@/assets/images/icon/logo.png')" alt="" />
+        <p>cdf澳门上葡京</p>
+      </div>
+      <div class="vline"></div>
+      <ul>
+        <li v-if="tours.length > 0">
+          <ui-icon type="preview" @click.stop="playTour"></ui-icon>
+          <div>導覽</div>
+        </li>
+        <li v-for="(item, i) in menulist" :key="i">
+          <ui-icon :type="item.icon"></ui-icon>
+          <div>{{ item.name }}</div>
+        </li>
+      </ul>
+    </div>
+
+    <div class="toolbar color">
+      <div class="navigation">
+        <div class="h3">專櫃導航</div>
+        <div class="swiper-container" id="sw-navigation">
+          <ul class="swiper-wrapper">
+            <li class="swiper-slide" v-for="(item, i) in brandlist" :key="i">
+              <img :src="item.shopLogo" alt="" />
+              <div>{{ item.shopName }}</div>
+            </li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="category">
+        <div class="swiper-container" id="sw-category">
+          <ul class="swiper-wrapper">
+            <li
+              class="swiper-slide"
+              :class="{ categoryactive: '' == currentCategory.id }"
+              @click.stop="
+                onClickCategory({
+                  id: '',
+                  categoryName: '全部',
+                })
+              "
+            >
+              <div>全部</div>
+            </li>
+            <li
+              @click.stop="onClickCategory(item)"
+              :class="{ categoryactive: item.id == currentCategory.id }"
+              class="swiper-slide"
+              v-for="(item, i) in categorylist"
+              :key="i"
+            >
+              <div>{{ item.categoryName }}</div>
+            </li>
+          </ul>
+        </div>
+      </div>
+    </div>
+  </Panel>
+</template>
+
+<script setup>
+import { useStore } from "vuex";
+import { onMounted, watch, computed, reactive, ref, nextTick } from "vue";
+import Panel from "@/views/Panel.vue";
+import { useApp, getApp } from "@/app";
+import * as apis from "@/apis/index.js";
+
+const store = useStore();
+
+const currentCategory = ref({
+  id: "",
+  categoryName: "全部",
+});
+
+const isPlay = computed(() => {
+  let status = store.getters["tour/isPlay"];
+  let map = document.querySelector(".kankan-app div[xui_min_map]");
+  if (map) {
+    if (status) {
+      map.classList.add("disabled");
+    } else {
+      map.classList.remove("disabled");
+    }
+  }
+  return status;
+});
+
+const partId = computed(() => store.getters["tour/partId"]);
+
+const playTour = async () => {
+  let player = await getApp().TourManager.player;
+  if (isPlay.value) {
+    store.commit("tour/setData", { isPlay: true });
+    player.pause();
+  } else {
+    store.commit("tour/setData", { isPlay: true });
+    player.play(partId.value);
+  }
+};
+
+const metadata = computed(() => store.getters["scene/metadata"]);
+
+const player = computed(() => store.getters["player"]);
+
+const tours = computed(() => store.getters["tour/tours"]);
+
+const menulist = ref([
+  {
+    icon: "customer_service",
+    name: "客服",
+  },
+  {
+    icon: "guided_shopping",
+    name: "导购",
+  },
+  {
+    icon: "shopping",
+    name: "购物",
+  },
+]);
+
+const categorylist = ref([]);
+
+const brandlist = ref([
+  {
+    img: "show_3d_normal",
+    name: "GAP 盖璞",
+  },
+  {
+    img: "show_3d_normal",
+    name: "MOOST·理MOOST·理MOOST·理MOOST·理MOOST·理",
+  },
+  {
+    img: "show_3d_normal",
+    name: "H&M",
+  },
+  {
+    img: "show_3d_normal",
+    name: "GAP 盖璞",
+  },
+  {
+    img: "show_3d_normal",
+    name: "MOOST·理",
+  },
+  {
+    img: "show_3d_normal",
+    name: "H&M",
+  },
+  {
+    img: "show_3d_normal",
+    name: "GAP 盖璞",
+  },
+  {
+    img: "show_3d_normal",
+    name: "MOOST·理",
+  },
+  {
+    img: "show_3d_normal",
+    name: "H&M",
+  },
+  {
+    img: "show_3d_normal",
+    name: "GAP 盖璞",
+  },
+  {
+    img: "show_3d_normal",
+    name: "MOOST·理",
+  },
+  {
+    img: "show_3d_normal",
+    name: "H&M",
+  },
+]);
+
+const brandScroll = () => {
+  nextTick(() => {
+    let t = setTimeout(() => {
+      clearTimeout(t);
+      new Swiper("#sw-navigation", {
+        freeMode: true,
+        slidesPerView: "auto",
+        centeredSlides: false,
+        spaceBetween: 10,
+        grid: {
+          rows: 2,
+        },
+        on: {
+          touchMove(swiper, e) {
+            e.stopPropagation();
+            e.preventDefault();
+          },
+        },
+      });
+
+      new Swiper("#sw-category", {
+        freeMode: true,
+        slidesPerView: "auto",
+        spaceBetween: 10,
+        on: {
+          touchMove(swiper, e) {
+            e.stopPropagation();
+            e.preventDefault();
+          },
+        },
+      });
+    }, 100);
+  });
+};
+
+const getCategorylist = async () => {
+  let res = await apis.get_category_list({});
+  categorylist.value = res.data;
+  brandScroll();
+};
+
+const onClickCategory = (item) => {
+  console.log(item);
+  currentCategory.value.id = item.id;
+  currentCategory.value.categoryName = item.categoryName;
+};
+
+watch(
+  () => currentCategory,
+  (val, old) => {
+    getShoplist();
+  },
+  {
+    deep: true,
+  }
+);
+
+const getShoplist = async () => {
+  let res = await apis.get_shop_list({
+    categoryId: currentCategory.value.id,
+  });
+  brandlist.value = res.data;
+  brandScroll();
+};
+
+onMounted(() => {
+  useApp().then(async (sdk) => {
+    getCategorylist();
+    getShoplist();
+  });
+});
+</script>
+
+<style lang="scss" scoped>
+.menu {
+  width: 100%;
+  border-radius: 6px;
+  border: 1px solid rgba(255, 255, 255, 0.2);
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  justify-content: space-between;
+  box-sizing: border-box;
+  position: relative;
+  align-items: center;
+  height: 48px;
+  padding: 0 12px;
+  text-align: center;
+  .logo {
+    width: 90px;
+    transform: translateY(-24%);
+    > img {
+      width: 100%;
+      border-radius: 4px;
+    }
+    > p {
+      margin: 2px 0;
+      font-size: 10px;
+    }
+  }
+  .vline {
+    width: 1px;
+    height: 14px;
+    background: #fff;
+  }
+  > ul {
+    display: flex;
+    align-items: center;
+    font-size: 0;
+    justify-content: flex-end;
+    > li {
+      margin-left: 0.5rem;
+      &:first-of-type {
+        margin-left: 0;
+      }
+      > div {
+        margin-top: 4px;
+        font-size: 10px;
+      }
+    }
+  }
+}
+
+.toolbar {
+  width: 100%;
+  border-radius: 6px;
+  border: 1px solid rgba(255, 255, 255, 0.2);
+  margin-top: 8px;
+  margin-bottom: 30px;
+  background: rgba(0, 0, 0, 0.5);
+  .navigation {
+    padding: 8px 14px;
+
+    .h3 {
+      font-size: 14px;
+    }
+    .swiper-container {
+      width: 100%;
+      height: 130px;
+      overflow: hidden;
+      margin-top: 8px;
+      padding-right: 2px;
+      position: relative;
+
+      &::after {
+        position: absolute;
+        right: 0;
+        bottom: 0;
+        content: "";
+        display: inline-block;
+        height: 100%;
+        z-index: 99;
+        width: 17px;
+        background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #000000 100%);
+        opacity: 0.4;
+        pointer-events: none;
+      }
+      > ul {
+        margin: 0 !important;
+        > li {
+          width: 60px;
+          height: 60px;
+          border-radius: 4px;
+          position: relative;
+          font-size: 0;
+          overflow: hidden;
+          > img {
+            width: 100%;
+          }
+          > div {
+            width: 100%;
+            position: absolute;
+            bottom: -1px;
+            font-size: 12px;
+            left: 0;
+            background: rgba(0, 0, 0, 0.5);
+            text-overflow: ellipsis;
+            white-space: nowrap;
+            overflow: hidden;
+            text-align: center;
+            padding: 2px 4px;
+            box-sizing: border-box;
+            word-break: break-all;
+          }
+        }
+      }
+    }
+  }
+  .category {
+    padding: 8px 14px;
+    border-top: 1px solid rgba(255, 255, 255, 0.2);
+
+    .swiper-container {
+      width: 100%;
+      overflow: hidden;
+      > ul {
+        > li {
+          width: auto;
+          color: rgba(255, 255, 255, 0.5);
+          > div {
+            width: 100%;
+            font-size: 14px;
+            padding: 2px 4px;
+          }
+        }
+        .categoryactive {
+          color: #fff;
+        }
+      }
+    }
+  }
+}
+</style>

+ 106 - 0
src/components/Controls/RightButtons.vue

@@ -0,0 +1,106 @@
+<template>
+    <div class="controls-right-buttons" :class="{ disabled: isPlay }">
+        <div @click="onVRClick" v-if="controls.showVR">
+            <ui-icon type="vr" :tip="$t('mode.vr')" tipV="top"></ui-icon>
+        </div>
+        <div :class="{ playing: showMusicPlaying }" v-if="showMusic" @click="onMusicClick">
+            <ui-icon type="music" :tip="showMusicPlaying ? '' : $t('mode.music')" tipV="top"></ui-icon>
+        </div>
+        <div @click="onFullScreen">
+            <ui-icon :type="isFullscreen ? 'scene_window' : 'full'" :tip="isFullscreen ? $t('mode.exitFullScene') : $t('mode.fullScene')" tipV="top"></ui-icon>
+            <!-- <i class="iconfont" :class="isFullscreen ? 'icon-scene_window' : 'icon-full'"></i> -->
+        </div>
+    </div>
+</template>
+<script setup>
+import { computed, ref, onMounted } from 'vue'
+import { useStore } from 'vuex'
+import { useMusicPlayer } from '@/utils/sound'
+import { Dialog } from '@/global_components/'
+import { useI18n } from '@/i18n'
+const { t } = useI18n({ useScope: 'global' })
+const store = useStore()
+const controls = computed(() => store.getters['scene/metadata'].controls || {})
+const showMusic = computed(() => store.getters['scene/metadata'].music)
+const showMusicPlaying = ref(false)
+const musicPlayer = useMusicPlayer()
+const isFullscreen = ref(false)
+const onVRClick = () => {
+    Dialog.toast(t('limit.viewInVr'))
+}
+const isPlay = computed(() => {
+    return store.getters['tour/isPlay']
+})
+const onMusicClick = () => {
+    showMusicPlaying.value ? musicPlayer.pause() : musicPlayer.play()
+}
+const onFullScreen = () => {
+    let element = document.documentElement
+    if (isFullscreen.value) {
+        if (document.exitFullscreen) {
+            document.exitFullscreen()
+        } else if (document.webkitCancelFullScreen) {
+            document.webkitCancelFullScreen()
+        } else if (document.mozCancelFullScreen) {
+            document.mozCancelFullScreen()
+        } else if (document.msExitFullscreen) {
+            document.msExitFullscreen()
+        }
+    } else {
+        if (element.requestFullscreen) {
+            element.requestFullscreen()
+        } else if (element.webkitRequestFullScreen) {
+            element.webkitRequestFullScreen()
+        } else if (element.mozRequestFullScreen) {
+            element.mozRequestFullScreen()
+        } else if (element.msRequestFullscreen) {
+            element.msRequestFullscreen()
+        }
+    }
+    // 改变当前全屏状态
+    //this.isFullscreen = !this.isFullscreen;
+}
+onMounted(() => {
+    let events = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
+    events.forEach((item, index) => {
+        window.addEventListener(item, () => {
+            isFullscreen.value = !isFullscreen.value
+        })
+    })
+})
+musicPlayer.on('play', () => (showMusicPlaying.value = true))
+musicPlayer.on('pause', () => (showMusicPlaying.value = false))
+</script>
+<style lang="scss" scoped>
+.controls-right-buttons {
+    display: flex;
+    margin-right: 20px;
+    margin-bottom: 20px;
+    > div {
+        cursor: pointer;
+        margin-left: 10px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 34px;
+        height: 34px;
+        background-color: rgba(0, 0, 0, 0.3);
+        border-radius: 50%;
+        // pointer-events: all;
+        font-size: 18px;
+    }
+    .playing {
+        animation: spinner 4s linear infinite;
+    }
+}
+
+@keyframes spinner {
+    0% {
+        transform: rotate(0);
+    }
+
+    to {
+        transform: rotate(1turn);
+    }
+}
+</style>

+ 77 - 0
src/components/Header/index.vue

@@ -0,0 +1,77 @@
+<template>
+    <ui-editor-head :class="{ disable }">
+        <span class="title">{{ title }}</span>
+        <ul class="opts">
+            <li><i class="iconfont icon-course1"></i></li>
+            <li class="split"><b></b></li>
+            <li @click="onSave"><i class="iconfont icon-save"></i></li>
+            <li @click="onPublish"><i class="iconfont icon-publish"></i></li>
+        </ul>
+    </ui-editor-head>
+</template>
+<script setup>
+import { computed } from 'vue'
+import { useStore } from 'vuex'
+import { Loading, Dialog } from '@/global_components/'
+const store = useStore()
+const title = computed(() => store.getters['scene/metadata'].title)
+const disable = computed(() => store.getters.outsideDisable)
+const onSave = () => {
+    Loading.show()
+    store.dispatch('scene/save').then(res => {
+        console.log(res)
+        Loading.hide()
+        if (res) {
+            Dialog.toast('保存成功')
+        }
+    })
+}
+const onPublish = () => {
+    Loading.show()
+    store.dispatch('scene/publish').then(res => {
+        Loading.hide()
+        Dialog.toast('发布成功')
+    })
+}
+</script>
+<style lang="scss" scoped>
+.ui-editor-head {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+.title {
+    font-size: 16px;
+}
+.opts {
+    position: absolute;
+    right: 0;
+    top: 0;
+    height: 100%;
+    display: flex;
+    li {
+        cursor: pointer;
+        height: 100%;
+        display: flex;
+        align-items: center;
+        padding: 0 10px;
+        margin-left: 5px;
+        color: var(--editor-font-color);
+        &:hover {
+            color: #fff;
+        }
+        &.split {
+            pointer-events: none;
+        }
+    }
+    i {
+        font-size: 20px;
+    }
+    b {
+        display: block;
+        width: 2px;
+        height: 16px;
+        background-color: rgba(255, 255, 255, 0.16);
+    }
+}
+</style>

+ 118 - 0
src/components/Header/state.js

@@ -0,0 +1,118 @@
+const player = {
+    lang: 'zh',
+    /**
+     * 加载进度
+     */
+    // progres: config.appenv == "shipin" ? -1 : 0,
+    /**
+     * 初始状态模式
+     */
+    mode: 'pano',
+    /**
+     * 可进行模式切换
+     */
+    modeCanSwitch: true,
+    /**
+     * 显示所有UI
+     */
+    showUI: true,
+    /**
+     *
+     */
+    showAppUI: true,
+    /**
+     * 单独控制小地图
+     */
+    showUIMap: false,
+    showMore: false,
+    showDescription: false,
+    showToolbar: true,
+    showUserGuide: true,
+    /**
+     * VR展示
+     */
+    showVR: false,
+    showVRType: null,
+    /**
+     * 显示小地图
+     */
+    showMap: true,
+    /**
+     * 显示场景信息
+     */
+    showInfo: false,
+    /**
+     * 显示闪光灯
+     */
+    showFlash: false,
+    /**
+     * 显示测距模式
+     */
+    showMeasure: false,
+    /**
+     * 显示所有挂件
+     */
+    showWidgets: true,
+    /**
+     * 显示截屏模式
+     */
+    showSnapshot: false,
+    /**
+     * 显示拆分模式
+     */
+    showSplit: false,
+    /**
+     * 显示位置图标
+     */
+    showPositionMarks: false,
+    /**
+     * 显示导航按钮状态
+     */
+    showNavigations: {
+        /**
+         * VR
+         */
+        vr: true,
+        /**
+         * 小地图
+         */
+        map: true,
+        /**
+         * 俯视图
+         */
+        cad: true,
+        /**
+         * 平面模式
+         */
+        m2d: true,
+        /**
+         * 3d模式
+         */
+        m3d: true,
+        /**
+         * 漫游模式
+         */
+        pano: true,
+        /**
+         * 标尺
+         */
+        rule: true,
+        /**
+         * 自动导览
+         */
+        guide: true,
+        /**
+         * 测距
+         */
+        measure: true,
+    },
+    /**
+     * 第二个player是iframe
+     */
+    playbIsIframe: false,
+    /**
+     * 第二个player iframe地址
+     */
+    playerbIframeUrl: '',
+}
+export default player

+ 767 - 0
src/components/Information/View.Mobile.vue

@@ -0,0 +1,767 @@
+<template>
+    <div class="header" @touchmove.prevent>
+        <div class="left" :class="{ show: player.showVR }">
+            <div v-show="mode != 'panorama' && !isApp && player.showWidgets" class="back-pano" @click="onChangeMode">
+                <ui-icon type="show_back"></ui-icon>
+            </div>
+            <div v-show="mode == 'panorama'" class="back" @click="onBack">
+                <ui-icon type="_back"></ui-icon>
+            </div>
+        </div>
+        <div class="title" :class="{ up: player.showDescription, drak: mode != 'panorama', empty: !description }" @click="onShowDescription" v-show="player.showWidgets">
+            <div>
+                <span>
+                    {{ metadata.title }}
+                    <i class="iconfont icon-pull-down"></i>
+                </span>
+            </div>
+        </div>
+        <div class="right"  v-show="player.showWidgets" @click="onShowMore">
+            <i class="iconfont icon-show_more_share"></i>
+        </div>
+        <transition appear name="custom-classes-transition" enter-active-class="animated fadeInUp short faster" leave-active-class="animated fadeOutDown short faster">
+            <div class="content" :class="{ drak: mode != 'panorama' }" v-if="player.showDescription" @click="onShowDescription">
+                <div>
+                    <div v-html="description"></div>
+                </div>
+            </div>
+        </transition>
+        <template v-if="isApp">
+            <transition appear name="custom-classes-transition" enter-active-class="animated slideInUp faster" leave-active-class="animated slideOutDown faster">
+                <div class="app-share" v-if="showShare">
+                    <ul :class="{ flex: player.lang == 'zh' }">
+                        <li @click="onShare('weixin')">
+                            <i class="iconfont icon_wechat"></i>
+                            <div>微信</div>
+                        </li>
+                        <li @click="onShare('weixinFriend')">
+                            <i class="iconfont icon_friend"></i>
+                            <div>朋友圈</div>
+                        </li>
+                        <li @click="onShare('qq')">
+                            <i class="iconfont icon_qq"></i>
+                            <div>QQ</div>
+                        </li>
+                        <li v-if="player.lang != 'zh'" @click="onShare('faceBook')">
+                            <i class="iconfont iconicon_share_facebook"></i>
+                            <div>Facebook</div>
+                        </li>
+                        <li v-if="player.lang != 'zh'" @click="onShare('whatsApp')">
+                            <i class="iconfont iconicon_share_whatsapp"></i>
+                            <div>WhatsApp</div>
+                        </li>
+                        <li @click="onShare('copy')">
+                            <i class="iconfont iconlink"></i>
+                            <div>复制链接</div>
+                        </li>
+                    </ul>
+                    <div @click="showShare = false">取消</div>
+                </div>
+            </transition>
+        </template>
+        <div class="url-share" v-show="showCopy">
+            <div>
+                <div class="tips">
+                    <h4>分享链接给好友</h4>
+                    <i class="iconfont iconshow_cancel" @click="showCopy = false"></i>
+                </div>
+                <div class="url">{{ copyLink }}</div>
+                <div class="btns">
+                    <ui-button class="cancel" @click="showCopy = false">取消</ui-button>
+                    <ui-button class="primary" :data-clipboard-text="copyLink" ref="copy$">一键复制</ui-button>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+<script setup>
+import ClipboardJS from 'clipboard'
+import browser from '@/utils/browser'
+import { useStore } from 'vuex'
+import { Dialog } from '@/global_components/'
+import { IsApp, MessageToApp, NotchHeight } from '@/utils/platform'
+import { useMusicPlayer } from '@/utils/sound'
+import { onMounted, watch, computed, ref, nextTick } from 'vue'
+import { useApp, getApp } from '@/app'
+
+let share_url = browser.getURLParam('share_url')
+if (share_url) {
+    share_url = decodeURIComponent(share_url)
+} else {
+    share_url = location.href.split('#')[0]
+}
+const musicPlayer = useMusicPlayer()
+const store = useStore()
+const metadata = computed(() => store.getters['scene/metadata'])
+const controls = computed(() => {
+    return metadata.value.controls
+})
+const showMusicPlaying = ref(false)
+const showMusic = computed(() => store.getters['scene/metadata'].music)
+const player = computed(() => store.getters['player'])
+
+const copy$ = ref(null)
+const copyLink = ref(share_url)
+const isApp = ref(IsApp)
+const showCopy = ref(false)
+const showShare = ref(false)
+const isMusicPlaying = ref(false)
+
+const mode = computed(() => store.getters['mode'])
+const description = computed(() => metadata.value.description)
+watch(
+    () => showCopy.value,
+    (val, old) => {
+        store.commit('SetPlayerOptions', {
+            showMap: !showCopy.value,
+            showToolbar: !showCopy.value,
+        })
+    },
+    {
+        deep: true,
+    }
+)
+watch(
+    () => showShare.value,
+    (val, old) => {
+        store.commit('SetPlayerOptions', {
+            showMap: !showShare.value,
+            showToolbar: !showShare.value,
+        })
+    },
+    {
+        deep: true,
+    }
+)
+
+onMounted(() => {
+    new ClipboardJS(copy$.value.$el).on('success', function (e) {
+        e.clearSelection()
+        showCopy.value = false
+        Dialog.toast({ content: '场景链接复制成功', type: 'success' })
+    })
+
+    nextTick(() => {
+        document.querySelector('.player').addEventListener('touchstart', () => {
+            if (player.value.showMore) {
+                store.commit('SetPlayerOptions', {
+                    showMore: false,
+                    showMap: true,
+                    showToolbar: true,
+                })
+            } else if (player.value.showDescription) {
+                store.commit('SetPlayerOptions', {
+                    showDescription: false,
+                    showMap: true,
+                    showToolbar: true,
+                })
+            }
+        })
+
+        window.Back = () => {
+            onBack()
+        }
+        musicPlayer.on('play', () => (showMusicPlaying.value = true))
+        musicPlayer.on('pause', () => (showMusicPlaying.value = false))
+    })
+})
+const onBack = () => {
+    player.value.showVR && store.commit('showVR')
+}
+const onShowMore = () => {
+    showCopy.value = true
+    // let show = !player.value.showMore
+    // store.commit('SetPlayerOptions', {
+    //     showMore: show,
+    //     showMap: show == false,
+    //     showToolbar: show == false,
+    //     showDescription: false,
+    // })
+}
+const onShowDescription = () => {
+    let show = !player.value.showDescription
+    store.commit('SetPlayerOptions', {
+        showMore: false,
+        showMap: show == false,
+        showToolbar: show == false,
+        showDescription: show,
+    })
+}
+const onMusicClick = () => {
+    showMusicPlaying.value ? musicPlayer.pause() : musicPlayer.play()
+}
+const onMenuClick = name => {
+    store.commit('SetPlayerOptions', {
+        showMore: false,
+        showDescription: false,
+        showMap: true,
+        showToolbar: true,
+    })
+
+    nextTick(() => {
+        if (name == 'music') {
+            onMusicClick()
+            // if (isMusicPlaying.value) {
+            //     musicPlayer.pause()
+            // } else {
+            //     musicPlayer.play()
+            // }
+        } else if (name == 'share') {
+            if (isApp.value) {
+                showShare.value = true
+            } else {
+                showCopy.value = true
+            }
+        } else if (name === 'measure') {
+            this.$bus.emit('measure/Handle', 'start')
+        } else if (name == 'vr') {
+            store.commit('showVR')
+        }
+    })
+}
+const onShare = name => {
+    if (name == 'copy') {
+        showShare.value = false
+        this.$nextTick(() => {
+            showCopy.value = true
+        })
+    } else {
+        MessageToApp(`Share-${name}`)
+        showCopy.value = false
+        showShare.value = false
+    }
+}
+const onChangeMode = () => {
+    store.commit('setMode', 'panorama')
+}
+</script>
+
+<style lang="scss" scoped>
+.disable {
+    opacity: 1;
+}
+.header {
+    position: absolute;
+    top: 0.3rem;
+    left: 0;
+    height: 40px;
+    width: 100%;
+    z-index: 101;
+    color: #fff;
+    display: flex;
+    align-items: center;
+    text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.4);
+    &.app {
+        top: 1rem;
+    }
+    .left {
+        width: 1rem;
+        height: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        &.show {
+            visibility: visible;
+            pointer-events: auto;
+            .back {
+                visibility: visible;
+            }
+        }
+        .back {
+            width: 0.78rem;
+            height: 0.78rem;
+            border-radius: 50%;
+            background-color: rgba(0, 0, 0, 0.3);
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            visibility: hidden;
+            position: relative;
+            i {
+                font-size: 0.3rem;
+                width: auto;
+                position: static;
+            }
+        }
+        .back-pano {
+            width: 100%;
+            height: 100%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            // padding-left: 15px;
+            position: relative;
+            i {
+                font-size: 0.6rem;
+                position: static;
+            }
+        }
+    }
+    .right {
+        position: relative;
+        width: 1.28rem;
+        height: 100%;
+        padding-right: 30px;
+        display: flex;
+        align-items: center;
+        justify-content: flex-end;
+        z-index: 11;
+        > i {
+            font-size: 0.45rem;
+            color: rgba(255, 255, 255, 0.88);
+        }
+        > div {
+            position: absolute;
+            display: flex;
+            flex-direction: column;
+            right: 10px;
+            top: 1.3rem;
+            padding: 4px 0.42105rem;
+            background-color: rgba(0, 0, 0, 0.5);
+            border-radius: 5px;
+            &::after {
+                content: '';
+                position: absolute;
+                top: -5px;
+                right: 0.3rem;
+                width: 0;
+                height: 0;
+                border-width: 0 5px 5px;
+                border-style: solid;
+                border-color: transparent transparent rgba(0, 0, 0, 0.5);
+            }
+            &.flex {
+                li {
+                    span {
+                        flex: 1;
+                        width: auto;
+                        // min-width: 2.8rem;
+                    }
+                }
+            }
+            li {
+                position: relative;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                // margin-top: 0.3rem;
+                font-size: 0;
+                // height: .5333rem;
+                padding: 0.1333rem 0 0.2667rem;
+                i {
+                    // position: absolute;
+                    // left: 0;
+                    // top: 0;
+                    // font-size: 0.45rem;
+                    font-size: 0.3733rem;
+                    margin-right: 0.4rem;
+                }
+                b {
+                    position: absolute;
+                    left: 0.32rem;
+                    top: 0.33rem;
+                    width: 7px;
+                    height: 7px;
+                    border-radius: 50%;
+                    background-color: #00c2c4;
+                    i {
+                        color: #fff;
+                        font-size: 12px;
+                        transform: scale(0.3, 0.3);
+                        top: -4px;
+                        left: -3px;
+                        position: absolute;
+                    }
+                }
+                span {
+                    width: 1.89474rem;
+                    white-space: nowrap;
+                    text-align: left;
+                    // padding-bottom: 0.3rem;
+                    // margin-left: 0.75rem;
+                    font-size: 0.3733rem;
+                    // text-indent: 0.2rem;
+                    // padding-right: 0.2rem;
+                }
+            }
+
+            .home {
+                border-top: solid 1px rgba(255, 255, 255, 0.4);
+                a {
+                    width: 1.89474rem;
+                    margin-top: 0.3rem;
+                    margin-bottom: 0.1rem;
+                    margin-left: auto;
+                    margin-right: auto;
+                    display: block;
+                    text-decoration: none;
+                    overflow: hidden;
+
+                    img {
+                        width: 100%;
+                        outline: none;
+                        border: none;
+                    }
+                }
+            }
+        }
+    }
+    .title {
+        display: flex;
+        flex: 1;
+        width: 100%;
+        height: 100%;
+        font-size: 14px;
+        letter-spacing: 1px;
+        align-items: center;
+        justify-content: center;
+        > div {
+            display: flex;
+            transition: background 0.3s ease, min-width 0.3s ease, border-radius 0.3s ease;
+            align-items: center;
+            justify-content: center;
+            padding-right: 12px;
+            padding-left: 12px;
+            white-space: nowrap;
+            position: absolute;
+            height: 100%;
+            min-width: 100%;
+            overflow: visible;
+            pointer-events: none;
+            background: linear-gradient(90deg,transparent,rgba(0,0,0,.2) 29%,rgba(0,0,0,.2) 69%,transparent);
+        }
+        span {
+            display: inline-block;
+            position: relative;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            max-width: 7rem;
+            display: inline-block;
+            padding-right: 20px;
+             i {
+                position: absolute;
+                right: 0;
+                color: #fff;
+                font-size: 10px;
+                top: 50%;
+                -webkit-transform: translateY(-50%);
+                transform: translateY(-50%);
+                vertical-align: bottom;
+                line-height: normal;
+            }
+        }
+       
+
+        &.up {
+            i {
+                transform: translateY(-50%) rotate(180deg);
+            }
+            > div {
+                min-width: 0;
+                //position: static;
+                flex-shrink: 0;
+                background-color: rgba(0, 0, 0, 0.5);
+                border-radius: 1.15789rem;
+            }
+            &.drak {
+                > div {
+                    background-color: rgba(0, 0, 0, 0.8);
+                }
+            }
+        }
+
+        &.empty {
+            i {
+                display: none;
+            }
+        }
+    }
+    .content {
+        position: absolute;
+        top: 1.2rem;
+        left: 0.92105rem;
+        right: 0.92105rem;
+        padding: 10px;
+        background: rgba(0, 0, 0, 0.5);
+        border-radius: 5px;
+        font-size: 0.36842rem;
+        text-align: center;
+        &.drak {
+            background: rgba(0, 0, 0, 0.8);
+        }
+        > div {
+            display: inline-block;
+            text-align: left;
+            letter-spacing: 1px;
+            word-break: break-all;
+            white-space: normal;
+            line-height: 1.5;
+            // h4 {
+            //     margin: 0;
+            //     padding: 0;
+            //     margin-bottom: 0.28rem;
+            //     font-size: 0.43rem;
+            //     padding-left: 0.18789rem;
+            //     width: 100%;
+            //     position: relative;
+            //     &::before {
+            //         content: "";
+            //         position: absolute;
+            //         left: 0;
+            //         top: 50%;
+            //         height: 80%;
+            //         transform: translateY(-50%);
+            //         width: 2px;
+            //         background-color: var(--editor-main-color);
+            //     }
+            // }
+
+            :deep(p) {
+                word-break: break-word;
+            }
+
+            :deep(a) {
+                color: var(--editor-main-color);
+            }
+        }
+        // &::after {
+        //     content: "";
+        //     position: absolute;
+        //     top: -6px;
+        //     left: 50%;
+        //     margin-left: -3px;
+        //     width: 0;
+        //     height: 0;
+        //     border-width: 0 7px 6px;
+        //     border-style: solid;
+        //     border-color: transparent transparent rgba(0, 0, 0, 0.5);
+        // }
+    }
+
+    .url-share {
+        position: fixed;
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        background-color: rgba(0, 0, 0, 0.1);
+        > div {
+            position: absolute;
+            left: 0.6rem;
+            right: 0.6rem;
+            top: 50vh;
+            transform: translateY(-50%);
+            border-radius: 4px;
+            background-color: rgba(0, 0, 0, 0.5);
+            padding: 0.31579rem;
+            font-size: 0.36842rem;
+            .tips {
+                display: flex;
+                align-items: center;
+                justify-content: space-between;
+                h4 {
+                    margin: 0;
+                    font-size: 0.42105rem;
+                }
+                i {
+                    font-size: 0.6rem;
+                }
+            }
+            .url {
+                display: -webkit-box;
+                color: #fff;
+                text-align: center;
+                padding: 0.26316rem;
+                width: 100%;
+                height: 1.8rem;
+                margin: 0.7rem 0rem;
+                border-radius: 0.07rem;
+                line-height: 1.99;
+                font-size: 0.36842rem;
+                background-color: rgba(0, 0, 0, 0.35);
+                overflow: hidden;
+                text-overflow: ellipsis;
+                word-break: break-all;
+                word-wrap: break-word;
+                -webkit-line-clamp: 2; //在第几行加省略号
+                -webkit-box-orient: vertical;
+            }
+            .btns {
+                display: flex;
+                justify-content: space-between;
+                button {
+                    font-size: 0.36842rem;
+                    width: 47%;
+                    height: 1.05263rem;
+                    border-radius: 1.05263rem;
+                    &.submit {
+                        background: #00c2c4;
+                    }
+                }
+            }
+        }
+    }
+
+    .app-share {
+        position: fixed;
+        left: 0;
+        bottom: 0;
+        width: 100%;
+        background-color: #fff;
+        color: var(--editor-main-color);
+        text-shadow: none;
+        border-radius: 0.146667rem 0.146667rem 0px 0px;
+        i {
+            // font-size: 2rem;
+            font-size: 1rem;
+            margin: 0.666667rem 0 0.106667rem;
+            display: inline-block;
+        }
+        ul {
+            // display: flex;
+            // justify-content: space-around;
+            overflow: hidden;
+            div {
+                text-align: center;
+                // margin-top: -0.3rem;
+                font-size: 0.34rem;
+            }
+            li {
+                width: 33.3%;
+                float: left;
+                // padding-bottom: 0.5rem;
+                overflow: hidden;
+                text-align: center;
+                // i{
+                //   font-size: 2rem;
+                // }
+            }
+            &.flex {
+                display: flex;
+                li {
+                    float: none;
+                }
+            }
+        }
+        > div {
+            height: 1.293333rem;
+            font-size: 16px;
+            text-align: center;
+            // padding: 0.5rem;
+            border-top: solid 1px #eeeeee;
+            margin-top: 0.466667rem;
+            line-height: 1.293333rem;
+        }
+    }
+}
+
+@media (orientation: landscape) {
+    .header {
+        top: 0.2rem;
+        height: 0.7rem;
+        &.app {
+            top: 0.2rem;
+        }
+        .left {
+            .back-pano {
+                i {
+                    font-size: 0.5rem;
+                }
+            }
+        }
+        .right {
+            > i {
+                font-size: 0.5rem;
+            }
+            > div {
+                right: 10px;
+                top: 0.75rem;
+                padding: 4px 0.2rem;
+                &::after {
+                    right: 0.23rem;
+                }
+                li {
+                    margin-top: 0.15rem;
+                    i {
+                        font-size: 0.3rem;
+                    }
+                    b {
+                        left: 0.2rem;
+                        top: 0.15rem;
+                    }
+                    span {
+                        padding-bottom: 0.15rem;
+                        margin-left: 0.5rem;
+                        font-size: 0.25rem;
+                    }
+                }
+
+                .home {
+                    a {
+                        margin-top: 0.15rem;
+                        margin-bottom: 0rem;
+                        width: 1.4rem;
+                    }
+                }
+            }
+        }
+        .title {
+            font-size: 0.3rem;
+        }
+        .content {
+            top: 1rem;
+            left: 2rem;
+            right: 2rem;
+            padding: 10px;
+
+            > div {
+                max-height: 3rem;
+                font-size: 0.25rem;
+                line-height: 1.5;
+            }
+        }
+    }
+}
+
+// ipad 横屏
+@media only screen and (min-device-width: 768px) and (orientation: landscape) {
+    .header {
+        height: 0.8rem;
+        .title {
+            i {
+                margin-bottom: 0;
+            }
+        }
+    }
+}
+// @media only screen and (min-device-width: 768px) and (orientation: portrait) {
+
+// }
+</style>
+<style lang="scss">
+.animated.short {
+    &.faster {
+        animation-duration: 0.3s;
+    }
+    @keyframes fadeInUp {
+        0% {
+            opacity: 0;
+            transform: translate3d(0, 1rem, 0);
+        }
+        to {
+            opacity: 1;
+            transform: translateZ(0);
+        }
+    }
+    @keyframes fadeOutDown {
+        0% {
+            opacity: 1;
+        }
+        to {
+            opacity: 0;
+            transform: translate3d(0, 1rem, 0);
+        }
+    }
+}
+</style>

+ 137 - 0
src/components/Information/View.Pc.vue

@@ -0,0 +1,137 @@
+<template>
+    <div class="title" @click="onShowDescription" :class="{ 'pull-up': showDescription, collapse: !showTitle }">
+        <div class="back-btn" @click.stop="showTitle = !showTitle">
+            <i class="iconfont icon-_back"></i>
+        </div>
+        <div class="text">
+            <div>{{ metadata.title }}</div>
+        </div>
+        <div class="icon">
+            <i class="iconfont icon-pull-down"></i>
+        </div>
+    </div>
+    <div v-if="showTitle" class="description" :class="{ show: showDescription }" @click="onShowDescription">
+        <div class="text" v-html="metadata.description"></div>
+    </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+
+import { useStore } from 'vuex'
+const showTitle = ref(true)
+const store = useStore()
+const metadata = computed(() => store.getters['scene/metadata'])
+const showDescription = ref(false)
+
+const onShowDescription = () => {
+    showDescription.value = !showDescription.value
+}
+</script>
+
+<style lang="scss" scoped>
+.title {
+    display: flex;
+    align-items: center;
+    padding-right: 30px;
+    width: 100%;
+    height: 100%;
+    cursor: pointer;
+    transition: all 0.1s;
+    &.collapse {
+        // width: 34px;
+        padding-right: 0;
+        .back-btn {
+            .iconfont {
+                transform: rotate(180deg);
+            }
+            &::after {
+                display: none;
+            }
+        }
+        .text {
+            display: none;
+        }
+        .icon {
+            display: none;
+        }
+    }
+    .back-btn {
+        width: 34px;
+        height: 34px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        cursor: pointer;
+        position: relative;
+        transition: all 0.1s;
+        &::after {
+            content: '';
+            position: absolute;
+            width: 1px;
+            height: 65%;
+            background: linear-gradient(transparent, #fff, transparent);
+            top: 50%;
+            transform: translateY(-50%);
+            right: 0;
+        }
+    }
+    .text {
+        // width: 190px;
+        width: 240px;
+        padding-left: 20px;
+        transition: width 0.3s;
+        > div {
+            width: 100%;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+        }
+    }
+    .icon {
+        i {
+            transition: transform 0.2s ease-in-out;
+        }
+    }
+
+    &.pull-up {
+        // .text {
+        //     width: 240px;
+        //     padding-left: 20px;
+
+        //     > div {
+        //         // width: 100%;
+        //         // overflow: hidden;
+        //         // text-overflow: ellipsis;
+        //         // white-space: nowrap;
+        //     }
+        // }
+        .icon {
+            i {
+                transform: rotate(180deg);
+            }
+        }
+    }
+}
+
+.description {
+    display: none;
+    position: absolute;
+    left: 10px;
+    top: calc(100% + 10px);
+    background: rgba(15, 15, 15, 0.3);
+    border-radius: 10px;
+    &.show {
+        display: block;
+    }
+    .text {
+        padding: 20px;
+        width: 400px;
+        letter-spacing: 1px;
+        overflow: hidden;
+        word-break: break-all;
+        white-space: normal;
+        line-height: 1.5;
+    }
+}
+</style>

+ 64 - 0
src/components/Information/index.vue

@@ -0,0 +1,64 @@
+<template>
+    <div class="information" :class="{ mobile: mobile, disabled: isPlay }">
+        <!-- <component :is="page"></component> -->
+        <ViewMobile />
+    </div>
+</template>
+
+<script setup>
+import ViewMobile from './View.Mobile.vue'
+import { ref, markRaw, onMounted, computed, watch } from 'vue'
+import { useApp } from '@/app'
+import { useStore } from 'vuex'
+const store = useStore()
+
+const mobile = ref(false)
+// const page = ref(ViewMobile)
+const isPlay = computed(() => store.getters['tour/isPlay'])
+useApp().then(app => {
+    mobile.value = app.config.mobile
+    // console.log(app.config)
+    // page.value = mobile.value ? ViewMobile : ViewPc
+})
+</script>
+
+<style lang="scss" scoped>
+.information {
+    position: absolute;
+    left: 0;
+    top: 20px;
+    height: 34px;
+    border-radius: 0 34px 34px 0;
+    background: rgba(0, 0, 0, 0.3);
+    pointer-events: all;
+    color: rgba(255, 255, 255, 0.88);
+    z-index: 100;
+    &.mobile {
+        position: absolute;
+        top: 0;
+        left: 50%;
+        transform: translateX(-50%);
+        width: 100%;
+        background: none;
+        height: 0;
+        &.disabled {
+            // position: absolute;
+        }
+    }
+    :deep(.icon) {
+        cursor: pointer;
+        display: flex;
+        position: absolute;
+        right: 0;
+        top: 0;
+        bottom: 0;
+        width: 24px;
+        align-items: center;
+        justify-content: flex-start;
+        color: inherit;
+        i {
+            font-size: 14px;
+        }
+    }
+}
+</style>

+ 37 - 0
src/components/Tags/constant.js

@@ -0,0 +1,37 @@
+export const custom = {
+    image: {
+        icon: 'pic',
+        upload: true,
+        uploadPlace: `上传图片`,
+        accept: `.jpg,.png`,
+        multiple: true,
+        name: '图片',
+        maxSize: 5 * 1024 * 1024,
+        maxNum: 9,
+        othPlaceholder: '支持JPG、PNG图片格式,单张不超过5MB,最多支持上传9张。',
+    },
+    video: {
+        icon: 'video',
+        upload: true,
+        uploadPlace: `上传视频`,
+        accept: `.mp4, .mov`,
+        multiple: false,
+        name: '视频',
+        maxSize: 20 * 1024 * 1024,
+        othPlaceholder: '支持MP4、MOV视频格式,码率小于2Mbps,不超过20MB',
+    },
+    audio: {
+        icon: 'music',
+        upload: true,
+        uploadPlace: `上传音频`,
+        accept: '.mp3, .wav',
+        multiple: false,
+        name: '音频',
+        maxSize: 5 * 1024 * 1024,
+        othPlaceholder: '支持MP3、WAV格式,不超过5MB',
+    },
+    link: {
+        icon: 'web',
+        name: '链接',
+    },
+}

+ 200 - 0
src/components/Tags/goods-list.vue

@@ -0,0 +1,200 @@
+<template>
+  <transition mode="out-in" appear>
+    <div class="goodlist" v-show="tagtype == 'goodlist'">
+      <div class="head-pages">
+        <div>
+          <span>{{ current + 1 }}</span
+          ><span>/9</span>
+        </div>
+        <ui-icon @click.stop="emit('close')" type="state_f"></ui-icon>
+      </div>
+      <div class="swiper-container" id="goodlist">
+        <ul class="swiper-wrapper">
+          <li class="swiper-slide" v-for="(i, index) in 4" :key="index">
+            <div :style="{ backgroundImage: `url(${require('@/assets/images/loading.jpg')})` }" class="img"></div>
+            <div class="info">
+              <p>這裏是商品標題最長限製十五個字</p>
+              <div><span>¥</span><span>3688</span></div>
+              <ul>
+                <li v-for="(item, index) in 3" :key="index">
+                  <span>型號:</span>
+                  <span>340271</span>
+                </li>
+              </ul>
+            </div>
+            <div class="goods-button">
+              <div>查看詳情</div>
+              <div>加入購物車</div>
+            </div>
+          </li>
+        </ul>
+      </div>
+    </div>
+  </transition>
+</template>
+
+<script setup>
+import { onMounted, watch, computed, defineEmits, ref, defineProps, nextTick } from "vue";
+import { useApp, getApp } from "@/app";
+import { useStore } from "vuex";
+const store = useStore();
+
+const tagtype = computed(() => store.getters["tag/tagClickType"]);
+
+const emit = defineEmits(["close"]);
+
+const current = ref(0);
+
+const brandScroll = () => {
+  nextTick(() => {
+    let t = setTimeout(() => {
+      clearTimeout(t);
+      new Swiper("#goodlist", {
+        effect: "coverflow",
+        grabCursor: true,
+        centeredSlides: true,
+        slidesPerView: "auto",
+        coverflowEffect: {
+          rotate: 0,
+          stretch: -40,
+          depth: 100,
+          modifier: 1,
+          slideShadows: false,
+        },
+        on: {
+          touchMove(swiper, e) {
+            e.stopPropagation();
+            e.preventDefault();
+          },
+          slideChange() {
+            current.value = this.activeIndex;
+          },
+        },
+      });
+    }, 100);
+  });
+};
+
+onMounted(() => {
+    brandScroll();
+});
+</script>
+
+<style lang="scss" scoped>
+.goodlist {
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  margin: 0 auto;
+  max-width: 500px;
+  z-index: 101;
+  background: rgba(0, 0, 0, 0.2);
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  justify-content: center;
+  .head-pages {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 10px;
+    width: 76%;
+    > div {
+      span {
+        font-size: 14px;
+        &:last-of-type {
+          color: rgba(255, 255, 255, 0.5);
+        }
+      }
+    }
+    > i {
+      color: rgba(0, 0, 0, 1);
+      font-size: 20px;
+    }
+  }
+  #goodlist {
+    width: 100%;
+    overflow: hidden;
+    padding-right: 2px;
+    position: relative;
+    > ul {
+      > li {
+        width: 76%;
+        border-radius: 2px;
+        position: relative;
+        font-size: 0;
+        overflow: hidden;
+        background: #fff;
+        padding-bottom: 14px;
+        .img {
+          height: 34vh;
+          width: 100%;
+          background-size: auto 100%;
+        }
+        .info {
+          font-size: 14px;
+          color: #131d34;
+          padding: 16px 20px;
+          > p {
+            font-size: 16px;
+            width: 100%;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+          }
+          > div {
+            color: var(--editor-main-color);
+            padding: 8px 0px 10px;
+            border-bottom: 1px solid #ebebeb;
+            > span {
+              font-size: 16px;
+              &:first-of-type {
+                font-size: 12px;
+                margin-right: 4px;
+              }
+            }
+          }
+          > ul {
+            padding: 12px 0;
+            border-bottom: 1px solid #ebebeb;
+
+            > li {
+              margin: 6px 0;
+              > span {
+                font-size: 14px;
+
+                &:first-of-type {
+                  color: #909090;
+                  font-size: 12px;
+                }
+              }
+            }
+          }
+        }
+        .goods-button {
+          display: flex;
+          justify-content: space-between;
+          font-size: 16px;
+          padding: 0 20px;
+
+          > div {
+            height: 42px;
+            background: #ff8e24;
+            border-radius: 4px;
+            width: 48%;
+            text-align: center;
+            line-height: 42px;
+            &:last-of-type {
+              background: var(--editor-main-color);
+            }
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 203 - 0
src/components/Tags/index.vue

@@ -0,0 +1,203 @@
+<template>
+  <teleport :to="tags$" v-if="tags$">
+    <template v-for="(tag, index) in tags" :key="index">
+      <div
+        @mouseenter="onMouseEnter(tag, index)"
+        @mouseleave="onMouseLeave($event, tag)"
+        :style="{ left: tag.x + 'px', top: tag.y + 'px' }"
+        :class="{ visible: tag.visible }"
+      >
+        <span @click.stop="goTag(tag, index)" class="point zoom" :style="{ 'background-image': 'url(' + getUrl(tag.icon) + ')' }"></span>
+        <!-- <div class="content"> -->
+        <div class="content">
+          <div class="trans" :class="{ active: (isFixed && tag.sid == hotData.sid) || showInfo }">
+            <template v-if="hotData && tag.sid == hotData.sid && !showMsg">
+              <div class="arrow" :id="`arrow_${tag.sid}`"></div>
+              <ShowTag @click.stop="" v-if="hotData" @open="openInfo" />
+            </template>
+          </div>
+        </div>
+        <TagView @click.stop v-if="showMsg && toggleIndex == index" @close="closeInfo" />
+      </div>
+    </template>
+  </teleport>
+    <GoodsList @close="closetagtype" />
+    <Treasure @close="closetagtype" />
+</template>
+<script setup>
+import { ref, onMounted, computed, watch, watchEffect, onActivated, onDeactivated, getCurrentInstance, nextTick } from "vue";
+import { getApp, useApp } from "@/app";
+import { useStore } from "vuex";
+import common from "../../utils/common";
+import TagView from "./tag-view.vue";
+import GoodsList from "./goods-list.vue";
+import Treasure from "./treasure.vue";
+
+import ShowTag from "./show-tag.vue";
+import { useMusicPlayer } from "@/utils/sound";
+const musicPlayer = useMusicPlayer();
+let init = true;
+const hotData = computed(() => {
+  let data = store.getters["tag/hotData"];
+  if (!data) {
+    // musicPlayer.play()
+    // musicPlayer.pause(true)
+    // if (!getApp().Scene.isCurrentPanoHasVideo) {
+    //     console.log('resume')
+    //     console.log(init)
+    //     musicPlayer.resume()
+    // }
+  }
+  return data;
+});
+
+
+const isPlay = computed(() => store.getters["tour/isPlay"]);
+const flying = computed(() => store.getters["flying"]);
+const leaveId = computed(() => store.getters["tag/leaveId"]);
+const isFixed = computed(() => store.getters["tag/isFixed"]);
+const enterVisible = computed(() => store.getters["tag/enterVisible"]);
+const editPosition = computed(() => store.getters["tag/editPosition"]);
+const toggleIndex = computed(() => store.getters["tag/toggleIndex"]);
+const isClick = computed(() => store.getters["tag/isClick"]);
+const positionInfo = computed(() => store.getters["tag/positionInfo"]);
+const store = useStore();
+const tags$ = ref(null);
+const tags = computed(() => {
+  return store.getters["tag/tags"] || [];
+});
+
+const showInfo = ref(false);
+const showMsg = ref(false);
+// const toggleIndex = ref(null)
+const openInfo = () => {
+  showMsg.value = true;
+  store.commit("tag/setFixed", false);
+  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')
+      musicPlayer.resume();
+    }
+  }
+
+  store.commit("tag/setFixed", false);
+  store.commit("tag/closeTag");
+  showInfo.value = false;
+};
+
+const closetagtype = ()=>{
+  store.commit("tag/setTagClickType", '');
+}
+
+const goTag = async (item, index) => {
+  let player = await getApp().TourManager.player;
+  if (isPlay.value) {
+    player.pause();
+    store.commit("tour/setData", { isPlay: false });
+  }
+  if (flying.value) {
+    return;
+  }
+  if (isFixed.value && hotData.value.sid == item.sid && !positionInfo.value) {
+    closeTag();
+  } else {
+    if (!enterVisible.value && !editPosition.value) {
+      if (!positionInfo.value) {
+        store.commit("tag/show", index);
+        store.commit("tag/setFixed", true);
+        showInfo.value = true;
+        store.commit("tag/setToggleIndex", index);
+        store.commit("tag/gotoTag", item);
+      }
+      store.commit("tag/setClick", true);
+    } else {
+      //热点可视操作
+      getApp().TagManager.edit.setTagVisi(item.sid);
+    }
+  }
+};
+
+onMounted(async () => {
+  const app = await useApp();
+  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;
+
+  return common.changeUrl(url);
+};
+const onMouseEnter = (tag, index) => {
+  if (!getApp().config.mobile) {
+    if (flying.value || isPlay.value) {
+      return;
+    }
+    if (!enterVisible.value && !editPosition.value && !positionInfo.value) {
+      // console.log('onMouseEnter')
+
+      showInfo.value = true;
+      store.commit("tag/show", index);
+
+      store.commit("tag/setToggleIndex", index);
+      if (leaveId.value != tag.sid) {
+        //聚焦后 移到其他热点取消fixed
+        store.commit("tag/setFixed", false);
+      }
+    }
+  }
+};
+
+const onMouseLeave = (event, tag) => {
+  if (!getApp().config.mobile) {
+    if (flying.value) {
+      return;
+    }
+    if (event.relatedTarget != null) {
+      showInfo.value = false;
+      store.commit("tag/setLeaveId", tag.sid);
+      if (!enterVisible.value && !isFixed.value && !showMsg.value && !editPosition.value && !positionInfo.value) {
+        closeTag();
+      }
+    }
+  }
+};
+
+const onClickHandler = () => {
+  // if (flying.value) {
+  //     return
+  // }
+
+  if (!positionInfo.value && isFixed.value) {
+    closeTag();
+    store.commit("tag/setClick", false);
+  }
+};
+</script>

+ 130 - 0
src/components/Tags/link-manage.vue

@@ -0,0 +1,130 @@
+<!--  -->
+<template>
+    <div class="link-manage">
+        <div class="header">
+            <div class="header-title">{{ $t('tag.toolbox.addLink') }}</div>
+        </div>
+        <div class="link-content">
+            <ui-input width="100%" @input="onInput('text')" :placeholder="$t('tag.toolbox.linkTitleTips')" type="text" v-model="text" require :error="error.text" :maxlength="40" />
+            <ui-input width="100%" type="text" v-model="link" @input="onInput('link')" :error="error.link" require :placeholder="$t('tag.toolbox.linkUrlTips')" />
+        </div>
+        <div class="btn">
+            <ui-button class="cancel" @click="emit('close')">{{ $t('common.cancel') }}</ui-button>
+            <ui-button type="primary" @click="confirm">{{ $t('common.add') }}</ui-button>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { reactive, toRefs, onBeforeMount, onMounted, defineEmits, defineProps, ref } from 'vue'
+// 注册事件
+const emit = defineEmits(['close', 'confirm'])
+const text = ref('')
+const link = ref('')
+const error = ref({
+    text: '',
+    link: '',
+})
+const onInput = type => {
+    if (type == 'text') {
+        if (text.value == '') {
+            error.value.text = `${t('tag.toolbox.linkTitleTips')}`
+        } else {
+            error.value.text = ''
+        }
+    } else if (type == 'link') {
+        if (link.value == '') {
+            error.value.link = `${t('tag.toolbox.linkUrlTips')}`
+        } else {
+            error.value.link = ''
+        }
+    }
+}
+
+const confirm = () => {
+    if (text.value == '') {
+        error.value.text = `${t('tag.toolbox.linkTitleTips')}`
+    } else {
+        error.value.text = ''
+    }
+    if (link.value == '') {
+        error.value.link = `${t('tag.toolbox.linkUrlTips')}`
+    } else {
+        error.value.link = ''
+    }
+
+    if (text.value == '' || link.value == '') {
+        return
+    }
+    emit('confirm', { text, link })
+}
+</script>
+<style lang="scss" scoped>
+.link-manage {
+    position: absolute;
+    width: 400px;
+    // height: 310px;
+    background: rgba(27, 27, 28, 0.9);
+    box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3);
+    border: 1px solid #000000;
+    backdrop-filter: blur(4px);
+    left: -22px;
+    top: 100%;
+    margin-top: 5px;
+    z-index: 100;
+    &::after {
+        position: absolute;
+        content: '';
+        left: 29px;
+        top: -10px;
+        width: 0;
+        height: 0;
+        border-left: 6px solid transparent;
+        border-right: 6px solid transparent;
+        border-bottom: 10px solid rgba(27, 27, 28, 0.8);
+    }
+    .link-content {
+        border-bottom: 1px solid rgba(255, 255, 255, 0.16);
+        padding: 40px 20px;
+        .ui-input {
+            margin-bottom: 30px;
+            &:last-of-type {
+                margin-bottom: 0;
+            }
+        }
+    }
+    .header {
+        height: 60px;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding: 0 20px;
+        box-sizing: border-box;
+        color: #999;
+        border-bottom: 1px solid rgba(255, 255, 255, 0.16);
+        .header-title {
+            font-size: 16px;
+            font-weight: bold;
+        }
+    }
+    .btn {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        padding: 0 85px;
+        height: 60px;
+        .cancel {
+            color: #00c8af;
+            border: 1px solid #00c8af;
+            &:hover {
+                opacity: 0.9;
+            }
+        }
+        button {
+            &:first-of-type {
+                margin-right: 10px;
+            }
+        }
+    }
+}
+</style>

+ 104 - 0
src/components/Tags/metas-upload.vue

@@ -0,0 +1,104 @@
+<!--  -->
+<template>
+    <div class="metas-upload">
+        <ui-input
+            type="file"
+            v-if="type != 'link'"
+            v-show="hotData.media[type].length <= 0"
+            :class="{ hide: audioInfo.length > 0 && type == 'audio' }"
+            :placeholder="custom[type].uploadPlace"
+            :disable="custom[type].upload"
+            :scale="custom[type].scale"
+            :accept="custom[type].accept"
+            :multiple="custom[type].multiple"
+            :maxSize="custom[type].maxSize"
+            :maxLen="custom[type].maxNum"
+            :othPlaceholder="custom[type].othPlaceholder"
+            @update:modelValue="data => hanlderFiles(data)"
+        >
+        </ui-input>
+        <metasWeb v-else />
+        <metasImage v-if="imageList.length > 0 && type == 'image'" />
+        <metasVideo v-if="videoSrc.length > 0 && type == 'video'" />
+        <metasAudio v-if="audioInfo.length > 0 && type == 'audio'" />
+    </div>
+</template>
+
+<script setup>
+import { reactive, toRefs, onBeforeMount, onMounted, ref, defineProps, computed, watch } from 'vue'
+import { useStore } from 'vuex'
+import { Loading, Dialog } from '@/global_components/'
+import { custom } from './constant.js'
+import metasImage from './metas/metas-image'
+import metasVideo from './metas/metas-video'
+import metasAudio from './metas/metas-audio'
+import metasWeb from './metas/metas-web'
+const store = useStore()
+const imageNum = ref(0)
+const props = defineProps({
+    type: {
+        type: String,
+        default: null,
+    },
+})
+const hotData = computed(() => store.getters['tag/hotData'])
+const imageList = computed(() => {
+    return hotData.value.media.image || []
+})
+const videoSrc = computed(() => {
+    return hotData.value.media.video || []
+})
+const audioInfo = computed(() => {
+    return hotData.value.media.audio || []
+})
+
+const hanlderFiles = data => {
+    switch (props.type) {
+        case 'image':
+            // store.commit('tag/setImageList', data[0])
+            setImageList(data[0])
+            break
+        case 'video':
+            store.commit('tag/setVideo', data)
+            break
+        case 'audio':
+            store.commit('tag/setAudio', data)
+            break
+    }
+}
+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 < custom['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: custom['image'].maxNum })}`,
+            })
+            break
+        }
+    }
+    store.commit('tag/setImageList', list)
+}
+</script>
+<style lang="scss" scoped>
+.hide {
+    display: none;
+}
+.metas-upload {
+    width: 100%;
+    height: 100%;
+    position: relative;
+    .ui-input {
+        width: 100%;
+        height: 100%;
+    }
+}
+</style>

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

@@ -0,0 +1,67 @@
+<template>
+    <div class="audio-box">
+        <div class="del-btn" @click="delAudio()">
+            <ui-icon type="del"></ui-icon>
+        </div>
+        <div v-for="(i, index) in audioInfo" class="audio-msg">
+            <ui-icon type="music"></ui-icon>
+            <span>{{ i.name }}</span>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { reactive, toRefs, onBeforeMount, onMounted, 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>

+ 386 - 0
src/components/Tags/metas/metas-image.vue

@@ -0,0 +1,386 @@
+<!--  -->
+<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">
+            <div v-show="!loading" class="image-list" :style="`transform:translateX(${-100 * imageNum}%);`">
+                <div :style="`transform:translateX(${100 * index}%);background-image:url(${common.changeUrl(i.src)});`" class="image-item" v-for="(i, index) in imageList"></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 < custom[type].maxNum && isEdit"
+                type="file"
+                :placeholder="custom[type].uploadPlace"
+                :disable="custom[type].upload"
+                :scale="custom[type].scale"
+                :accept="custom[type].accept"
+                :multiple="custom[type].multiple"
+                :maxSize="custom[type].maxSize"
+                :maxLen="custom[type].maxNum"
+                :othPlaceholder="custom[type].othPlaceholder"
+                @update:modelValue="data => hanlderFiles(data)"
+            >
+                <template v-slot:replace>
+                    <span class="continue-tips">继续添加</span>
+                    <span class="edit-pic-num">
+                        <span class="cur">{{ imageList.length }}</span>
+                        <span> / {{ custom[type].maxNum }}</span>
+                    </span>
+                </template>
+            </ui-input>
+            <!-- <span v-if="isEdit" class="pic-num">
+                <span class="cur">{{ imageList.length }}</span>
+                <span> / {{ custom[type].maxNum }}</span>
+            </span> -->
+            <span v-if="!isEdit" class="pic-num">
+                <span class="cur">{{ imageNum + 1 }}</span>
+                <span><span>&nbsp;</span>/<span>&nbsp;</span></span>
+                <span>{{ imageList.length }}</span>
+            </span>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { reactive, toRefs, onBeforeMount, onMounted, nextTick, ref, computed, defineProps } from 'vue'
+import { useStore } from 'vuex'
+import common from '@/utils/common'
+import { custom } from '../constant.js'
+import { getApp, useApp } from '@/app'
+import { Dialog } from '@/global_components/'
+const isMobile = ref(false)
+const store = useStore()
+const type = ref('image')
+const props = defineProps({
+    metasHeight: {
+        type: Number,
+        default: null,
+    },
+    viewer: {
+        type: Boolean,
+        default: false,
+    },
+})
+const isEdit = computed(() => store.getters['tag/isEdit'])
+const hotData = computed(() => store.getters['tag/hotData'])
+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++
+    }
+}
+const hanlderFiles = data => {
+    console.log(data[0])
+    // store.commit('tag/setImageList', data[0])
+    setImageList(data[0])
+    console.log(imageList.value.length, data[0].length - 1)
+    // 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 < custom['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: custom['image'].maxNum })}`,
+            })
+            break
+        }
+    }
+    store.commit('tag/setImageList', list)
+}
+
+onMounted(async () => {
+    const app = await useApp()
+    isMobile.value = app.config.mobile
+
+    nextTick(() => {
+        let img = new Image()
+        img.onload = () => {
+            loading.value = false
+        }
+        img.src = common.changeUrl(imageList.value[0].src)
+    })
+})
+const filesError = index => {
+    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;
+}
+.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;
+    .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);
+        }
+    }
+    .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;
+            margin-right: 5px;
+        }
+        .edit-pic-num {
+            // position: absolute;
+            // right: 10px;
+            font-size: 12px;
+            .cur {
+                color: var(--editor-main-color);
+            }
+        }
+        .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;
+            img {
+                height: 100%;
+                width: auto;
+            }
+        }
+    }
+    &.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/Tags/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/Tags/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>

+ 222 - 0
src/components/Tags/show-tag.vue

@@ -0,0 +1,222 @@
+<!--  -->
+<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)"></ui-audio>
+            </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 { useRouter } from 'vue-router'
+import { useMusicPlayer } from '@/utils/sound'
+const musicPlayer = useMusicPlayer()
+// const router = useRouter()
+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('pause(true)')
+    }
+    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')
+    }
+}
+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: 20px;
+            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;
+                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>

+ 332 - 0
src/components/Tags/style-icon.vue

@@ -0,0 +1,332 @@
+<!--  -->
+<template>
+    <div class="style-icon">
+        <div class="style-list">
+            <div class="upload-btn" v-if="icons && icons.length < 19">
+                <ui-button class="add">
+                    <ui-input v-vip:vip="auth.isVip" v-vip:expired="auth.isExpired" class="input" preview accept=".jpg, .jpeg, .png" @update:modelValue="iconUpload" type="file">
+                        <template v-slot:replace>
+                            <ui-icon type="add" class="icon" />
+                        </template>
+                    </ui-input>
+                </ui-button>
+                <div class="vip-tag"></div>
+            </div>
+            <div class="style-item default" @click="changeLogo('')" :style="`background-image:url(${defaultLogo});`" :class="{ active: icon == '' }"></div>
+            <template v-for="(i, index) in showList">
+                <!-- <div class="style-item" @click="changeLogo(i.id, index)" v-if="index < 3" :style="`background:${i.name};`" :class="{ active: icon == i.id }"></div> -->
+                <div class="style-item" @click="changeLogo(i.name, i.sid)" v-if="index < 4" :style="`background-image:url(${common.changeUrl(i.name)});`" :class="{ active: iconId == i.sid }">
+                    <div class="close-btn" @click.stop="delIcon(i)">
+                        <ui-icon type="close"></ui-icon>
+                    </div>
+                </div>
+            </template>
+            <div class="more" v-if="icons && icons.length > 4">
+                <ui-button @click.stop="showMore = !showMore">
+                    <ui-icon :class="{ active: showMore }" type="pull-down"></ui-icon>
+                </ui-button>
+                <div class="tagLogo-box" :class="{ active: showMore }">
+                    <template v-for="(i, index) in hideList">
+                        <div @click="insertLogo(i)" class="tagLogo-item" :style="`background-image:url(${common.changeUrl(i.name)});`">
+                            <div class="close-btn">
+                                <ui-icon type="close" @click.stop="delIcon(i)"></ui-icon>
+                            </div>
+                        </div>
+                    </template>
+                </div>
+            </div>
+        </div>
+        <!-- <span class="more">更多></span> -->
+    </div>
+</template>
+
+<script setup>
+import { reactive, toRefs, onBeforeMount, onMounted, onUnmounted, ref, computed } from 'vue'
+import { Cropper, Loading, Dialog } from '@/global_components/'
+import common from '@/utils/common'
+import { useStore } from 'vuex'
+import { getApp } from '@/app'
+const store = useStore()
+const showMore = ref(false)
+const defaultLogo = ref('')
+defaultLogo.value = getApp().resource.getAppURL('images/tag_icon_default.svg')
+const chooseItem = ref(null)
+const hotData = computed(() => store.getters['tag/hotData'])
+const icons = computed(() => {
+    console.log(store.getters['tag/icons'])
+    return store.getters['tag/icons'] || []
+})
+const auth = computed(() => store.getters['scene/auth'])
+const iconId = computed(() => store.getters['tag/iconId'])
+const icon = computed(() => {
+    return hotData.value.icon
+})
+
+const showList = computed(() => {
+    for (let i = 0; i < icons.value.length; i++) {
+        if (icons.value[i].sid == iconId.value) {
+            chooseItem.value = icons.value[i]
+        }
+    }
+    const styles = icons.value.slice(0, icons.value.length > 4 ? (icons.value.length < 19 ? 3 : 4) : 4)
+    if (chooseItem.value && !styles.includes(chooseItem.value)) {
+        let index = icons.value.length < 19 ? 2 : 3
+        styles[index] = chooseItem.value
+    }
+    return styles
+})
+const hideList = computed(() => {
+    let res = icons.value.filter(style => {
+        return !showList.value.includes(style)
+    })
+    return res
+})
+
+// let fristIndex = null
+const iconUpload = item => {
+    let sid = common.getRandomSid()
+    let src = URL.createObjectURL(item.file)
+    let pramas = { img: src, fixedNumber: [1, 1] }
+    let suffix = item.file.type.split('/')[1]
+    let fileName = sid + '.' + suffix
+    Cropper.open(pramas).then(res => {
+        if (res) {
+            let data = {
+                sid: sid,
+                name: res[1],
+                fileName: fileName,
+            }
+            store.commit('tag/pushIcon', { ...data })
+            store.commit('tag/changeIcon', { ...data })
+        }
+    })
+}
+const changeLogo = (name, sid) => {
+    store.commit('tag/changeIcon', { name: name, sid: sid })
+    showMore.value = false
+}
+const insertLogo = item => {
+    chooseItem.value = item
+    store.commit('tag/changeIcon', item)
+    showMore.value = false
+}
+const delIcon = item => {
+    chooseItem.value = null
+    store.commit('tag/deleteIcon', item)
+    if (item.sid == iconId.value) {
+        store.commit('tag/changeIcon', { name: '', sid: '' })
+    }
+}
+const closeMore = () => {
+    showMore.value = false
+}
+onMounted(() => {
+    window.addEventListener('click', closeMore, false)
+})
+onUnmounted(() => {
+    window.removeEventListener('click', closeMore, false)
+})
+</script>
+<style lang="scss" scoped>
+.style-icon {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin: 0 0 24px;
+    // .more {
+    //     color: var(--editor-main-color);
+    //     cursor: pointer;
+    // }
+    .style-list {
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+        > div {
+            margin-right: 20px;
+            &:last-of-type {
+                margin-right: 0;
+            }
+        }
+        .upload-btn {
+            position: relative;
+            cursor: pointer;
+            .ui-input {
+                position: relative;
+                z-index: 11;
+            }
+        }
+
+        .add {
+            width: 40px;
+            height: 40px;
+            border-radius: 0;
+            cursor: pointer;
+            // margin-right: 20px;
+            border: 1px solid #fde1b0;
+            color: #fde1b0;
+            position: relative;
+            border-radius: 4px;
+            transition: none;
+        }
+        .style-item {
+            width: 40px;
+            height: 40px;
+            box-sizing: border-box;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            cursor: pointer;
+            // background: #f2f2f2;
+            background-repeat: no-repeat;
+            background-size: cover;
+            position: relative;
+            border-radius: 4px;
+            border: 1px solid transparent;
+            &.default {
+            }
+            &.active {
+                border: 1px solid var(--editor-main-color);
+            }
+            .close-btn {
+                width: 16px;
+                height: 16px;
+                border-radius: 50%;
+                position: absolute;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                background: rgba(250, 63, 72, 1);
+                opacity: 0.6;
+                right: -6px;
+                top: -6px;
+                z-index: 101;
+                display: none;
+                &:hover {
+                    opacity: 1;
+                }
+                > i {
+                    font-size: 12px;
+                    color: #fff;
+                    transform: scale(0.8);
+                }
+            }
+            &:hover {
+                .close-btn {
+                    display: flex;
+                }
+            }
+        }
+        .more {
+            width: 40px;
+            height: 40px;
+            box-sizing: border-box;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            cursor: pointer;
+            // border-radius: 4px;
+            // border: 1px solid rgba(255, 255, 255, 0.2);
+            box-sizing: border-box;
+            position: relative;
+            z-index: 100;
+            button {
+                width: 100%;
+                height: 100%;
+                position: relative;
+                .iconfont {
+                    transition: all 0.3s;
+                    position: absolute;
+                    margin-top: -7px;
+                    margin-left: -7px;
+
+                    &.active {
+                        transform: rotate(180deg);
+                    }
+                }
+            }
+            .tagLogo-box {
+                position: absolute;
+                width: 380px;
+                // height: 200px;
+                background: rgba(27, 27, 28, 0.9);
+                box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3);
+                border: 1px solid #000000;
+                backdrop-filter: blur(4px);
+                z-index: 10;
+                right: -20px;
+                top: 100%;
+                margin-top: 12px;
+                display: flex;
+                flex-wrap: wrap;
+                align-items: center;
+                justify-content: flex-start;
+                padding: 0 18px 20px 18px;
+                transform: scale(0);
+                transform-origin: 90% top;
+                transition: all 0.3s;
+                &.active {
+                    transform: scale(1);
+                    transform-origin: 90% top;
+                }
+                &::after {
+                    position: absolute;
+                    content: '';
+                    right: 32px;
+                    top: -10px;
+                    width: 0;
+                    height: 0;
+                    border-left: 6px solid transparent;
+                    border-right: 6px solid transparent;
+                    border-bottom: 10px solid rgba(27, 27, 28, 0.8);
+                }
+                .tagLogo-item {
+                    width: 40px;
+                    height: 40px;
+                    border-radius: 4px;
+                    background: #fff;
+                    margin-right: 20px;
+                    margin-top: 20px;
+                    position: relative;
+                    border: 1px solid transparent;
+                    background-size: cover;
+
+                    &.active {
+                        border: 1px solid var(--editor-main-color);
+                    }
+                    &:nth-of-type(6n) {
+                        margin-right: 0;
+                    }
+                    .close-btn {
+                        width: 16px;
+                        height: 16px;
+                        border-radius: 50%;
+                        position: absolute;
+                        display: flex;
+                        align-items: center;
+                        justify-content: center;
+                        background: rgba(250, 63, 72, 1);
+                        opacity: 0.6;
+                        right: -6px;
+                        top: -6px;
+                        z-index: 101;
+                        display: none;
+                        &:hover {
+                            opacity: 1;
+                        }
+                        > i {
+                            font-size: 12px;
+                            color: #fff;
+                            transform: scale(0.8);
+                        }
+                    }
+                    &:hover {
+                        .close-btn {
+                            display: flex;
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+</style>

+ 230 - 0
src/components/Tags/tag-info.vue

@@ -0,0 +1,230 @@
+<!--  -->
+<template>
+    <div class="hot-editor" :id="`tagBox_${hotData.sid}`">
+        <div class="header">
+            <div class="header-title">{{ $t('tag.toolbox.hotspot') }}</div>
+            <!-- <ui-icon class="close" type="close"></ui-icon> -->
+        </div>
+        <div class="hot-content">
+            <StyleIcon />
+            <ui-input
+                require
+                class="input"
+                width="100%"
+                :placeholder="$t('tag.toolbox.hotspotTitleTips')"
+                type="text"
+                :modelValue="title"
+                @update:modelValue="value => (title = value)"
+                maxlength="30"
+            />
+
+            <div class="desc-box" :class="{ border: showBorder }">
+                <div class="edit-box">
+                   
+                </div>
+                <div class="desc-link">
+                    <!-- <LinkManage :show="!!(inInsertLink && maxTextLen)" :textlen="maxTextLen" @close="inInsertLink = false" @add="insertel" /> -->
+                    <LinkManage v-if="showLink" @close="closeLink" @confirm="insertText" />
+                    <ui-icon type="link" class="icon" @click="showLink = true" />
+
+                    <span>
+                        <span class="theme-color">{{ descriptionLength }}</span>
+                        /
+                        <span>{{ descriptionMax }}</span>
+                    </span>
+                </div>
+            </div>
+
+            <div class="tag-metas">
+                <MetasUpload :type="info.type" />
+            </div>
+            <div class="submit-ctrl">
+                <div class="radio-group">
+                    <ui-input
+                        v-for="(item, type) in custom"
+                        :key="type"
+                        class="radio"
+                        type="radio"
+                        :modelValue="info.type === type"
+                        @update:modelValue="changeType(type)"
+                        :tip="item.name"
+                        :icon="item.icon"
+                    />
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { reactive, toRefs, ref, onBeforeMount, onMounted, defineProps, computed, nextTick } from 'vue'
+import { useStore } from 'vuex'
+const store = useStore()
+import MetasUpload from './metas-upload.vue'
+import StyleIcon from './style-icon.vue'
+import LinkManage from './link-manage.vue'
+import { custom } from './constant.js'
+
+const editor$ = ref(null)
+const descriptionMax = 200
+const descriptionLength = ref(0)
+const showLink = ref(false)
+const hotData = computed(() => store.getters['tag/hotData'])
+const info = computed(() => {
+    let data = {
+        type: hotData.value.type || 'image',
+    }
+    return data
+})
+const title = computed({
+    get() {
+        if (hotData.value.title && hotData.value.title != void 0) {
+            return hotData.value.title
+        }
+        return hotData.value.title || ''
+    },
+    set(value) {
+        store.commit('tag/setTitle', value)
+    },
+})
+const content = computed({
+    get() {
+        if (editor$.value) {
+            descriptionLength.value = editor$.value.getLength()
+            if (hotData.value.content && hotData.value.content != void 0) {
+                return hotData.value.content
+            } else {
+                editor$.value.setHtml(hotData.value.content)
+            }
+        }
+        return hotData.value.content || ''
+    },
+    set(value) {
+        // console.log(value)
+        store.commit('tag/setContent', { value })
+    },
+})
+
+const closeLink = () => {
+    showLink.value = false
+}
+const insertText = data => {
+    editor$.value.insertLink(data.text.value, data.link.value)
+    nextTick(() => {
+        store.commit('tag/update', { content: editor$.value.getHtml() })
+    })
+    closeLink()
+}
+const onDescriptionChange = ({ length, html, init }) => {
+    // // alert(1)
+    // console.log(length, html, init)
+    // if (init) {
+    //     return (descriptionLength.value = length)
+    // }
+
+    content.value = html
+    descriptionLength.value = length
+}
+const showBorder = ref(false)
+const onEditDescription = () => {
+    showBorder.value = false
+}
+
+const changeType = type => {
+    console.log(type)
+    info.value.type = type
+    store.commit('tag/setMetaType', type)
+}
+const maxContentLen = ref(10)
+</script>
+<style lang="scss" scoped>
+.hot-editor {
+    background: rgba(27, 27, 28, 0.8);
+    border-radius: 4px;
+    pointer-events: auto;
+    backdrop-filter: blur(4px);
+    // min-width: 400px;
+    .hot-content {
+        width: 100%;
+        padding: 30px 20px;
+        .submit-ctrl {
+            margin-top: 20px;
+            display: flex;
+
+            .radio-group {
+                flex: 1;
+                display: inline-flex;
+
+                > .radio {
+                    margin-right: 22px;
+                }
+            }
+
+            .submit {
+                flex: none;
+                cursor: pointer;
+            }
+        }
+        .desc-box {
+            width: 100%;
+            height: 158px;
+            background: rgba(255, 255, 255, 0.1);
+            border-radius: 4px;
+            border: 1px solid rgba(255, 255, 255, 0.2);
+            margin-bottom: 10px;
+            &.border {
+                border: 1px solid var(--editor-main-color);
+            }
+            .edit-box {
+                height: 128px;
+                padding: 10px;
+                box-sizing: border-box;
+            }
+        }
+        .tag-metas {
+            width: 100%;
+            height: 225px;
+            background: rgba(255, 255, 255, 0.1);
+            border-radius: 4px;
+            overflow: hidden;
+        }
+        .desc-link {
+            position: relative;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            background: rgba(255, 255, 255, 0.1);
+            border-radius: 0px 0px 4px 4px;
+            height: 30px;
+            padding: 0 10px;
+            .iconfont {
+                font-size: 16px;
+                cursor: pointer;
+            }
+            span {
+                font-size: 12px;
+            }
+        }
+        .input {
+            margin-bottom: 10px;
+        }
+    }
+    .header {
+        height: 60px;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding: 0 20px;
+        box-sizing: border-box;
+        color: #999;
+        border-bottom: 1px solid rgba(255, 255, 255, 0.16);
+        .header-title {
+            font-size: 16px;
+            font-weight: bold;
+        }
+        .close {
+            cursor: pointer;
+        }
+    }
+}
+</style>

+ 141 - 0
src/components/Tags/tag-view.vue

@@ -0,0 +1,141 @@
+<!--  -->
+<template>
+    <teleport to="body">
+        <div class="tag-layer" @click.stop="" :class="{ mobile: isMobile }">
+            <div class="tag-info" @click.stop="" id="tag-info">
+                <ui-icon class="close-btn" @click.stop="close" type="close"></ui-icon>
+                <div class="tag-metas" v-if="hotData.media[hotData.type].length > 0 && hotData.type != 'audio'">
+                    <metasImage :viewer="true" v-if="hotData.type == 'image'" />
+                    <metasWeb v-if="hotData.type == 'link'" />
+                </div>
+            </div>
+        </div>
+    </teleport>
+</template>
+
+<script setup>
+import { reactive, defineEmits, onBeforeMount, onMounted, ref, watchEffect, computed, watch, nextTick } from 'vue'
+import { Dialog } from '@/global_components/'
+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 { 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 {
+            // padding: 20px;
+            .close-btn {
+                position: absolute;
+                right: 20px;
+                top: -30px;
+                font-size: 18px;
+                cursor: pointer;
+            }
+        }
+    }
+}
+</style>

+ 76 - 0
src/components/Tags/treasure.vue

@@ -0,0 +1,76 @@
+<template>
+  <transition mode="out-in">
+    <div class="treasure" v-if="tagtype == 'treasure'">
+      <div class="treasurecon">
+        <p>恭喜您,找到寶藏!</p>
+        <img :src="require('@/assets/images/icon/gifts_on.png')" alt="" />
+        <div @click.stop="goto_now" class="btn">立即前往</div>
+      </div>
+    </div>
+  </transition>
+</template>
+
+<script setup>
+import { onMounted, watch, defineEmits, computed, ref, nextTick, defineProps } from "vue";
+import { useApp, getApp } from "@/app";
+import { useStore } from "vuex";
+const store = useStore();
+
+const tagtype = computed(() => store.getters["tag/tagClickType"]);
+
+const emit = defineEmits(["close"]);
+
+const goto_now = () => {
+  emit("close");
+};
+</script>
+
+<style lang="scss" scoped>
+.treasure {
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  margin: 0 auto;
+  max-width: 500px;
+  z-index: 101;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  justify-content: center;
+  .treasurecon {
+    text-align: center;
+    width: 90%;
+    p {
+      height: 33px;
+      font-size: 24px;
+      font-weight: 400;
+      color: #ffd980;
+      line-height: 33px;
+      text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2);
+    }
+    > img {
+      width: 100%;
+    }
+
+    .btn {
+      max-width: 122px;
+      margin: 0 auto;
+      height: 32px;
+      line-height: 32px;
+      color: #ed5d18;
+      background: linear-gradient(180deg, #fbecc7 0%, #fff8e8 6%, #f8e0b4 15%, #f1c387 52%, #fad78c 87%, #f8e2ad 94%, #fdde8e 100%);
+      border-radius: 16px;
+      font-size: 16px;
+      font-weight: bold;
+      position: relative;
+      top: -32px;
+      z-index: 99;
+    }
+  }
+}
+</style>

+ 108 - 0
src/components/Tags/waterfall.vue

@@ -0,0 +1,108 @@
+<template>
+  <transition mode="out-in">
+    <div class="waterfall" v-if="tagtype == 'waterfall'">
+      <div class="waterfallcon">
+        <div class="wfheader">
+          <img :src="require('@/assets/images/icon/top5.png')" alt="">
+          <ui-icon @click="emit('close')" type="close"></ui-icon>
+        </div>
+        <ul class="wfcon">
+          <li v-for="(item,i) in 5" :key="i">
+            <img v-if="i<=2" :src="require(`@/assets/images/icon/${i+1}.svg`)" alt="">
+            <span v-else>{{i+1}}</span>
+            <div class="wfavatar" :style="{backgroundImage:`url(${require(`@/assets/images/icon/gifts_on.png`)})`}"></div>
+            <div class="wfinfo">
+              <p>這裏是商品標題</p>
+              <p>¥ 198 | 查看 ></p>
+            </div>
+          </li>
+        </ul>
+      </div>
+    </div>
+  </transition>
+</template>
+
+<script setup>
+import { onMounted, watch, defineEmits, computed, ref, nextTick, defineProps } from "vue";
+import { useApp, getApp } from "@/app";
+import { useStore } from "vuex";
+const store = useStore();
+
+const tagtype = computed(() => store.getters["tag/tagClickType"]);
+
+const emit = defineEmits(["close"]);
+
+</script>
+
+<style lang="scss" scoped>
+.waterfall {
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  margin: 0 auto;
+  max-width: 500px;
+  z-index: 101;
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  justify-content: flex-start;
+  pointer-events: none;
+  .waterfallcon {
+    text-align: center;
+    pointer-events: auto;
+    width: 90%;
+    padding: 10px 20px;
+    margin-top: 20%;
+    background: rgba(0, 0, 0, 0.6);
+    border-radius: 2px;
+    .wfheader{
+      display: flex;
+      justify-content: space-between;
+      width: 100%;
+      align-items: center;
+      >img{
+        width: 80px;
+      }
+    }
+    .wfcon{
+      color: #fff;
+      width: 100%;
+      padding-left: 10%;
+      max-height: 35vh;
+      overflow-y: auto;
+      >li{
+        display: flex;
+        align-items: center;
+        margin: 16px 0;
+        >img, >span{
+          display: inline-block;
+          width: 20px;
+        }
+        .wfavatar{
+          width: 36px;
+          height: 36px;
+          background-size: cover;
+          margin: 0 10px;
+          background-color: #fff;
+          border-radius: 4px;
+        }
+        .wfinfo{
+          text-align: left;
+          >p{
+            font-size: 16px;
+            &:last-of-type{
+              color: rgba(255, 255, 255, 0.8);
+              font-size: 12px;
+              margin-top: 6px;
+            }
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 52 - 0
src/components/dialog/Alert.vue

@@ -0,0 +1,52 @@
+<template>
+    <ui-dialog>
+        <template v-slot:header>
+            <span>{{ title }}</span>
+            <i v-if="showCloseIcon" class="iconfont icon-close" @click="close"></i>
+        </template>
+        {{ content }}
+        <template v-slot:footer v-if="showFooter">
+            <ui-button type="submit" @click="close">{{ okText }}</ui-button>
+        </template>
+    </ui-dialog>
+</template>
+<script>
+import { defineComponent } from 'vue'
+import { isFunction, omit } from '../../utils'
+export default defineComponent({
+    name: 'ui-alert',
+    props: {
+        showCloseIcon: {
+            type: Boolean,
+            default: true,
+        },
+        showFooter: {
+            type: Boolean,
+            default: true,
+        },
+        title: {
+            type: String,
+            default: '提示',
+        },
+        okText: {
+            type: String,
+            default: '确定',
+        },
+        func: Function,
+        content: String,
+        destroy: Function,
+    },
+    setup: function (props, ctx) {
+        const close = () => {
+            if (isFunction(props.func) && props.func() === false) {
+                return
+            }
+            isFunction(props.destroy) && props.destroy()
+        }
+        return {
+            ...omit(props, 'destroy', 'func'),
+            close,
+        }
+    },
+})
+</script>

+ 58 - 0
src/components/dialog/Confirm.vue

@@ -0,0 +1,58 @@
+<template>
+    <ui-dialog>
+        <template v-slot:header>
+            <span>{{ title }}</span>
+            <i class="iconfont icon-close" @click="close('no')"></i>
+        </template>
+        <template v-if="$slots.content">
+            <slot name="content" />
+        </template>
+        <template v-else>
+            <div class="message" v-html="content"></div>
+        </template>
+        <template v-slot:footer>
+            <ui-button v-if="!single" type="cancel" @click="close('no')">{{ noText }}</ui-button>
+            <ui-button type="primary" @click="close('ok')">{{ okText }}</ui-button>
+        </template>
+    </ui-dialog>
+</template>
+<script>
+import { defineComponent } from 'vue'
+import { isFunction, omit } from '../../utils'
+export default defineComponent({
+    name: 'ui-confirm',
+    props: {
+        title: {
+            type: String,
+            default: '提示',
+        },
+        okText: {
+            type: String,
+            default: '确定',
+        },
+        noText: {
+            type: String,
+            default: '取消',
+        },
+        single: {
+            type: Boolean,
+            default: false,
+        },
+        func: Function,
+        content: String,
+        destroy: Function,
+    },
+    setup: function (props, ctx) {
+        const close = result => {
+            if (isFunction(props.func) && props.func(result) === false) {
+                return
+            }
+            isFunction(props.destroy) && props.destroy()
+        }
+        return {
+            ...omit(props, 'destroy', 'func'),
+            close,
+        }
+    },
+})
+</script>

+ 17 - 0
src/components/dialog/Dialog-content.vue

@@ -0,0 +1,17 @@
+<template>
+  <div class="ui-dialog__box">
+      <header v-if="$slots.header">
+          <slot name="header"></slot>
+      </header>
+      <section>
+          <slot></slot>
+      </section>
+      <footer v-if="$slots.footer">
+          <slot name="footer"></slot>
+      </footer>
+  </div>
+</template>
+
+<script>
+export default { name: 'ui-dialog-content' }
+</script>

+ 27 - 0
src/components/dialog/Dialog.vue

@@ -0,0 +1,27 @@
+<template>
+    <teleport to="body">
+        <div class="ui-dialog" :style="{ zIndex: zIndex }" v-if="show">
+            <dialog-content>
+                <template v-for="(slot, name) in $slots" v-slot:[name]="raw">
+                    <slot :name="name" v-bind="raw" />
+                </template>
+            </dialog-content>
+        </div>
+    </teleport>
+</template>
+<script>
+import { defineComponent, ref } from 'vue'
+import zindex from '../../utils/zindex'
+import DialogContent from './Dialog-content.vue'
+export default defineComponent({
+    name: "ui-dialog",
+    setup: function (props, ctx) {
+        const show = ref(true);
+        return {
+            show,
+            zIndex: zindex(),
+        };
+    },
+    components: { DialogContent }
+})
+</script>

+ 70 - 0
src/components/dialog/Toast.vue

@@ -0,0 +1,70 @@
+<template>
+    <teleport to="body">
+        <transition name="slide-down" mode="out-in" appear>
+            <div class="ui-toast" :style="{ zIndex: zIndex }" v-if="show">
+                <div class="ui-toast__box" :class="[type]">
+                    <i v-if="type !== 'fixed' && type" class="icon"></i>
+                    <div class="ui-toast__msg" v-html="content"></div>
+                    <i class="iconfont icon-close close" @click="close" v-if="showClose"></i>
+                </div>
+            </div>
+        </transition>
+    </teleport>
+</template>
+<script>
+import { defineComponent, onMounted, nextTick, ref } from 'vue'
+import zindex from '../../utils/zindex'
+export default defineComponent({
+    name: 'ui-toast',
+    props: {
+        type: String,
+        delay: Number,
+        content: String,
+        destroy: Function,
+        close: Function,
+        showClose: Boolean,
+    },
+    setup: function (props, ctx) {
+        const show = ref(true)
+        const close = () => {
+            show.value = false
+            nextTick(() => {
+                if (typeof props.close == 'function') {
+                    props.close()
+                }
+                typeof props.destroy === 'function' && props.destroy()
+            })
+        }
+
+        if (props.type !== 'fixed') {
+            setTimeout(() => close(), props.delay || 3000)
+        }
+        return {
+            show,
+            type: props.type,
+            close,
+            content: props.content,
+            zIndex: zindex(),
+        }
+    },
+})
+</script>
+<style lang="scss" scoped>
+.slide-down-enter-active,
+.slide-down-leave-active {
+    will-change: transform;
+    transition: all 0.35s ease-in-out;
+}
+.slide-down-enter-from {
+    opacity: 0;
+    transform: translate3d(0, -100%, 0);
+}
+.slide-down-enter {
+    opacity: 1;
+    transform: translate3d(-50%, 100%, 0);
+}
+.slide-down-leave-active {
+    opacity: 0;
+    transform: translate3d(0, -100%, 0);
+}
+</style>

+ 63 - 0
src/components/dialog/Window.vue

@@ -0,0 +1,63 @@
+<template>
+    <ui-dialog>
+        <template v-slot:header>
+            <span style="font-size: 16px">{{ title }}</span>
+            <i class="iconfont icon-close" @click="onNo" v-if="showCloseIcon"></i>
+        </template>
+        <template v-if="$slots.content">
+            <slot name="content" />
+        </template>
+        <template v-else>
+            {{ content }}
+        </template>
+        <template v-slot:footer>
+            <ui-button type="cancel" @click="onNo" v-if="showCancelButton">{{ noText }}</ui-button>
+            <ui-button :class="{ disabled: !canSubmit }" type="submit primary" @click="onOk">{{ okText }}</ui-button>
+        </template>
+    </ui-dialog>
+</template>
+<script>
+import { defineComponent } from 'vue'
+export default defineComponent({
+    name: 'ui-window',
+    props: {
+        title: {
+            type: String,
+            default: '提示'
+        },
+        okText: {
+            type: String,
+            default: '确定'
+        },
+        noText: {
+            type: String,
+            default: '取消'
+        },
+        showCloseIcon: {
+            type: Boolean,
+            default: true
+        },
+        showCancelButton: {
+            type: Boolean,
+            default: true
+        },
+        canSubmit: {
+            type: Boolean,
+            default: true
+        }
+    },
+    emits: ['ok', 'no'],
+    setup: function(props, ctx) {
+        const onNo = () => {
+            ctx.emit('no')
+        }
+        const onOk = () => {
+            ctx.emit('ok')
+        }
+        return {
+            onNo,
+            onOk
+        }
+    }
+})
+</script>

+ 93 - 0
src/components/dialog/index.js

@@ -0,0 +1,93 @@
+import Dialog from './Dialog'
+import Window from './Window'
+import Toast from './Toast'
+import Alert from './Alert'
+import Confirm from './Confirm'
+import DialogContent from './Dialog-content'
+import { mount } from '../../utils/componentHelper'
+
+Dialog.use = function use(app) {
+    Dialog.toast = function (options) {
+        if (typeof options == 'string') {
+            options = {
+                content: options,
+            }
+        }
+
+        const { destroy, vNode, el } = mount(Toast, {
+            app,
+            props: {
+                ...options,
+                destroy,
+            },
+        })
+
+        if (!Dialog.toast._destroys) {
+            Dialog.toast._destroys = []
+        }
+        Dialog.toast._destroys.push(destroy)
+
+        return {
+            hide: function () {
+                let destroy = null
+                while ((destroy = Dialog.toast._destroys.shift()) && destroy) {
+                    destroy()
+                }
+            }.bind(this),
+        }
+    }
+    Dialog.toast.hide = function () {
+        if (Dialog.toast._destroys && Dialog.toast._destroys.length) {
+            let destroy = Dialog.toast._destroys.pop()
+            destroy && destroy()
+        }
+    }
+    Dialog.alert = function (options) {
+        if (typeof options == 'string') {
+            options = {
+                content: options,
+            }
+        }
+
+        const { destroy } = mount(Alert, {
+            app,
+            props: { ...options, destroy: () => destroy() },
+        })
+
+        this.alert.hide = function () {
+            destroy()
+        }
+
+        return this.alert
+    }
+
+    Dialog.confirm = function (options) {
+        if (typeof options == 'string') {
+            options = {
+                content: options,
+            }
+        }
+
+        let promise
+        if (!options.func) {
+            promise = new Promise(resolve => {
+                options.func = result => resolve(result === 'ok')
+            })
+        }
+
+        const { destroy } = mount(Confirm, {
+            app,
+            props: { ...options, destroy: () => destroy() },
+        })
+
+        this.confirm.hide = function () {
+            destroy()
+        }
+
+        return promise || this.confirm
+    }
+}
+
+export { Window, Toast, Alert, Confirm, DialogContent }
+
+export default Dialog

+ 0 - 0
src/components/layout/index.vue


+ 302 - 0
src/components/layout/mobile/mobile-guides.vue

@@ -0,0 +1,302 @@
+<template>
+    <div class="guides" @touchmove.prevent :class="{ disable: !status.isPlaying && !modeCanSwitch }">
+        <div class="frame">
+            <ul>
+                <li v-for="(item, key) in roomLabels" :key="key" :class="{ active: key == index }" @click="onFrameActive(key)">
+                    <div class="content">
+                        <span>{{ item.label }}</span>
+                    </div>
+                    <div class="progress" v-if="status.isPlaying">
+                        <div class="value" :style="{ width: progress + '%' }"></div>
+                    </div>
+                </li>
+            </ul>
+        </div>
+    </div>
+</template>
+<script>
+// import config from "@/config"
+import { mapGetters } from 'vuex'
+// import { flyToNewMode, play } from "@/core"
+import { backgroundMusicPlayer } from '@/utils/sounds'
+// import transitions from "@/core/util/transitions"
+let frame
+export default {
+    props: {
+        show: Boolean,
+    },
+    data() {
+        return {
+            index: -1,
+            progress: 0,
+            disable: false,
+        }
+    },
+    created() {
+        let timer = null
+        // play.on("guide/play/start", index => {
+        //     this.onRoomPlay(true, false)
+        //     this.$store.commit("guide/SetStatus", {
+        //         isPlaying: true,
+        //     })
+        // })
+
+        // play.on("guide/play/pause", index => {
+        //     this.onRoomPlay(false)
+        //     this.$store.commit("guide/SetStatus", {
+        //         isPlaying: false,
+        //     })
+        // })
+
+        // play.on("guide/play/stop", () => {
+        //     this.onRoomPlay(false, true)
+        //     this.$store.commit("guide/SetStatus", {
+        //         isPlaying: false,
+        //     })
+        //     this.$emit("end")
+        // })
+
+        // play.on("guide/play/playing", index => {
+        //     this.$store.commit("guide/SetIndex", index)
+        // })
+
+        // play.on("guide/play/flyToStart", index => {
+        //     this.$store.commit("guide/SetIndex", index)
+        // })
+    },
+    mounted() {
+        frame = new Sly(this.$el.querySelector('.frame'), {
+            horizontal: !0,
+            itemNav: 'centered',
+            smart: !0,
+            scrollTrap: !0,
+            scrollBy: 1,
+            mouseDragging: !0,
+            touchDragging: !0,
+            speed: 300,
+            dragHandle: !0,
+            dynamicHandle: !0,
+            clickBar: !0,
+            scrollSource: this.$el,
+            activateOn: null,
+        }).init()
+
+        window.__scrollbars.push(frame)
+        this.$nextTick(() => {
+            frame.reload()
+            this.$emit('init')
+        })
+    },
+    watch: {
+        show() {
+            this.$nextTick(() => {
+                if (this.show) {
+                    // if (frame) {
+                    //     let index = window.__scrollbars.indexOf(frame)
+                    //     if (index != -1) {
+                    //         window.__scrollbars.splice(index, 1)
+                    //         frame.destroy()
+                    //     }
+                    //     frame = new Sly(this.$el.querySelector(".frame"), {
+                    //         horizontal: !0,
+                    //         itemNav: "centered",
+                    //         smart: !0,
+                    //         scrollTrap: !0,
+                    //         scrollBy: 1,
+                    //         mouseDragging: !0,
+                    //         touchDragging: !0,
+                    //         speed: 300,
+                    //         dragHandle: !0,
+                    //         dynamicHandle: !0,
+                    //         clickBar: !0,
+                    //         scrollSource: this.$el,
+                    //         activateOn: null,
+                    //     }).init()
+                    //     window.__scrollbars.push(frame)
+                    //     this.$nextTick(() => {
+                    //         frame.reload()
+                    //         if(this.status.isPlaying){
+                    //             frame.activate(this.index)
+                    //         }
+                    //     })
+                    // }
+                    if (this.status.isPlaying) {
+                        frame.activate(this.index)
+                    }
+                }
+            })
+        },
+        index() {
+            this.$nextTick(() => {
+                this.index != -1 && frame.activate(this.index)
+            })
+        },
+        disable() {
+            this.$emit('disable', this.disable)
+        },
+    },
+    computed: {
+        ...mapGetters({
+            mode: 'mode',
+            page: 'page',
+            modeCanSwitch: 'modeCanSwitch',
+            musicIndex: 'scene/musicIndex',
+            plays: 'guide/plays',
+            status: 'guide/status',
+            roomLabels: 'guide/roomLabels',
+            metadata: 'scene/metadata',
+        }),
+    },
+    methods: {
+        onFrameActive(index) {
+            let room = this.roomLabels[index]
+            this.disable = true
+            this.$nextTick(() => {
+                play.pause()
+                play.chooseFragment(room.index, 0, () => {
+                    //可能没执行函数doAfterFlyToPano,所以这里补上,逻辑也合理。
+                    if (play.control.flyToSingleCap) {
+                        play.control.flyToSingleCap = false
+                    }
+                    this.disable = false
+                })
+                this.$store.commit('guide/SetIndex', room.index)
+                this.index = index
+                //frame.activate(this.index)
+            })
+        },
+        onGuidePlay() {
+            if (this.status.isPlaying) {
+                play.pause()
+            } else {
+                if (this.index == -1) {
+                    this.index = 0
+                }
+                this.disable = true
+                let room = this.roomLabels[this.index]
+                // 当前房间重新开始播放
+                play.chooseFragment(room.index, 0, () => {
+                    //可能没执行函数doAfterFlyToPano,所以这里补上,逻辑也合理。
+                    if (play.control.flyToSingleCap) {
+                        play.control.flyToSingleCap = false
+                    }
+                    play.start(() => {
+                        this.disable = false
+                    })
+                })
+            }
+        },
+        onRoomPlay(state, reset) {
+            if (!state) {
+                this.timer && transitions.cancel(this.timer)
+                this.progress = 100
+                setTimeout(() => {
+                    this.progress = 0
+                    if (reset) {
+                        this.index = 0
+                        this.$nextTick(() => {
+                            this.index = -1
+                        })
+                    }
+                }, 200)
+                return
+            }
+            let data = this.roomLabels[this.index]
+            if (!data) {
+                return
+            }
+            let time = parseInt(Number(data.cTime).toFixed(3) * 1000)
+            this.timer = transitions.start(progress => {
+                if (progress == 1) {
+                    this.progress = 0
+                    this.index++
+                    this.onRoomPlay(state)
+                } else {
+                    this.progress = progress * 100
+                }
+            }, time)
+        },
+        reload() {
+            this.$nextTick(() => {
+                frame.reload()
+            })
+        },
+    },
+}
+</script>
+<style lang="scss" scoped>
+.guides {
+    position: relative;
+    height: 100%;
+    width: 100%;
+    touch-action: none;
+    color: rgba(255, 255, 255, 0.5);
+    &.overflow {
+        &::before {
+            content: '';
+            position: absolute;
+            left: 0;
+            top: 0;
+            width: 0.52632rem;
+            height: 100%;
+            background: linear-gradient(270deg, rgba(8, 8, 8, 0) 0%, #000000 100%);
+        }
+        &::after {
+            content: '';
+            position: absolute;
+            right: 0;
+            top: 0;
+            width: 0.52632rem;
+            height: 100%;
+            background: linear-gradient(90deg, rgba(8, 8, 8, 0) 0%, #000000 100%);
+        }
+    }
+}
+.frame {
+    height: 100%;
+}
+ul {
+    display: flex;
+    height: 100%;
+    li {
+        cursor: pointer;
+        position: relative;
+        flex-shrink: 0;
+        margin-right: 4px;
+        &:last-child {
+            margin-right: 0px;
+        }
+        &.active {
+            color: #00c2c4;
+            .progress {
+                display: block;
+            }
+        }
+        .content {
+            display: flex;
+            width: 90px;
+            height: 100%;
+            align-items: center;
+            justify-content: center;
+            background-color: rgba(0, 0, 0, 0.5);
+            border-radius: 0.08rem;
+        }
+        .progress {
+            display: none;
+            position: absolute;
+            left: 2px;
+            bottom: 2px;
+            right: 2px;
+            height: 2px;
+            border-radius: 4px;
+            background-color: rgba(0, 0, 0, 0.3);
+            .value {
+                height: 100%;
+                width: 0;
+                border-radius: 4px;
+                background-color: rgb(0, 194, 196);
+            }
+        }
+    }
+}
+</style>

+ 35 - 0
src/components/loading/Loading.vue

@@ -0,0 +1,35 @@
+<template>
+    <teleport :to="el">
+        <div class="ui-loading" :style="{ zIndex, ['--width']: size + 'px', ['--color']: color }">
+            <div class="ui-loading__box">
+                <div class="default">
+                    <div></div>
+                    <div></div>
+                    <div></div>
+                </div>
+            </div>
+        </div>
+    </teleport>
+</template>
+<script setup>
+import { defineProps } from 'vue'
+import getZIndex from '../../utils/zindex'
+
+defineProps({
+    el: {
+        default: 'body',
+    },
+    size: {
+        default: 15,
+    },
+    color: {
+        default: '#fff',
+    },
+})
+
+const zIndex = getZIndex()
+</script>
+
+<script>
+export default { name: 'ui-loading' }
+</script>

+ 36 - 0
src/components/loading/index.js

@@ -0,0 +1,36 @@
+import Loading from './Loading'
+import { mount } from '../../utils/componentHelper'
+
+const seat = 1
+const closeStack = []
+Loading.use = function use(app) {
+    Loading.show = function (config) {
+        if (closeStack.length) {
+            closeStack.push(seat)
+        } else {
+            const { destroy } = mount(Loading, {
+                app,
+                props: { ...config },
+            })
+            closeStack.push(destroy)
+        }
+    }
+    Loading.hide = function () {
+        if (closeStack.length) {
+            const close = closeStack.pop()
+            if (close !== seat) {
+                close()
+            }
+        }
+    }
+    Loading.hideAll = function () {
+        for (const close of closeStack) {
+            if (close !== seat) {
+                close()
+            }
+        }
+        closeStack.length = 0
+    }
+}
+
+export default Loading

+ 542 - 0
src/components/shared/Loading.vue

@@ -0,0 +1,542 @@
+<template>
+    <transition appear name="custom-classes-transition" leave-active-class="animated fadeOut faster">
+        <div v-if="show" class="scene-loading" :class="{ small: small, thumb: props.thumb, default: thumbStyle }" @touchmove.prevent :style="thumb && { backgroundImage: `url(${thumb})` }">
+            <div class="content">
+                <div :style="`transform:translateZ(0) scale(${small});`">
+                    <div v-for="item in 3" :key="item" :class="`brick brick-${item} ${enter ? 'enter' : ''}`">
+                        <div class="brick-top face"></div>
+                        <div class="brick-bottom face"></div>
+                        <div class="brick-left face"></div>
+                        <div class="brick-right face"></div>
+                        <div class="brick-front face"></div>
+                        <div class="brick-back face"></div>
+                    </div>
+                </div>
+
+                <div :class="`logo`" v-show="metadata.num">
+                    <div>
+                        <div v-if="loadingLogoFile" class="logo-main" :style="{ 'background-image': `url(${loadingLogoFile})` }"></div>
+                        <div v-else>
+                            <i class="iconfont icon-hengbiaoCN"></i>
+                        </div>
+                    </div>
+                </div>
+                <div class="copy-right" v-if="!loadingLogoFile">
+                    <span>四维时代提供技术支持</span>
+                </div>
+            </div>
+        </div>
+    </transition>
+</template>
+<script setup>
+import { ref, watch, computed, onMounted, defineProps } from 'vue'
+import { useStore } from 'vuex'
+import { useApp } from '@/app'
+const props = defineProps({
+    small: {
+        type: Boolean,
+        default: false,
+    },
+    thumb: {
+        type: Boolean,
+        default: false,
+    },
+})
+const store = useStore()
+const progres = ref(0)
+const enter = ref(false)
+const thumb = ref(null)
+const thumbStyle = ref(null)
+const show = ref(true)
+const metadata = computed(() => store.getters['scene/metadata'])
+const loadingLogoFile = computed(() => store.getters['scene/loadingLogoFile'])
+
+onMounted(() => {
+    enter.value = true
+})
+
+if (props.thumb) {
+    useApp().then(app => {
+        app.Scene.on('ready', () => {
+            show.value = false
+        })
+        app.store.on('metadata', metadata => {
+            if (metadata.entry) {
+                thumb.value = app.resource.getUserResourceURL('thumb-2k.jpg')
+            } else {
+                thumb.value = require('@/assets/images/loading.jpg')
+                thumbStyle.value = 'default'
+            }
+        })
+    })
+}
+</script>
+<style lang="scss" scoped>
+@use "sass:math";
+// $brick-width: 15px;
+// $brick-height: 5px;
+
+.scene-loading {
+    $brick-width: 30px;
+    $brick-height: 10px;
+    display: table;
+    table-layout: fixed;
+    height: 100%;
+    width: 100%;
+    z-index: 100;
+
+    &.thumb {
+        position: fixed;
+        left: 0;
+        top: 0;
+        background-repeat: no-repeat;
+        background-position: center center;
+        background-size: cover;
+        z-index: 5000;
+        &.default {
+            background-position: right center;
+        }
+    }
+
+    * {
+        opacity: 1 !important;
+    }
+
+    .content {
+        width: 100%;
+        height: 100%;
+        display: table-cell;
+        text-align: center;
+        vertical-align: middle;
+        .all {
+            perspective: 100px;
+            text-align: center;
+            display: inline-block;
+        }
+        .brick {
+            text-align: center;
+            position: relative;
+            width: $brick-width;
+            height: $brick-height;
+            transform-style: preserve-3d;
+            transform-origin: math.div($brick-width, 2) math.div($brick-height, 2);
+            margin: 0 auto math.div($brick-width, 12);
+        }
+        .brick-1 {
+            animation: rorate 2s 1s infinite linear;
+            transform: rotateX(-30deg) rotateY(45deg) translateZ(30px);
+        }
+        .brick-2 {
+            animation: rorate 2s 0.75s infinite linear;
+            transform: rotateX(-30deg) rotateY(45deg) translateZ(20px);
+        }
+        .brick-3 {
+            animation: rorate 2s 0.5s infinite linear;
+            transform: rotateX(-30deg) rotateY(45deg) translateZ(10px);
+        }
+
+        .face {
+            width: $brick-width;
+            height: $brick-height;
+            overflow: hidden;
+            position: absolute;
+            background: rgba(255, 255, 255, 0.4);
+            opacity: 0;
+        }
+        .brick.enter {
+            transition: 0.5s all;
+            transform: rotateX(-30deg) rotateY(45deg);
+            .face {
+                transition: 0.5s all;
+                opacity: 1;
+            }
+        }
+        .brick-2 {
+            .face {
+                background: rgba(255, 255, 255, 0.3);
+            }
+            .brick-front,
+            .brick-back {
+                background: rgba(255, 255, 255, 0.4);
+            }
+        }
+        .brick-3 {
+            .face {
+                background: rgba(255, 255, 255, 0.2);
+            }
+            .brick-front,
+            .brick-back {
+                background: rgba(255, 255, 255, 0.1);
+            }
+        }
+        .brick-front {
+            transform: translate3d(0, 0, math.div($brick-width, 2));
+            background: rgba(255, 255, 255, 0.6);
+        }
+        .brick-top {
+            width: $brick-width;
+            height: $brick-width;
+            transform: rotateX(90deg) translate3d(0, 0, math.div($brick-width, 2));
+            background-color: rgba(255, 255, 255, 0.8);
+        }
+        .brick-bottom {
+            width: $brick-width;
+            height: $brick-width;
+            transform: rotateX(-90deg) translate3d(0, 0, math.div(-$brick-height, 2));
+            background-color: rgba(255, 255, 255, 0.8);
+        }
+        .brick-left {
+            transform: rotateY(-90deg) translate3d(0, 0, math.div($brick-width, 2));
+            background: rgba(255, 255, 255, 0.5);
+        }
+        .brick-right {
+            transform: rotateY(90deg) translate3d(0, 0, math.div($brick-width, 2));
+            background: rgba(255, 255, 255, 0.5);
+        }
+        .brick-back {
+            transform: rotateY(180deg) translate3d(0, 0, math.div($brick-width, 2));
+            background: rgba(255, 255, 255, 0.6);
+        }
+    }
+    .logo {
+        width: 100%;
+        // height: 48px;
+        color: #fff;
+        font-size: 16px;
+        text-align: center;
+        // margin-top: 12px;
+        opacity: 0;
+        // visibility: hidden;
+        &.enter {
+            animation: logo-enter 0.5s forwards;
+            visibility: initial;
+        }
+        .logo-main {
+            // width: 100%;
+            // height: 80px;
+            width: 140px;
+            height: 70px;
+            position: absolute;
+            left: 50%;
+            top: 80%;
+            transform: translateX(-50%) translateY(-50%);
+        }
+        div {
+            margin: 0 auto;
+            width: 100%;
+            height: 100%;
+            position: relative;
+            background-size: contain;
+            background-repeat: no-repeat;
+            background-position: center center;
+            min-height: 70px;
+        }
+        i {
+            position: absolute;
+            left: 50%;
+            top: 65%;
+            transform: translateX(-50%) translateY(-50%);
+            font-size: 148px;
+        }
+    }
+    .copy-right {
+        color: rgba(255, 255, 255, 0.6);
+        opacity: 0.5;
+        position: absolute;
+        left: 50%;
+        bottom: 30px;
+        transform: translateX(-50%);
+        line-height: 30px;
+        height: 48px;
+        white-space: nowrap;
+        span {
+            white-space: nowrap;
+        }
+    }
+}
+.small {
+    $brick-width: 12px;
+    $brick-height: 4px;
+    display: table;
+    table-layout: fixed;
+    // pointer-events: none;
+    // position: absolute;
+    // left: 50%;
+    // top: 50%;
+    // transform: translate(-50%, -50%);
+    height: 100%;
+    width: 100%;
+    // background-color: rgba(0, 0, 0, 0.45);
+    z-index: 100;
+    * {
+        opacity: 1 !important;
+    }
+
+    .content {
+        width: 100%;
+        height: 100%;
+        display: table-cell;
+        text-align: center;
+        vertical-align: middle;
+        .all {
+            perspective: 100px;
+            text-align: center;
+            display: inline-block;
+        }
+        .brick {
+            text-align: center;
+            position: relative;
+            width: $brick-width;
+            height: $brick-height;
+            transform-style: preserve-3d;
+            transform-origin: math.div($brick-width, 2) math.div($brick-height, 2);
+            margin: 0 auto math.div($brick-width, 12);
+        }
+        .brick-1 {
+            animation: rorate 2s 1s infinite linear;
+            transform: rotateX(-30deg) rotateY(45deg) translateZ(30px);
+        }
+        .brick-2 {
+            animation: rorate 2s 0.75s infinite linear;
+            transform: rotateX(-30deg) rotateY(45deg) translateZ(20px);
+        }
+        .brick-3 {
+            animation: rorate 2s 0.5s infinite linear;
+            transform: rotateX(-30deg) rotateY(45deg) translateZ(10px);
+        }
+
+        .face {
+            width: $brick-width;
+            height: $brick-height;
+            overflow: hidden;
+            position: absolute;
+            background: rgba(255, 255, 255, 0.4);
+            opacity: 0;
+        }
+        .brick.enter {
+            transition: 0.5s all;
+            transform: rotateX(-30deg) rotateY(45deg);
+            .face {
+                transition: 0.5s all;
+                opacity: 1;
+            }
+        }
+        .brick-2 {
+            .face {
+                background: rgba(255, 255, 255, 0.3);
+            }
+            .brick-front,
+            .brick-back {
+                background: rgba(255, 255, 255, 0.4);
+            }
+        }
+        .brick-3 {
+            .face {
+                background: rgba(255, 255, 255, 0.2);
+            }
+            .brick-front,
+            .brick-back {
+                background: rgba(255, 255, 255, 0.1);
+            }
+        }
+        .brick-front {
+            transform: translate3d(0, 0, math.div($brick-width, 2));
+            background: rgba(255, 255, 255, 0.6);
+        }
+        .brick-top {
+            width: $brick-width;
+            height: $brick-width;
+            transform: rotateX(90deg) translate3d(0, 0, math.div($brick-width, 2));
+            background-color: rgba(255, 255, 255, 0.8);
+        }
+        .brick-bottom {
+            width: $brick-width;
+            height: $brick-width;
+            transform: rotateX(-90deg) translate3d(0, 0, math.div(-$brick-height, 2));
+            background-color: rgba(255, 255, 255, 0.8);
+        }
+        .brick-left {
+            transform: rotateY(-90deg) translate3d(0, 0, math.div($brick-width, 2));
+            background: rgba(255, 255, 255, 0.5);
+        }
+        .brick-right {
+            transform: rotateY(90deg) translate3d(0, 0, math.div($brick-width, 2));
+            background: rgba(255, 255, 255, 0.5);
+        }
+        .brick-back {
+            transform: rotateY(180deg) translate3d(0, 0, math.div($brick-width, 2));
+            background: rgba(255, 255, 255, 0.6);
+        }
+    }
+    .logo {
+        width: 100%;
+        // height: 48px;
+        color: #fff;
+        font-size: 16px;
+        text-align: center;
+        // margin-top: 12px;
+        opacity: 0;
+        // visibility: hidden;
+        &.enter {
+            animation: logo-enter 0.5s forwards;
+            visibility: initial;
+        }
+        .logo-main {
+            // width: 100%;
+            // height: 80px;
+            width: 70px;
+            height: 35px;
+            position: absolute;
+            left: 50%;
+            top: 80%;
+            transform: translateX(-50%) translateY(-50%);
+        }
+        div {
+            margin: 0 auto;
+            width: 100%;
+            height: 100%;
+            position: relative;
+            background-size: contain;
+            background-repeat: no-repeat;
+            background-position: center center;
+            min-height: 35px;
+        }
+        i {
+            position: absolute;
+            left: 50%;
+            top: 65%;
+            transform: translateX(-50%) translateY(-50%);
+            font-size: 74px;
+        }
+    }
+
+    .logo-bottom {
+        width: 100%;
+        height: 48px;
+        color: #fff;
+        font-size: 16px;
+        position: absolute;
+        bottom: 42px;
+        left: 0;
+        div {
+            margin: 0 auto;
+            width: 100%;
+            height: 100%;
+            position: relative;
+            background-size: contain;
+            background-repeat: no-repeat;
+            background-position: center center;
+        }
+        i {
+            position: absolute;
+            left: 50%;
+            top: 50%;
+            transform: translateX(-50%) translateY(-50%);
+            font-size: 140px;
+        }
+    }
+}
+[show-mode='mobile'],
+[edit-mode='mobile'] {
+    $brick-width: 24px;
+    $brick-height: 8px;
+    .content {
+        .brick {
+            width: $brick-width;
+            height: $brick-height;
+            transform-origin: math.div($brick-width, 2) math.div($brick-height, 2);
+            margin: 0 auto math.div($brick-width, 12);
+            &.enter {
+                transition: 0.5s all;
+                transform: rotateX(-30deg) rotateY(45deg);
+
+                .face {
+                    transition: 0.5s all;
+                    opacity: 1;
+                }
+            }
+        }
+        .brick-1 {
+            animation: rorate 2s 1s infinite linear;
+            transform: rotateX(-30deg) rotateY(45deg) translateZ(30px);
+        }
+        .brick-2 {
+            animation: rorate 2s 0.75s infinite linear;
+            transform: rotateX(-30deg) rotateY(45deg) translateZ(20px);
+        }
+        .brick-3 {
+            animation: rorate 2s 0.5s infinite linear;
+            transform: rotateX(-30deg) rotateY(45deg) translateZ(10px);
+        }
+        .face {
+            width: $brick-width;
+            height: $brick-height;
+        }
+        .brick-front {
+            transform: translate3d(0, 0, math.div($brick-width, 2));
+        }
+        .brick-top {
+            width: $brick-width;
+            height: $brick-width;
+            transform: rotateX(90deg) translate3d(0, 0, math.div($brick-width, 2));
+        }
+        .brick-bottom {
+            width: $brick-width;
+            height: $brick-width;
+            transform: rotateX(-90deg) translate3d(0, 0, math.div(-$brick-height, 2));
+        }
+        .brick-left {
+            transform: rotateY(-90deg) translate3d(0, 0, math.div($brick-width, 2));
+        }
+        .brick-right {
+            transform: rotateY(90deg) translate3d(0, 0, math.div($brick-width, 2));
+        }
+        .brick-back {
+            transform: rotateY(180deg) translate3d(0, 0, math.div($brick-width, 2));
+        }
+    }
+    .logo {
+        margin-top: 5px;
+        .logo-main {
+            margin-top: 15px;
+        }
+        i {
+            font-size: 100px;
+        }
+    }
+    // .bottom-intro {
+    //     div {
+    //         height: 36px;
+    //     }
+    // }
+    .logo-bottom {
+        bottom: 0.7rem;
+        i {
+            font-size: 75px;
+        }
+        div {
+            height: 36px;
+        }
+    }
+}
+
+@keyframes logo-enter {
+    from {
+        opacity: 0;
+    }
+    to {
+        opacity: 1;
+    }
+}
+@keyframes rorate {
+    0% {
+        transform: rotateX(-30deg) rotateY(-45deg);
+    }
+    50% {
+        transform: rotateX(-30deg) rotateY(-315deg);
+    }
+    100% {
+        transform: rotateX(-30deg) rotateY(-315deg);
+    }
+}
+</style>

+ 79 - 0
src/components/shared/Password.vue

@@ -0,0 +1,79 @@
+<template>
+    <ui-window v-if="show" @ok="onOk" @no="onNo" :showCloseIcon="false" :showCancelButton="false">
+        <template v-slot:content>
+            <div class="wrapper">
+                <ui-input type="password" placeholder="请输入浏览密码" maxlength="4" v-model.trim="password" width="100%" @input="onPasswordChange" @keyup.enter="onOk" />
+                <span class="error">{{ error }}</span>
+            </div>
+        </template>
+    </ui-window>
+</template>
+<script setup>
+import { ref, watch, computed, onMounted, defineProps } from 'vue'
+import { useStore } from 'vuex'
+import { getApp, getNum } from '@/app'
+import { Dialog } from '@/global_components/'
+
+const show = ref(false)
+const store = useStore()
+const metadata = computed(() => store.getters['scene/metadata'])
+const error = ref('')
+const password = ref('')
+const onOk = () => {
+    let app = getApp()
+
+    error.value = ''
+
+    if (!password.value) {
+        return (error.value = '请输入密码')
+    }
+
+    app.remote_viewer
+        .check_key({
+            num: getNum(),
+            password: password.value,
+        })
+        .then(response => {
+            if (response.success) {
+                show.value = false
+                app.Scene.unlock()
+            } else {
+                error.value = '密码错误'
+            }
+        })
+        .catch(error => {
+            Dialog.toast('操作失败,请稍后再试')
+        })
+}
+const onNo = () => {
+    show.value = false
+}
+const onPasswordChange = e => {
+    password.value = e.target.value.replace(/[^\w]/g, '').replace(/_/g, '')
+}
+watch(metadata, () => {
+    if (metadata.value) {
+        if (metadata.value.controls.showLock) {
+            show.value = true
+        } else {
+            getApp().Scene.unlock()
+        }
+    }
+})
+</script>
+<style lang="scss" scoped>
+.wrapper {
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+}
+.error {
+    height: 18px;
+    margin-top: 10px;
+    color: red;
+    text-align: left;
+    width: 100%;
+    padding-left: 10px;
+}
+</style>

二进制
src/global_components/assets/img/icons/toast-error.png


二进制
src/global_components/assets/img/icons/toast-success.png


二进制
src/global_components/assets/img/icons/toast-warn.png


+ 63 - 0
src/global_components/assets/scss/_base-vars.scss

@@ -0,0 +1,63 @@
+
+:root {
+  --colors-primary-fill: 255, 255, 255;
+  --colors-primary-base-fill: 0, 200, 175;
+  --colors-primary-base: rgb(var(--colors-primary-base-fill));
+  --colors-primary-hover: #4DD8C7;
+  // --colors-primary-hover: #008B7A;
+  --colors-primary-active: #008B7A;
+  --colors-primary-click: #005046;
+  --colors-warn: #FA3F48;
+  --colors-color: #999;
+  --colors-border-color: rgba(var(--colors-primary-fill), 0.16);
+  --colors-content-color: rgb(--colors-primary-fill);
+  
+  
+  --colors-normal-back: rgba(var(--colors-primary-fill), 0.1);
+  --colors-normal-base: rgba(var(--colors-primary-fill), 0.4);
+  --colors-normal-hover: rgba(var(--colors-primary-fill), 1);
+  --colors-normal-click: var(--colors-primary-click);
+
+  --colors-normal-fill-back: var(--colors-normal-back);
+  --colors-normal-fill-base: var(--colors-normal-base);
+  --colors-normal-fill-hover: var(--colors-normal-hover);
+  --colors-normal-fill-click: var(--colors-primary-click);
+
+  --colors-error-fill: 250, 63, 72;
+  
+  --small-size: 12px;
+  --medium-size: 14px;
+  --big-size: 16px;
+
+
+  // 正常
+  --color-main-normal: var(--colors-primary-base);
+  // 悬停
+  --color-main-hover: var(--colors-primary-hover);
+  // 点击
+  --color-main-focus: var(--colors-primary-click);
+
+
+  --editor-head-filter: blur(0px);
+  --editor-head-height: 50px;
+  
+  --editor-head-back: rgba(20, 20, 20, 0.86);
+
+  --editor-menu-filter: var(--editor-head-filter);
+  --editor-menu-width: 80px;
+  --editor-menu-left: 0px;
+  --editor-menu-right: 0px;
+  --editer-menu-fill: 27, 27, 28;
+  --editor-menu-back: rgba(var(--editer-menu-fill), 0.8);
+  --editor-menu-active-back: rgba(var(--colors-primary-fill), 0.06);
+  --editor-menu-color: #999;
+  --editor-menu-active: rgba(255,255,255,0.06);;
+
+
+  --editor-toolbox-top: var(--editor-head-height);
+  --editor-toolbox-left: calc(var(--editor-menu-left) + var(--editor-menu-width));
+  --editor-toolbox-width: 340px;
+  --editor-toolbox-back: var(--editor-menu-back);
+  --editor-toolbox-padding: 20px;
+  --editor-toolbar-height: 60px;
+}

+ 293 - 0
src/global_components/assets/scss/_base.scss

@@ -0,0 +1,293 @@
+/*!
+ * ress.css • v4.0.0
+ * MIT License
+ * github.com/filipelinhares/ress
+ */
+
+/* # =================================================================
+   # Global selectors
+   # ================================================================= */
+
+html {
+    box-sizing: border-box;
+    -webkit-text-size-adjust: 100%; /* Prevent adjustments of font size after orientation changes in iOS */
+    word-break: normal;
+    -moz-tab-size: 4;
+    tab-size: 4;
+}
+
+*,
+::before,
+::after {
+    background-repeat: no-repeat; /* Set `background-repeat: no-repeat` to all elements and pseudo elements */
+    box-sizing: inherit;
+    appearance: none;
+    -webkit-tap-highlight-color: rgba(255,255,255,0);
+    text-rendering: optimizeLegibility!important;
+    -webkit-font-smoothing: antialiased!important;
+}
+
+::before,
+::after {
+    text-decoration: inherit; /* Inherit text-decoration and vertical align to ::before and ::after pseudo elements */
+    vertical-align: inherit;
+}
+
+* {
+    padding: 0; /* Reset `padding` and `margin` of all elements */
+    margin: 0;
+}
+
+/* # =================================================================
+     # General elements
+     # ================================================================= */
+
+hr {
+    overflow: visible; /* Show the overflow in Edge and IE */
+    height: 0; /* Add the correct box sizing in Firefox */
+    color: inherit; /* Correct border color in Firefox. */
+}
+
+details,
+main {
+    display: block; /* Render the `main` element consistently in IE. */
+}
+
+summary {
+    display: list-item; /* Add the correct display in all browsers */
+}
+
+small {
+    font-size: 80%; /* Set font-size to 80% in `small` elements */
+}
+
+[hidden] {
+    display: none; /* Add the correct display in IE */
+}
+
+abbr[title] {
+    border-bottom: none; /* Remove the bottom border in Chrome 57 */
+    /* Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari */
+    text-decoration: underline;
+    text-decoration: underline dotted;
+}
+
+a {
+    background-color: transparent; /* Remove the gray background on active links in IE 10 */
+}
+
+a:active,
+a:hover {
+    outline-width: 0; /* Remove the outline when hovering in all browsers */
+}
+
+code,
+kbd,
+pre,
+samp {
+    font-family: monospace, monospace; /* Specify the font family of code elements */
+}
+
+pre {
+    font-size: 1em; /* Correct the odd `em` font sizing in all browsers */
+}
+
+b,
+strong {
+    font-weight: bolder; /* Add the correct font weight in Chrome, Edge, and Safari */
+}
+
+/* https://gist.github.com/unruthless/413930 */
+sub,
+sup {
+    font-size: 75%;
+    line-height: 0;
+    position: relative;
+    vertical-align: baseline;
+}
+
+sub {
+    bottom: -0.25em;
+}
+
+sup {
+    top: -0.5em;
+}
+
+table {
+    border-color: inherit; /* Correct border color in all Chrome, Edge, and Safari. */
+    text-indent: 0; /* Remove text indentation in Chrome, Edge, and Safari */
+}
+
+/* # =================================================================
+     # Forms
+     # ================================================================= */
+
+input {
+    border-radius: 0;
+}
+
+/* Replace pointer cursor in disabled elements */
+[disabled] {
+    cursor: default;
+    user-select: none;
+}
+
+[type='number']::-webkit-inner-spin-button,
+[type='number']::-webkit-outer-spin-button {
+    height: auto; /* Correct the cursor style of increment and decrement buttons in Chrome */
+}
+
+[type='search'] {
+    -webkit-appearance: textfield; /* Correct the odd appearance in Chrome and Safari */
+    outline-offset: -2px; /* Correct the outline style in Safari */
+}
+
+[type='search']::-webkit-search-decoration {
+    -webkit-appearance: none; /* Remove the inner padding in Chrome and Safari on macOS */
+}
+
+textarea {
+    overflow: auto; /* Internet Explorer 11+ */
+    resize: vertical; /* Specify textarea resizability */
+}
+
+button,
+input,
+optgroup,
+select,
+textarea {
+    font: inherit; /* Specify font inheritance of form elements */
+}
+
+optgroup {
+    font-weight: bold; /* Restore the font weight unset by the previous rule */
+}
+
+button {
+    overflow: visible; /* Address `overflow` set to `hidden` in IE 8/9/10/11 */
+}
+
+button,
+select {
+    text-transform: none; /* Firefox 40+, Internet Explorer 11- */
+}
+
+/* Apply cursor pointer to button elements */
+button,
+[type='button'],
+[type='reset'],
+[type='submit'],
+[role='button'] {
+    cursor: pointer;
+    color: inherit;
+}
+
+/* Remove inner padding and border in Firefox 4+ */
+button::-moz-focus-inner,
+[type='button']::-moz-focus-inner,
+[type='reset']::-moz-focus-inner,
+[type='submit']::-moz-focus-inner {
+    border-style: none;
+    padding: 0;
+}
+
+/* Replace focus style removed in the border reset above */
+button:-moz-focusring,
+[type='button']::-moz-focus-inner,
+[type='reset']::-moz-focus-inner,
+[type='submit']::-moz-focus-inner {
+    outline: 1px dotted #ccc;
+}
+
+button,
+  html [type='button'], /* Prevent a WebKit bug where (2) destroys native `audio` and `video`controls in Android 4 */
+  [type='reset'],
+  [type='submit'] {
+    -webkit-appearance: button; /* Correct the inability to style clickable types in iOS */
+}
+
+/* Remove the default button styling in all browsers */
+button,
+input,
+select,
+textarea {
+    background-color: transparent;
+    border-style: none;
+}
+
+a:focus,
+button:focus,
+input:focus,
+select:focus,
+textarea:focus {
+    outline-width: 0;
+}
+
+/* Style select like a standard input */
+select {
+    -moz-appearance: none; /* Firefox 36+ */
+    -webkit-appearance: none; /* Chrome 41+ */
+}
+
+select::-ms-expand {
+    display: none; /* Internet Explorer 11+ */
+}
+
+select::-ms-value {
+    color: currentColor; /* Internet Explorer 11+ */
+}
+
+legend {
+    border: 0; /* Correct `color` not being inherited in IE 8/9/10/11 */
+    color: inherit; /* Correct the color inheritance from `fieldset` elements in IE */
+    display: table; /* Correct the text wrapping in Edge and IE */
+    max-width: 100%; /* Correct the text wrapping in Edge and IE */
+    white-space: normal; /* Correct the text wrapping in Edge and IE */
+    max-width: 100%; /* Correct the text wrapping in Edge 18- and IE */
+}
+
+::-webkit-file-upload-button {
+    /* Correct the inability to style clickable types in iOS and Safari */
+    -webkit-appearance: button;
+    color: inherit;
+    font: inherit; /* Change font properties to `inherit` in Chrome and Safari */
+}
+
+/* # =================================================================
+     # Specify media element style
+     # ================================================================= */
+
+img {
+    border-style: none; /* Remove border when inside `a` element in IE 8/9/10 */
+}
+
+/* Add the correct vertical alignment in Chrome, Firefox, and Opera */
+progress {
+    vertical-align: baseline;
+}
+
+/* # =================================================================
+     # Accessibility
+     # ================================================================= */
+
+/* Specify the progress cursor of updating elements */
+[aria-busy='true'] {
+    cursor: progress;
+}
+
+/* Specify the pointer cursor of trigger elements */
+[aria-controls] {
+    cursor: pointer;
+}
+
+/* Specify the unstyled cursor of disabled, not-editable, or otherwise inoperable elements */
+[aria-disabled='true'] {
+    cursor: default;
+}
+
+.disabled,
+:disabled{
+    opacity: 0.3 !important;
+    pointer-events: none !important;
+}

+ 0 - 0
src/global_components/assets/scss/_components.scss


部分文件因为文件数量过多而无法显示