chenlei 3 maanden geleden
commit
1e28f3f257
100 gewijzigde bestanden met toevoegingen van 8574 en 0 verwijderingen
  1. 4 0
      .env.development
  2. 4 0
      .env.production
  3. 25 0
      .gitignore
  4. 2 0
      .npmrc
  5. 8 0
      .prettierignore
  6. 10 0
      .prettierrc
  7. 115 0
      README.md
  8. 11 0
      auto-imports.d.ts
  9. 18 0
      components.d.ts
  10. 42 0
      config.js
  11. 22 0
      env.d.ts
  12. 30 0
      hotspot.html
  13. 33 0
      index.html
  14. 51 0
      package.json
  15. 4040 0
      pnpm-lock.yaml
  16. BIN
      public/favicon/favicon-syjy.ico
  17. BIN
      public/favicon/favicon-zgrs.ico
  18. BIN
      public/favicon/favicon.ico
  19. BIN
      public/fonts/SOURCEHANSERIFCN-BOLD.OTF
  20. BIN
      public/fonts/SOURCEHANSERIFCN-REGULAR.OTF
  21. BIN
      public/fonts/SourceHanSansCN-Bold.otf
  22. BIN
      public/fonts/SourceHanSansCN-Medium.otf
  23. BIN
      public/fonts/SourceHanSansCN-Regular.otf
  24. BIN
      public/images/4dage-logo.png
  25. BIN
      public/images/4dagePoint.png
  26. BIN
      public/images/4dagePoint2.png
  27. BIN
      public/images/End.png
  28. BIN
      public/images/VR.png
  29. BIN
      public/images/Volume btn_off.png
  30. BIN
      public/images/Volume btn_on.png
  31. BIN
      public/images/arrow.png
  32. BIN
      public/images/auto-suspend.png
  33. BIN
      public/images/auto.png
  34. BIN
      public/images/btm_logo.png
  35. BIN
      public/images/close1.png
  36. BIN
      public/images/dollhouse.png
  37. BIN
      public/images/enlarge_on.png
  38. BIN
      public/images/face.jpg
  39. BIN
      public/images/floor.png
  40. BIN
      public/images/hotListClose.png
  41. BIN
      public/images/hotlist.png
  42. BIN
      public/images/inside.png
  43. BIN
      public/images/marker.png
  44. BIN
      public/images/narrow_off.png
  45. BIN
      public/images/pause.png
  46. BIN
      public/images/pc_step1.png
  47. BIN
      public/images/pc_step2.png
  48. BIN
      public/images/pc_step3.png
  49. BIN
      public/images/phone_step1.png
  50. BIN
      public/images/phone_step2.png
  51. BIN
      public/images/phone_step3.png
  52. BIN
      public/images/phone_step_01.png
  53. BIN
      public/images/play.png
  54. BIN
      public/images/reticule.png
  55. BIN
      public/images/rules.png
  56. BIN
      public/images/texture.jpg
  57. BIN
      public/images/vrOffImg.png
  58. 4 0
      public/js/lib/jquery-2.1.1.min.js
  59. 19 0
      scripts/generate-offline-config.js
  60. 5 0
      scripts/offline-replace-config.json
  61. 24 0
      scripts/publish.js
  62. 7 0
      src/element.d.ts
  63. 17 0
      src/global.d.ts
  64. 21 0
      src/index/App.vue
  65. 29 0
      src/index/api/home.ts
  66. 1 0
      src/index/api/index.ts
  67. 143 0
      src/index/app.scss
  68. 7 0
      src/index/assets/el.scss
  69. BIN
      src/index/assets/images/audio-icon.png
  70. BIN
      src/index/assets/images/img-icon.png
  71. BIN
      src/index/assets/images/info-icon.png
  72. BIN
      src/index/assets/images/model-icon.png
  73. BIN
      src/index/assets/images/video-icon.png
  74. 7 0
      src/index/assets/utils.scss
  75. 21 0
      src/index/components/js-script.tsx
  76. 21 0
      src/index/configure.ts
  77. 12 0
      src/index/main.ts
  78. 20 0
      src/index/router/index.ts
  79. 9 0
      src/index/store/index.ts
  80. 23 0
      src/index/store/module/base.ts
  81. 19 0
      src/index/store/types.ts
  82. 36 0
      src/index/types/home.ts
  83. 11 0
      src/index/types/hotspot.ts
  84. 32 0
      src/index/types/index.ts
  85. 1849 0
      src/index/utils/hot.js
  86. 31 0
      src/index/utils/index.ts
  87. 126 0
      src/index/utils/services.ts
  88. 123 0
      src/index/views/home/components/gui-loading/index.scss
  89. 36 0
      src/index/views/home/components/gui-loading/index.tsx
  90. 207 0
      src/index/views/home/components/guide/index.scss
  91. 182 0
      src/index/views/home/components/guide/index.tsx
  92. 167 0
      src/index/views/home/components/hot-spot-list/index.scss
  93. 57 0
      src/index/views/home/components/hot-spot-list/index.tsx
  94. 113 0
      src/index/views/home/components/menu/index.scss
  95. 201 0
      src/index/views/home/components/menu/index.tsx
  96. 60 0
      src/index/views/home/components/other/index.scss
  97. 80 0
      src/index/views/home/components/other/index.tsx
  98. 173 0
      src/index/views/home/components/popup/index.scss
  99. 266 0
      src/index/views/home/components/popup/index.vue
  100. 0 0
      src/index/views/home/components/rules/index.vue

+ 4 - 0
.env.development

@@ -0,0 +1,4 @@
+VITE_APP_DOMAIN = '4dage.com'
+VITE_APP_PROTOCOL = 'https'
+VITE_APP_BACKEND_DOMAIN = '4dkk.4dage.com'
+VITE_APP_BACKEND_URL = 'https://4dkk.4dage.com'

+ 4 - 0
.env.production

@@ -0,0 +1,4 @@
+VITE_APP_DOMAIN = '4dage.com'
+VITE_APP_PROTOCOL = 'https'
+VITE_APP_BACKEND_DOMAIN = '4dkk.4dage.com'
+VITE_APP_BACKEND_URL = 'https://4dkk.4dage.com'

+ 25 - 0
.gitignore

@@ -0,0 +1,25 @@
+.DS_Store
+.temp
+node_modules
+/build
+/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?

+ 2 - 0
.npmrc

@@ -0,0 +1,2 @@
+shamefully-hoist=true
+registry=https://registry.npmmirror.com/

+ 8 - 0
.prettierignore

@@ -0,0 +1,8 @@
+build/
+public/
+**/*.png
+**/*.svg
+**/*.jpg
+.DS_Store
+.history
+package.json

+ 10 - 0
.prettierrc

@@ -0,0 +1,10 @@
+{
+  "$schema": "http://json.schemastore.org/prettierrc",
+  "singleQuote": true,
+  "semi": true,
+  "trailingComma": "es5",
+  "printWidth": 100,
+  "proseWrap": "never",
+  "vueIndentScriptAndStyle": true,
+  "endOfLine": "auto"
+}

+ 115 - 0
README.md

@@ -0,0 +1,115 @@
+### 初始化
+
+```bash
+yarn
+
+yarn serve
+```
+
+<br>
+
+### 目录结构
+
+```
+|-build
+|-public // 静态资源
+|-src
+  |- api // 接口文件
+  |- types // 类型声明
+  |- assets // 公用代码资源
+  |- components // 公用组件
+  |- views // 项目页面入口
+  |- utils // 公用工具函数
+  |- router // 路由配置
+  |- store // 全局状态管理
+  |
+  |- env.d.ts // 环境变量声明
+  |- global.d.ts // 全局变量声明
+  |- el.d.ts // element 声明
+```
+
+<br>
+
+### 多场景模式
+
+⚠️ `/public` 下为公用文件,不要在场景分支里修改
+
+通过 `process.env.SCENE` 区分场景
+
+尽量避免使用 `.vue` 自定义后缀,ide 暂不能友好支持模糊匹配 `.vue`,需要明确使用 `demo.vue`,导致无法区分场景
+
+<br>
+
+### 关于分支
+
+`release` 稳定版本,线上代码稳定一段时间后同步 `release-buffer`
+
+`release-buffer` 发布版本,场景线分支发布生产环境需要合回此分支,使用 `--no-ff` 记录合并操作
+
+<br>
+
+### 🚀 关于代码格式化
+
+推荐使用 ide 插件,在 ide 扩展中下载 `prettier`
+
+以 vscode 为例:
+
+> 在 `file -> preferences -> setting` 中搜索 `defaultFormatter`<br> 选择 `Prettier - Code formatter`<br> 接着搜索 `format`<br> 将 `editor: format on save` 勾选
+
+<br>
+
+### 🤖 关于自动化部署
+
+发布地址:http://face3d.4dage.com:29394/deploy/app
+
+新建的分支如果需要自动化部署,需要在 `package.json` 下增加指令
+
+    举例:新增一个demo大场景
+    1. 在 release-buffer 下新增 demo 分支
+    2. 在 scripts 中新增 push:demo 指令,注意 push: 后的参数需要与分支名相同
+    3. 注意:不要在 release* 下发布版本
+
+测试环境项目地址:https://scene.4dage.com/?m=1172
+
+```bash
+scene=${SPUG_GIT_BRANCH%%/*}
+
+echo "当前场景值:$scene"
+
+if [ $SPUG_DEPLOY_TYPE == "2" ]
+then
+  echo "开始回滚"
+  basename "$PWD"
+else
+  mv .temp/* .
+
+  fileCount=$(find /home/spug_backup/vue3-scene-web/* -maxdepth 0 -type d -printf '.' | wc -c)
+  echo "当前文件夹数量:$fileCount"
+
+  if [ ${fileCount} -gt 1 ]
+  then
+    lastFileDir=$(ls -d -F /home/spug_backup/vue3-scene-web/* -t | grep '/$' | head -n 2 | tail -n 1)
+
+    echo "上一个部署目录:$lastFileDir"
+
+    if [ -d "${lastFileDir}data/" ]
+    then
+      echo "copy data file"
+      cp -rTn ${lastFileDir}data/ ./data
+    fi
+
+    if [ -d "${lastFileDir}resources/web/" ]
+    then
+      echo "copy resources file"
+      if [ $scene = "test" ]
+      then
+        rsync -rtvu --exclude=./js --exclude=./img --exclude=./fonts --exclude=./css ${lastFileDir}resources/web/ ./resources/web/
+      else
+        rsync -rtvu --exclude=$scene ${lastFileDir}resources/web/ ./resources/web/
+      fi
+    fi
+  fi
+
+  rm -r .temp
+fi
+```

+ 11 - 0
auto-imports.d.ts

@@ -0,0 +1,11 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+// biome-ignore lint: disable
+export {}
+declare global {
+  const ElMessage: typeof import('element-plus/es')['ElMessage']
+  const ElNotification: typeof import('element-plus/es')['ElNotification']
+}

+ 18 - 0
components.d.ts

@@ -0,0 +1,18 @@
+/* eslint-disable */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+  export interface GlobalComponents {
+    ElDialog: typeof import('element-plus/es')['ElDialog']
+    ElImage: typeof import('element-plus/es')['ElImage']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+  }
+  export interface ComponentCustomProperties {
+    vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+  }
+}

+ 42 - 0
config.js

@@ -0,0 +1,42 @@
+import pkg from "./package.json";
+
+const ASSETS_DIR = "resources/web/";
+
+/**
+ * 主域名
+ */
+const DOMAIN = "4dage.com";
+
+/**
+ * 前端版本号
+ */
+const VERSION = pkg.version;
+
+/**
+ * HTTP 协议
+ */
+const PROTOCOL = "https";
+
+/**
+ * API 域名
+ */
+const BACKEND_DOMAIN = process.env.DOMAIN || `www.${DOMAIN}`;
+
+export default {
+  /**
+   * 静态资源放置的子目录
+   */
+  assetsDir: ASSETS_DIR,
+  /**
+   * 可访问的内置常量
+   */
+  constants: {
+    ASSETS_DIR,
+    VERSION,
+    PROTOCOL,
+    DOMAIN,
+    BACKEND_DOMAIN,
+    ASSETS_URL: `//${ASSETS_DIR}`,
+    BACKEND_URL: `${PROTOCOL}://${BACKEND_DOMAIN}`,
+  },
+};

+ 22 - 0
env.d.ts

@@ -0,0 +1,22 @@
+/// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+  /** 协议 */
+  readonly VITE_APP_PROTOCOL: string;
+
+  /** 热点域名 */
+  readonly VITE_APP_HOT_DOMAIN?: string;
+
+  /** 域名 */
+  readonly VITE_APP_DOMAIN?: string;
+
+  /** API 域名 */
+  readonly VITE_APP_BACKEND_DOMAIN: string;
+  readonly VITE_APP_BACKEND_URL: string;
+
+  /** 页面标题 */
+  readonly VITE_APP_TITLE?: string;
+
+  /** 场景值 */
+  readonly VITE_APP_SCENE?: string;
+}

+ 30 - 0
hotspot.html

@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="utf-8" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui"
+    />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
+    <link
+      rel="icon"
+      href="<%= BASE_URL %>favicon/favicon<%= !!process.env.VITE_APP_SCENE ? '-' + process.env.VITE_APP_SCENE : '' %>.ico"
+    />
+    <title><%= VITE_APP_TITLE %></title>
+    <meta name="description" content="四维时代" />
+    <meta property="og:title" content="四维时代" />
+    <meta property="og:description" content="四维时代" />
+    <meta property="og:image:type" content="image/jpg" />
+  </head>
+  <body>
+    <noscript>
+      <strong
+        >We're sorry but <%= VITE_APP_TITLE %> doesn't work properly without JavaScript enabled.
+        Please enable it to continue.</strong
+      >
+    </noscript>
+    <div id="app"></div>
+    <script type="module" src="src/hotspot/main.ts"></script>
+  </body>
+</html>

+ 33 - 0
index.html

@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="utf-8" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui"
+    />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
+    <link
+      rel="icon"
+      href="/favicon/favicon<%= !!process.env.VITE_APP_SCENE ? '-' + process.env.VITE_APP_SCENE : '' %>.ico"
+    />
+    <title><%= VITE_APP_TITLE %></title>
+    <meta name="description" content="四维时代" />
+    <meta property="og:title" content="四维时代" />
+    <meta property="og:description" content="四维时代" />
+    <meta property="og:image:type" content="image/jpg" />
+  </head>
+  <body>
+    <noscript>
+      <strong
+        >We're sorry but <%= VITE_APP_TITLE %> doesn't work properly without JavaScript enabled.
+        Please enable it to continue.</strong
+      >
+    </noscript>
+    <div id="app"></div>
+    <script src="/js/lib/jquery-2.1.1.min.js"></script>
+    <script src="//4dkk.4dage.com/v4/sdk/4.14.3/kankan-sdk-deps.js"></script>
+    <script src="//4dkk.4dage.com/v4/sdk/4.14.3/kankan-sdk.js"></script>
+    <script type="module" src="/src/index/main.ts"></script>
+  </body>
+</html>

+ 51 - 0
package.json

@@ -0,0 +1,51 @@
+{
+  "name": "vite-kakan-web",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "serve": "cross-env VITE_APP_TITLE=DEMO VITE_APP_HOT_DOMAIN=./hotspot.html vite",
+    "build:test": "cross-env VITE_APP_TITLE=DEMO VITE_APP_HOT_DOMAIN=./hotspot.html run-p type-check \"build-only {@}\" --",
+    "build:offline": "cross-env VITE_APP_OFFLINE=1 npm run build:test && cross-env node ./scripts/generate-offline-config.js && replace-in-file --configFile ./scripts/offline-replace-config.json",
+    "push:test": "cross-env node ./scripts/publish.js",
+    "preview": "vite preview",
+    "build-only": "vite build",
+    "type-check": "vue-tsc --build --force"
+  },
+  "dependencies": {
+    "@vueuse/core": "^13.9.0",
+    "axios": "^1.7.8",
+    "classname": "^0.0.0",
+    "clipboard": "^2.0.11",
+    "element-plus": "^2.8.8",
+    "lodash": "^4.17.21",
+    "pinia": "^2.2.6",
+    "qrcode": "^1.5.4",
+    "query-string": "^9.1.1",
+    "swiper": "^11.1.15",
+    "v-viewer": "^3.0.21",
+    "vue": "^3.5.12",
+    "vue-qrcode": "^2.2.2",
+    "vue-router": "^4.4.5",
+    "vue3-video-play": "^1.3.2"
+  },
+  "devDependencies": {
+    "@tsconfig/node22": "^22.0.0",
+    "@types/node": "^22.9.0",
+    "@vitejs/plugin-vue": "^5.1.4",
+    "@vitejs/plugin-vue-jsx": "^4.0.1",
+    "@vue/tsconfig": "^0.5.1",
+    "cross-env": "^7.0.3",
+    "less": "^4.2.1",
+    "npm-run-all2": "^7.0.1",
+    "replace-in-file": "^8.3.0",
+    "sass": "^1.81.0",
+    "typescript": "~5.6.3",
+    "unplugin-auto-import": "^0.18.5",
+    "unplugin-vue-components": "^0.27.4",
+    "vite": "^5.4.10",
+    "vite-plugin-html": "^3.2.2",
+    "vite-plugin-vue-devtools": "^7.5.4",
+    "vue-tsc": "^2.1.10"
+  }
+}

File diff suppressed because it is too large
+ 4040 - 0
pnpm-lock.yaml


BIN
public/favicon/favicon-syjy.ico


BIN
public/favicon/favicon-zgrs.ico


BIN
public/favicon/favicon.ico


BIN
public/fonts/SOURCEHANSERIFCN-BOLD.OTF


BIN
public/fonts/SOURCEHANSERIFCN-REGULAR.OTF


BIN
public/fonts/SourceHanSansCN-Bold.otf


BIN
public/fonts/SourceHanSansCN-Medium.otf


BIN
public/fonts/SourceHanSansCN-Regular.otf


BIN
public/images/4dage-logo.png


BIN
public/images/4dagePoint.png


BIN
public/images/4dagePoint2.png


BIN
public/images/End.png


BIN
public/images/VR.png


BIN
public/images/Volume btn_off.png


BIN
public/images/Volume btn_on.png


BIN
public/images/arrow.png


BIN
public/images/auto-suspend.png


BIN
public/images/auto.png


BIN
public/images/btm_logo.png


BIN
public/images/close1.png


BIN
public/images/dollhouse.png


BIN
public/images/enlarge_on.png


BIN
public/images/face.jpg


BIN
public/images/floor.png


BIN
public/images/hotListClose.png


BIN
public/images/hotlist.png


BIN
public/images/inside.png


BIN
public/images/marker.png


BIN
public/images/narrow_off.png


BIN
public/images/pause.png


BIN
public/images/pc_step1.png


BIN
public/images/pc_step2.png


BIN
public/images/pc_step3.png


BIN
public/images/phone_step1.png


BIN
public/images/phone_step2.png


BIN
public/images/phone_step3.png


BIN
public/images/phone_step_01.png


BIN
public/images/play.png


BIN
public/images/reticule.png


BIN
public/images/rules.png


BIN
public/images/texture.jpg


BIN
public/images/vrOffImg.png


File diff suppressed because it is too large
+ 4 - 0
public/js/lib/jquery-2.1.1.min.js


+ 19 - 0
scripts/generate-offline-config.js

@@ -0,0 +1,19 @@
+import { fileURLToPath } from 'node:url';
+import { dirname } from 'node:path';
+import fs from 'node:fs';
+
+const _filename = fileURLToPath(import.meta.url);
+const _dirname = dirname(_filename);
+
+const scene = process.env.VITE_APP_SCENE;
+const scenePath = scene ? '/' + scene : '';
+
+const configContent = `{
+  "files": ["build${scenePath}/js/*.js", "build${scenePath}/data/*.json"],
+  "from": "https://super.4dage.com",
+  "to": "."
+}`;
+
+fs.writeFileSync(`${_dirname}/offline-replace-config.json`, configContent, 'utf-8');
+
+console.log(`offline-replace-config.json 已生成,场景: ${scene}`);

+ 5 - 0
scripts/offline-replace-config.json

@@ -0,0 +1,5 @@
+{
+  "files": ["build/js/*.js", "build/data/*.json"],
+  "from": "https://super.4dage.com",
+  "to": "."
+}

+ 24 - 0
scripts/publish.js

@@ -0,0 +1,24 @@
+import fs from 'fs-extra';
+import ch from 'child_process';
+
+const SCENE = process.env.VITE_APP_SCENE;
+
+fs.ensureDirSync('.temp');
+fs.emptyDirSync('.temp');
+
+const distDir = `build${SCENE ? '/' + SCENE : ''}`;
+
+ch.execSync(`pnpm build${SCENE ? ':' + SCENE : ''}:test`, {
+  stdio: ['ignore', 'inherit', 'inherit'],
+});
+
+const distFiles = fs.readdirSync(distDir);
+
+if (!distFiles.length) throw new Error(`请先执行 pnpm ${distDir}`);
+
+distFiles.forEach((fileName) => {
+  fs.copySync(
+    `${distDir}/${fileName}`,
+    `.temp/${fileName === 'resources' ? fileName : 'data/' + fileName}`
+  );
+});

+ 7 - 0
src/element.d.ts

@@ -0,0 +1,7 @@
+declare global {
+  const ElMessage: typeof import('element-plus')['ElMessage'];
+  const ElLoading: typeof import('element-plus')['ElLoading'];
+  const ElNotification: typeof import('element-plus')['ElNotification'];
+}
+
+export {};

+ 17 - 0
src/global.d.ts

@@ -0,0 +1,17 @@
+interface Window {
+  KanKan: any;
+  kankan: any;
+  TagView: any;
+  /** 场景值 */
+  number: string;
+  /** gl资源请求域名,声明在 manage */
+  g_Prefix: string;
+  /** 上下文卸载结束时间戳 */
+  navigationStart: number;
+  /** 获取热点 iframe 路径 */
+  getHotIframePath: (str: string) => string;
+  $: JQuery;
+  browser: {
+    isMobile(): boolean;
+  };
+}

+ 21 - 0
src/index/App.vue

@@ -0,0 +1,21 @@
+<template>
+  <router-view v-loading="false" />
+</template>
+
+<script lang="ts" setup>
+  import { api as viewerApi } from 'v-viewer';
+  import 'viewerjs/dist/viewer.css';
+  import { MESSAGE_KEY } from './types';
+
+  window.addEventListener('message', (e) => {
+    switch (e.data.type) {
+      case MESSAGE_KEY.SHOW_VIEWER:
+        viewerApi({ images: e.data.images, options: { zIndex: 9999 } });
+        break;
+    }
+  });
+</script>
+
+<style lang="scss">
+  @use './app.scss';
+</style>

+ 29 - 0
src/index/api/home.ts

@@ -0,0 +1,29 @@
+import { type GetHotSpotListResponse, type GetSceneDetailResponse } from '@/types/home';
+import service from '@/utils/services';
+
+const SCENE_BASE_URL = 'https://count.4dage.com/api';
+
+export const homeApi = {
+  getHotSpotList() {
+    return service.get<GetHotSpotListResponse>(
+      `${window.g_Prefix}/data/${window.number}/hot/js/data.js`
+    );
+  },
+
+  getSceneDetail(sceneCode: string) {
+    return service.get<GetSceneDetailResponse | null>(
+      `${SCENE_BASE_URL}/count/detail/${sceneCode}`
+    );
+  },
+
+  saveSceneVisit(sceneCode: string) {
+    return service.get(`${SCENE_BASE_URL}/count/saveVisit/${sceneCode}`);
+  },
+
+  /**
+   * 点赞
+   */
+  saveStar(sceneCode: string) {
+    return service.get(`${SCENE_BASE_URL}/count/saveStar/${sceneCode}`);
+  },
+};

+ 1 - 0
src/index/api/index.ts

@@ -0,0 +1 @@
+export * from './home';

+ 143 - 0
src/index/app.scss

@@ -0,0 +1,143 @@
+:root {
+  --z-index-normal: 1;
+  --z-index-top: 1000;
+  --z-index-popper: 2000;
+  --z-hot-popper: 3000;
+  --design-width: 1920;
+  --design-height: 920;
+  --swiper-theme-color: white;
+  --swiper-pagination-bullet-inactive-color: white;
+}
+
+body,
+ol,
+ul,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+th,
+td,
+dl,
+dd,
+form,
+fieldset,
+legend,
+input,
+textarea,
+select {
+  margin: 0;
+  padding: 0;
+}
+* {
+  box-sizing: border-box;
+  user-select: none;
+}
+html,
+body,
+#app {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+body {
+  margin: 0px;
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.9);
+  font-family: 'Source Han Sans CN-Medium';
+  -webkit-tap-highlight-color: transparent;
+}
+a {
+  color: #fff;
+  cursor: pointer;
+  text-decoration: none;
+}
+em {
+  font-style: normal;
+}
+li {
+  list-style: none;
+}
+img {
+  border: 0;
+  vertical-align: middle;
+}
+table {
+  border-collapse: collapse;
+  border-spacing: 0;
+}
+p {
+  word-wrap: break-word;
+}
+iframe {
+  border: none;
+}
+
+@font-face {
+  font-family: 'Source Han Sans CN-Regular';
+  src: url('/fonts/SourceHanSansCN-Regular.otf');
+}
+@font-face {
+  font-family: 'Source Han Sans CN-Bold';
+  src: url('/fonts/SourceHanSansCN-Bold.otf');
+}
+@font-face {
+  font-family: 'Source Han Sans CN-Medium';
+  src: url('/fonts/SourceHanSansCN-Medium.otf');
+}
+@font-face {
+  font-family: 'SourceHanSerifCN-Bold';
+  src: url('/fonts/SOURCEHANSERIFCN-BOLD.OTF');
+}
+@font-face {
+  font-family: 'SourceHanSerifCN-Regular';
+  src: url('/fonts/SOURCEHANSERIFCN-REGULAR.OTF');
+}
+
+.limit-line {
+  display: -webkit-box;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+  word-break: break-all;
+  word-wrap: break-word;
+}
+
+.line-2 {
+  -webkit-line-clamp: 2;
+}
+
+.line-3 {
+  -webkit-line-clamp: 3;
+}
+
+.hidden {
+  display: none !important;
+  visibility: hidden !important;
+}
+
+.darkGlass {
+  background-color: rgba(0, 0, 0, 0.5);
+}
+
+.message-outer {
+  position: absolute;
+  display: table;
+  height: 100%;
+  width: 100%;
+
+  * {
+    transition: all 0.3s;
+  }
+}
+
+@media only screen and (max-width: 600px) {
+  :root {
+    --design-width: 750;
+    --design-height: 1440;
+  }
+}

+ 7 - 0
src/index/assets/el.scss

@@ -0,0 +1,7 @@
+@forward 'element-plus/theme-chalk/src/common/var.scss' with (
+  $colors: (
+    'primary': (
+      'base': #00b4ed,
+    ),
+  )
+);

BIN
src/index/assets/images/audio-icon.png


BIN
src/index/assets/images/img-icon.png


BIN
src/index/assets/images/info-icon.png


BIN
src/index/assets/images/model-icon.png


BIN
src/index/assets/images/video-icon.png


+ 7 - 0
src/index/assets/utils.scss

@@ -0,0 +1,7 @@
+@function vh-calc($num) {
+  @return calc(100vh * ($num / var(--design-height)));
+}
+
+@function vw-calc($num) {
+  @return calc(100vw * ($num / var(--design-width)));
+}

+ 21 - 0
src/index/components/js-script.tsx

@@ -0,0 +1,21 @@
+import { defineComponent, h } from 'vue';
+
+export default defineComponent({
+  name: 'JsScript',
+  props: {
+    src: {
+      type: String,
+      required: true,
+    },
+  },
+  emits: ['load'],
+  render() {
+    return h('script', {
+      type: 'text/javascript',
+      src: this.src,
+      onLoad: () => {
+        this.$emit('load');
+      },
+    });
+  },
+});

+ 21 - 0
src/index/configure.ts

@@ -0,0 +1,21 @@
+import queryString from 'query-string';
+
+const urlParams = queryString.parse(location.search.slice(1));
+
+window.number = urlParams.m as string;
+
+if (performance) {
+  window.navigationStart = performance.timing.navigationStart;
+} else {
+  window.navigationStart = Date.now() + 300;
+}
+
+window.getHotIframePath = (src: string) => {
+  console.log(import.meta.env);
+  return import.meta.env.VITE_APP_HOT_DOMAIN
+    ? src.replace(
+        'https://www.4dmodel.com/SuperTwo/hot_online1/index.html#/',
+        import.meta.env.VITE_APP_HOT_DOMAIN
+      )
+    : src;
+};

+ 12 - 0
src/index/main.ts

@@ -0,0 +1,12 @@
+import { createApp } from 'vue';
+import App from './App.vue';
+import router from './router';
+import { createPinia } from 'pinia';
+import './configure';
+
+export const app = createApp(App);
+
+app.use(router);
+app.use(createPinia());
+
+app.mount('#app');

+ 20 - 0
src/index/router/index.ts

@@ -0,0 +1,20 @@
+import {
+  createRouter,
+  createWebHashHistory,
+  type RouteRecordRaw,
+} from "vue-router";
+
+const routes: Array<RouteRecordRaw> = [
+  {
+    path: "/",
+    name: "home",
+    component: () => import("@/views/home"),
+  },
+];
+
+const router = createRouter({
+  history: createWebHashHistory(import.meta.env.BASE_URL),
+  routes,
+});
+
+export default router;

+ 9 - 0
src/index/store/index.ts

@@ -0,0 +1,9 @@
+import useBaseStore from './module/base';
+
+export default function useStore() {
+  return {
+    base: useBaseStore(),
+  };
+}
+
+export * from './types';

+ 23 - 0
src/index/store/module/base.ts

@@ -0,0 +1,23 @@
+import { defineStore } from 'pinia';
+import type { BaseStateType } from '../types';
+
+const useBaseStore = defineStore('base', {
+  state: (): BaseStateType => ({
+    kankanInited: false,
+    guidePlaying: false,
+    bgMusicDom: undefined,
+    bgMusicPlaying: false,
+
+    hotList: [],
+    hotListVisible: false,
+    hotVisible: false,
+    checkedHotData: null,
+  }),
+  actions: {
+    setState(key: keyof BaseStateType, val: any) {
+      this[key] = val as never;
+    },
+  },
+});
+
+export default useBaseStore;

+ 19 - 0
src/index/store/types.ts

@@ -0,0 +1,19 @@
+import type { HotDataType } from '@/types';
+
+export interface BaseStateType {
+  /** 初始化是否完成 */
+  kankanInited: boolean;
+  /** 热点列表 */
+  hotList: HotDataType[];
+  /** 热点详情数据 */
+  checkedHotData: HotDataType | null;
+  /** 显示热点详情 */
+  hotVisible: boolean;
+  /** 导览播放中 */
+  guidePlaying: boolean;
+  /** 显示热点列表 */
+  hotListVisible: boolean;
+
+  bgMusicPlaying: boolean;
+  bgMusicDom?: HTMLAudioElement;
+}

+ 36 - 0
src/index/types/home.ts

@@ -0,0 +1,36 @@
+export type GetHotSpotListResponse = Record<
+  string,
+  {
+    title: string;
+  }
+>;
+
+export interface GetSceneDetailResponse {
+  id: number;
+  starSum: number;
+  shareSum: number;
+  visitSum: number;
+}
+
+export type TourType = {
+  sid: string;
+  name: string;
+  list: {
+    sid: string;
+    enter: {
+      cover: string;
+    };
+  }[];
+};
+
+export enum CAMERA_TYPE_ENUM {
+  FLOORPLAN = 1,
+  DOLLHOUSE = 2,
+  PANORAMA = 3,
+}
+
+export const CameraTypeMap = {
+  floorplan: CAMERA_TYPE_ENUM.FLOORPLAN,
+  dollhouse: CAMERA_TYPE_ENUM.DOLLHOUSE,
+  panorama: CAMERA_TYPE_ENUM.PANORAMA,
+};

+ 11 - 0
src/index/types/hotspot.ts

@@ -0,0 +1,11 @@
+export enum HOTSPOT_TYPE {
+  IMG = 'img',
+  VIDEO = 'video',
+  TEXT = 'text',
+}
+
+export type HotspotTabType = {
+  type: HOTSPOT_TYPE;
+  name: string;
+  icon: any;
+};

+ 32 - 0
src/index/types/index.ts

@@ -0,0 +1,32 @@
+import type { Ref } from 'vue';
+
+export enum MESSAGE_KEY {
+  SHOW_VIEWER = 0,
+}
+
+export type HotMediaType = {
+  width: number;
+  height: number;
+  sid: string;
+  src: string;
+  thumb: string;
+  type: 'image' | 'video';
+};
+
+export type HotDataType = {
+  content: string;
+  icon: string;
+  sid: string;
+  title: string;
+  type: 'media';
+  media: HotMediaType[];
+  bgm?: {
+    name: string;
+    src: string;
+  };
+};
+
+export interface IRulesMethods {
+  showRuleBox: Ref<boolean, boolean>;
+  startMeasure: () => void;
+}

File diff suppressed because it is too large
+ 1849 - 0
src/index/utils/hot.js


+ 31 - 0
src/index/utils/index.ts

@@ -0,0 +1,31 @@
+export function parseUrlParams(url: string): Record<string, string> {
+  const params: Record<string, string> = {};
+
+  const queryString = url.split('?')[1];
+
+  if (!queryString) {
+    return params; // 没有查询参数,返回空对象
+  }
+
+  const pairs = queryString.split('&');
+
+  for (const pair of pairs) {
+    const [key, value] = pair.split('=');
+
+    if (key) {
+      params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
+    }
+  }
+
+  return params;
+}
+
+export function judgeIsMobile() {
+  const userAgentInfo = navigator.userAgent;
+  const mobileAgents = ['Android', 'iPhone', 'SymbianOS', 'Windows Phone', 'iPad', 'iPod'];
+  const mobileFlag = mobileAgents.some((mobileAgent) => {
+    return userAgentInfo.indexOf(mobileAgent) > 0;
+  });
+
+  return mobileFlag;
+}

+ 126 - 0
src/index/utils/services.ts

@@ -0,0 +1,126 @@
+import axios, {
+  type AxiosInstance,
+  type InternalAxiosRequestConfig,
+  type AxiosResponse,
+} from 'axios';
+import type { EpPropMergeType } from 'element-plus/es/utils';
+import { isNumber } from 'lodash';
+
+export enum ResponseStatusCode {
+  SUCCESS = 0,
+}
+
+interface Config<D = any> extends InternalAxiosRequestConfig<D> {
+  /**
+   * 隐藏错误提醒
+   */
+  hidden?: boolean;
+}
+
+export interface DageResponse<T = any, D = any> extends AxiosResponse<T, D> {
+  code: number;
+  msg: string;
+  success: boolean;
+}
+
+interface Service extends AxiosInstance {
+  get<T = any, R = DageResponse<T>, D = any>(url: string, config?: Config<D>): Promise<R>;
+  post<T = any, R = DageResponse<T>, D = any>(
+    url: string,
+    data?: D,
+    config?: Config<D>
+  ): Promise<R>;
+}
+
+const service: Service = axios.create({
+  baseURL: import.meta.env.VITE_APP_BACKEND_URL,
+  timeout: 60000,
+  headers: {
+    'Cache-Control': 'no-cache',
+    'Content-Type': 'application/json;charset=UTF-8',
+    'X-Requested-With': 'XMLHttpRequest',
+  },
+});
+
+const showMessage = (
+  message: string,
+  type: EpPropMergeType<
+    StringConstructor,
+    'error' | 'success' | 'warning' | 'info',
+    unknown
+  > = 'error'
+) => {
+  // @ts-ignore
+  ElMessage({
+    showClose: true,
+    type,
+    duration: 4000,
+    message,
+  });
+};
+
+/**
+ * 服务端接口empty字符串跟null返回的结果不同,过滤掉empty字符串
+ * @param params
+ * @param emptyString 是否过滤空字符串
+ */
+function filterEmptyKey(params: any, emptyString = false) {
+  if (Array.isArray(params) || params == null) {
+    return params;
+  }
+
+  Object.keys(params).forEach((key) => {
+    if (params[key] === null || (emptyString && params[key] === '')) {
+      delete params[key];
+    }
+  });
+}
+
+service.interceptors.request.use((config: Config) => {
+  if (config.method === 'post') {
+    const params = {
+      ...config.data,
+    };
+    filterEmptyKey(params); // 过滤空字符串
+    config.data = params;
+  } else if (config.method === 'get') {
+    config.params = {
+      _t: new Date().getTime() / 1000,
+      ...config.params,
+    };
+    filterEmptyKey(config.params, true);
+  }
+  return config;
+});
+
+service.interceptors.response.use(
+  // @ts-ignore
+  (res) => {
+    const { data, config }: { config: Config; data: DageResponse } = res;
+
+    // data 有可能直接返回数据
+    if (isNumber(data.code) && data.code !== ResponseStatusCode.SUCCESS) {
+      const msg = data.msg || '加载失败';
+      const code = data.code || -1000;
+
+      // 未手动配置 隐藏 消息提示时,公共提醒错误
+      if (!config.hidden) {
+        setTimeout(() => {
+          showMessage(msg);
+        }, 0);
+      }
+
+      return Promise.reject({
+        code,
+        msg,
+      });
+    }
+
+    return data || {};
+  },
+  (error) => {
+    showMessage(error.message);
+  }
+);
+
+export default service as Required<Service>;

+ 123 - 0
src/index/views/home/components/gui-loading/index.scss

@@ -0,0 +1,123 @@
+#gui-loading {
+  z-index: var(--z-index-top);
+}
+
+.message-inner {
+  display: table-cell;
+  height: 100%;
+  text-align: center;
+  vertical-align: middle;
+}
+
+#loaderCoBrand,
+.model-title {
+  position: absolute;
+  width: 100%;
+  text-align: center;
+  font-weight: 100;
+}
+
+.model-title {
+  top: 19%;
+  display: block;
+  padding: 0 4px;
+  font-size: 38px;
+  letter-spacing: 0.75px;
+  color: rgba(255, 255, 255, 1);
+  margin: 0;
+  hyphens: auto;
+  line-height: 45px;
+}
+
+.progressbar {
+  position: relative;
+  display: block;
+  margin: 40px auto;
+  padding: 0px 20px;
+  width: 24%;
+  height: 10px;
+  background: #fff;
+  border-radius: 5px;
+
+  .label {
+    font-family: 'Aldrich', sans-serif;
+    position: absolute;
+    margin-left: -20px;
+    display: block;
+    width: 40px;
+    height: 22px;
+    line-height: 20px;
+    left: 0px;
+    font-size: 16px;
+    text-align: center;
+    -webkit-border-radius: 6px;
+    border-radius: 6px;
+    border: 1px solid rgba(0, 0, 0, 0.2);
+    box-shadow: 0px 1px 4px 0px rgba(0, 0, 0, 0.3);
+    top: -30px;
+    color: #fff;
+  }
+  .bar {
+    position: absolute;
+    display: block;
+    width: 0px;
+    height: 10px;
+    top: 0px;
+    left: 0px;
+    background: var(--el-color-primary);
+    border-radius: 5px;
+    overflow: hidden;
+
+    span {
+      position: absolute;
+      display: block;
+      width: 100%;
+      height: 64px;
+      border-radius: 16px;
+      background: var(--el-color-primary);
+      opacity: 0.2;
+    }
+  }
+}
+
+.specialPower {
+  display: none;
+}
+
+@media only screen and (max-width: 1000px) {
+  .progressbar {
+    width: 60%;
+  }
+}
+
+@media only screen and (max-width: 700px) {
+  .progressbar {
+    width: 65%;
+  }
+}
+
+@media only screen and (max-width: 555px) {
+  .model-title {
+    font-size: 22px;
+    line-height: 26px;
+  }
+}
+
+@media only screen and (max-height: 600px) {
+  .model-title {
+    top: 10%;
+    bottom: auto;
+  }
+}
+
+@media only screen and (max-height: 370px) {
+  .model-title {
+    top: 10px;
+    bottom: auto;
+    font-size: 14px;
+    line-height: 20px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}

+ 36 - 0
src/index/views/home/components/gui-loading/index.tsx

@@ -0,0 +1,36 @@
+import { defineComponent } from 'vue';
+import './index.scss';
+
+export default defineComponent({
+  name: 'HomeGuiLoading',
+  render() {
+    return (
+      <div id="gui-loading" class="message-outer darkGlass" style="display: none;">
+        <h2 class="model-title"></h2>
+        <div class="message-inner">
+          <div class="loadingLogo">
+            <div class="img"></div>
+          </div>
+          <div class="progressbar" data-perc="100">
+            <div class="label">
+              <div class="perc">0%</div>
+            </div>
+            <div class="bar">
+              <span></span>
+            </div>
+          </div>
+          <div id="loaderCoBrand">
+            <div class="vert-align"></div>
+          </div>
+          <div class="bottom-logo">
+            <div class="img"></div>
+          </div>
+          <div class="specialPower">
+            <span class="powered-by">{`{[{ POWERED_BY }]}`}</span>
+            &#xA0;<div class="img"></div>
+          </div>
+        </div>
+      </div>
+    );
+  },
+});

+ 207 - 0
src/index/views/home/components/guide/index.scss

@@ -0,0 +1,207 @@
+#drawer-container {
+  position: absolute;
+  left: 0;
+  bottom: 0px;
+  width: 100%;
+  pointer-events: none;
+  transition-property: bottom, opacity;
+  transition-duration: 0.5s;
+  z-index: var(--z-index-normal);
+}
+
+#drawer {
+  position: absolute;
+  left: 0;
+  bottom: 0;
+  width: 100%;
+  height: 0;
+  color: #fff;
+  pointer-events: all;
+  transition-duration: 0.5s;
+  transition-property: height, bottom;
+
+  &.playing {
+    bottom: 20px;
+  }
+  &.open {
+    height: 130px;
+
+    &.noScroll {
+      height: 110px;
+    }
+    &.fadeOut {
+      pointer-events: none;
+    }
+  }
+}
+
+.thumb-container {
+  width: auto !important;
+
+  .thumbImg {
+    cursor: pointer;
+
+    img {
+      height: 90px;
+    }
+  }
+}
+
+.frame-container {
+  padding: 0 10px;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+.frame {
+  margin: 10px 0;
+  height: 90px;
+}
+
+.frame .thumbImg {
+  width: 136px;
+  position: relative;
+}
+
+.frame .thumbImg .overlay {
+  position: absolute;
+  left: 2px;
+  right: 2px;
+  bottom: 2px;
+  padding: 0 6px;
+  color: #fff;
+  background-color: rgba(0, 0, 0, 0.7);
+  text-align: center;
+  z-index: 100;
+}
+
+.frame .thumbImg .mark360View,
+.frame .thumbImg .markInsideView {
+  position: absolute;
+  top: 2px;
+  left: 2px;
+  width: 50px;
+  max-height: 25px;
+  color: #fff;
+  background-color: rgba(0, 0, 0, 1);
+  z-index: 100;
+  transform: translate3d(0, 0, 0);
+}
+
+.frame .thumbImg img {
+  width: inherit;
+  border: 2px solid transparent;
+  transition: all 0.5s;
+}
+
+.frame .thumbImg.thumbImg.hasHover > img:hover {
+  opacity: 1;
+  border-color: #fff;
+}
+
+.playing .frame .thumbImg.thumbImg.hasHover > img:hover {
+  opacity: 0.6;
+  border-color: transparent;
+}
+
+.frame .thumbImg.thumbImg.hasHover.recent > img:hover,
+.frame .thumbImg.thumbImg.recent > img {
+  opacity: 0.6;
+  border-color: #a0a0a0;
+}
+
+.frame .thumbImg.thumbImg.hasHover.upcoming > img:hover,
+.frame .thumbImg.thumbImg.upcoming > img {
+  opacity: 1;
+  border-color: #bfaf1e;
+}
+
+.frame .thumbImg.thumbImg.active > img,
+.frame .thumbImg.thumbImg.hasHover.active > img:hover {
+  opacity: 1;
+  border-color: var(--el-color-primary);
+}
+
+.scrollbar {
+  width: calc(100% - 10px);
+  margin: 0 5px;
+  height: 8px;
+  float: left;
+  border-radius: 5px;
+  background: rgba(0, 0, 0, 0.75);
+}
+
+.scrollbar .handle {
+  width: 100px;
+  height: 100%;
+  background: rgba(255, 255, 255, 0.75);
+  border-radius: 5px;
+}
+
+#progressBar,
+#status {
+  display: table-cell;
+  height: 0;
+  vertical-align: middle;
+}
+
+#playHead {
+  display: table;
+  position: absolute;
+  bottom: -20px;
+  left: 0;
+  height: 20px;
+  width: 100%;
+  transition-property: bottom;
+  transition-duration: 0.5s;
+  background-color: #000;
+
+  &.playing {
+    bottom: 0;
+  }
+}
+
+#status {
+  width: 65px;
+  color: #fff;
+  font-family: OpenSans, 'Helvetica Neue', Arial, sans-serif;
+  font-weight: 700;
+  font-size: 11px;
+  padding-left: 10px;
+}
+
+#progressBar {
+  padding: 0 10px;
+  pointer-events: all;
+
+  .step {
+    height: 6px;
+    float: left;
+
+    &::before {
+      content: '';
+      display: block;
+      width: 100%;
+      height: 100%;
+      background-color: #575757;
+    }
+    &.active::before {
+      background-color: var(--el-color-primary);
+    }
+  }
+}
+
+@media only screen and (max-width: 487px), (max-height: 487px) {
+  .thumb-container .thumbImg img,
+  .frame {
+    height: 70px;
+  }
+  .frame .thumbImg {
+    width: 103px;
+  }
+  #drawer.open.noScroll,
+  #drawer.open.noScroll.playing {
+    height: 90px;
+  }
+}

+ 182 - 0
src/index/views/home/components/guide/index.tsx

@@ -0,0 +1,182 @@
+import { defineComponent, ref, watch } from 'vue';
+import { Swiper, SwiperSlide } from 'swiper/vue';
+import { FreeMode } from 'swiper/modules';
+import { useRoute } from 'vue-router';
+import { storeToRefs } from 'pinia';
+import className from 'classname';
+import useBaseStore from '@/store/module/base';
+import type { TourType } from '@/types/home';
+import './index.scss';
+
+export default defineComponent({
+  name: 'HomeGuide',
+  props: {
+    open: {
+      type: Boolean,
+      required: true,
+    },
+    playing: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  setup() {
+    let disable = false;
+    let partIId = 0;
+    const route = useRoute();
+    const baseStore = useBaseStore();
+    const { kankanInited } = storeToRefs(baseStore);
+    /** 当前播放导览片段 */
+    const playingPartId = ref(0);
+    /** 导览片段进度 */
+    const progressPart = ref(0);
+    /** 导览进度 */
+    const progress = ref(0);
+    /** 导览列表 */
+    const tourList = ref<TourType[]>([]);
+    /** 手动点击导览 */
+    const checkedGuide = ref(false);
+
+    const handlePlayGuide = async (index: number) => {
+      if (disable) return;
+
+      disable = true;
+      playingPartId.value = index;
+      progress.value = 0;
+      progressPart.value = 0;
+
+      const player = await window.kankan.TourManager.player;
+      player.pause();
+      player.selectPart(index);
+
+      disable = false;
+      checkedGuide.value = true;
+    };
+
+    const resetGuide = () => {
+      baseStore.setState('guidePlaying', false);
+      playingPartId.value = 0;
+      progress.value = 0;
+      progressPart.value = 0;
+      checkedGuide.value = false;
+    };
+
+    watch(
+      kankanInited,
+      (v) => {
+        if (!v) return;
+
+        // 导览数据
+        window.kankan.TourManager.on('loaded', (tours) => {
+          tourList.value = tours;
+        });
+
+        // 监听看看的模式
+        window.kankan.Camera.on('mode.beforeChange', ({ toMode }) => {
+          if (baseStore.guidePlaying) resetGuide();
+        });
+
+        window.kankan.use('TourPlayer').then((player) => {
+          player.on('play', ({ partId, frameId }) => {
+            baseStore.setState('guidePlaying', true);
+          });
+          player.on('pause', ({ partId, frameId }) => {
+            baseStore.setState('guidePlaying', false);
+          });
+          player.on('end', async () => {
+            resetGuide();
+          });
+
+          let currPartId;
+          let currFrames;
+
+          player.on('progress', ({ partId, frameId, progress: _progress }) => {
+            // 画面进度
+            partIId = partId;
+            progress.value = Number(_progress * 100);
+            playingPartId.value = partIId;
+
+            // 片段进度
+            if (tourList.value.length == 1) {
+              progressPart.value = progress.value;
+            } else {
+              if (currPartId != partId) {
+                currPartId = partId;
+                currFrames = tourList.value[partId].list.length;
+                progressPart.value = 0;
+              }
+
+              const length = tourList.value[partId].list.length;
+
+              const baiFen = (frameId / length + Number(_progress.toFixed(5)) / length) * 100;
+              progressPart.value = baiFen;
+            }
+          });
+        });
+      },
+      {
+        immediate: true,
+      }
+    );
+
+    return {
+      route,
+      tourList,
+      checkedGuide,
+      progressPart,
+      playingPartId,
+      baseUrl: import.meta.env.VITE_APP_BACKEND_URL,
+      handlePlayGuide,
+    };
+  },
+  render() {
+    return (
+      <div id="drawer-container">
+        <div
+          id="drawer"
+          class={className('fullWidth', { open: this.open, noScroll: !this.playing })}
+        >
+          <div class="frame-container darkGlass">
+            <Swiper
+              class="frame"
+              spaceBetween={10}
+              slidesPerView={'auto'}
+              freeMode
+              modules={[FreeMode]}
+            >
+              {this.tourList.map((item, index) => (
+                <SwiperSlide key={item.sid} class="thumb-container">
+                  <div
+                    class={className('thumbImg', {
+                      active: this.checkedGuide && this.playingPartId === index,
+                    })}
+                    onClick={this.handlePlayGuide.bind(undefined, index)}
+                  >
+                    <img
+                      src={
+                        `${this.baseUrl}/scene_view_data/${this.route.query.m}/user/` +
+                        item.list[0].enter.cover
+                      }
+                      alt=""
+                    />
+                    <div class="overlay limit-line">{item.name}</div>
+                  </div>
+                </SwiperSlide>
+              ))}
+            </Swiper>
+          </div>
+        </div>
+
+        <div id="playHead" class={className({ playing: this.playing })}>
+          <div id="status">
+            <span class="curIdx">{this.playingPartId + 1}</span> of{' '}
+            <span class="totalSteps">{this.tourList.length}</span>
+          </div>
+          <div id="progressBar">
+            <div class="step" style={{ width: this.progressPart + '%' }} />
+          </div>
+        </div>
+      </div>
+    );
+  },
+});

+ 167 - 0
src/index/views/home/components/hot-spot-list/index.scss

@@ -0,0 +1,167 @@
+#hotListWrap {
+  display: flex;
+  flex-direction: column;
+  position: absolute;
+  top: 0;
+  right: -400px;
+  width: 356px;
+  max-width: 50%;
+  height: 100%;
+  transition: right 0.4s, width 0.5s;
+  background: rgba(34, 36, 37, 0.7);
+  z-index: var(--z-index-popper);
+}
+
+.hotListActive {
+  right: 0 !important;
+}
+
+#hotListTitle {
+  position: relative;
+  width: 100%;
+  height: 15%;
+  opacity: 0.8;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  text-align: center;
+}
+
+#hotListContent {
+  width: 100%;
+  flex-grow: 1;
+  height: 100%;
+  overflow-y: scroll;
+  overflow-x: hidden;
+}
+
+#hotListBottom {
+  position: relative;
+  height: 20%;
+}
+
+#hotListClose {
+  position: absolute;
+  width: 26px;
+  right: 64px;
+  bottom: 64px;
+  cursor: pointer;
+}
+
+#hotListContent ul {
+  padding: 40px 30px;
+  font-size: 18px;
+  letter-spacing: 3px;
+}
+
+#hotListContent ul li {
+  height: 68px;
+  line-height: 68px;
+  text-align: right;
+  color: #979cab;
+  padding: 0 25px;
+  transition: color 0.3s, background 0.6s;
+  border-radius: 10px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+#hotListContent ul li:hover {
+  color: white;
+}
+
+#hotListContent ul li.active {
+  color: black;
+  background: #f3f5f9;
+}
+
+#hotListText {
+  font-size: 22px;
+  letter-spacing: 4px;
+  color: white;
+}
+
+#hotListIcon {
+  width: 34px;
+  margin-left: 48px;
+}
+
+#hotListContent::-webkit-scrollbar {
+  width: 6px;
+}
+
+#hotListContent::-webkit-scrollbar-thumb {
+  border-radius: 10px;
+  background-color: #979cab;
+}
+
+#hotListContent::-webkit-scrollbar-track {
+  border-radius: 10px;
+}
+
+@media only screen and (max-width: 910px) {
+  #hotListWrap {
+    top: -320px;
+    right: 0;
+    width: 100%;
+    max-width: 100%;
+    height: 250px;
+    transition: top 0.4s, width 0.5s;
+    background: rgba(34, 36, 37, 0.9);
+  }
+  .hotListActive {
+    top: 0 !important;
+  }
+
+  #hotListTitle {
+    height: 40%;
+    position: relative;
+    background: none;
+    opacity: 1;
+  }
+  #hotListText {
+    font-size: 20px;
+    letter-spacing: 3px;
+    font-weight: 100;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+  }
+  #hotListIcon {
+    width: 24px;
+    margin-left: 26px;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(165%, -50%);
+  }
+  #hotListContent ul li {
+    text-align: center;
+    height: 40px;
+    line-height: 40px;
+  }
+  #hotListContent ul {
+    padding: 0 30px;
+    font-size: 16px;
+  }
+  #hotListBottom {
+    display: none;
+    height: 15%;
+    /* background: rgba(34, 36, 37,0.9); */
+    background: none;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+  #hotListClose {
+    display: none;
+    position: initial;
+    width: 18px;
+  }
+  #hotListContent ul li.active {
+    color: white;
+    background: none;
+  }
+}

+ 57 - 0
src/index/views/home/components/hot-spot-list/index.tsx

@@ -0,0 +1,57 @@
+import { defineComponent } from 'vue';
+import className from 'classname';
+import { storeToRefs } from 'pinia';
+import useBaseStore from '@/store/module/base';
+import './index.scss';
+import type { HotDataType } from '@/types';
+
+export default defineComponent({
+  name: 'HomeHotSpotList',
+  setup() {
+    const baseStore = useBaseStore();
+    const { hotListVisible, hotList } = storeToRefs(baseStore);
+
+    const handleClose = () => {
+      baseStore.setState('hotListVisible', false);
+    };
+
+    const openDetail = (data: HotDataType) => {
+      baseStore.setState('checkedHotData', data);
+      baseStore.setState('hotVisible', true);
+      baseStore.setState('hotListVisible', false);
+
+      // 聚焦当前点击的热点
+      window.TagView.focus(data.sid);
+    };
+
+    return {
+      hotList,
+      hotListVisible,
+      handleClose,
+      openDetail,
+    };
+  },
+  render() {
+    return (
+      <div id="hotListWrap" class={className({ hotListActive: this.hotListVisible })}>
+        <div id="hotListTitle">
+          <div>
+            <span id="hotListText">热点列表</span>
+          </div>
+        </div>
+        <div id="hotListContent">
+          <ul>
+            {this.hotList.map((i) => (
+              <li key={i.sid} onClick={this.openDetail.bind(undefined, i)}>
+                <span>{i.title}</span>
+              </li>
+            ))}
+          </ul>
+        </div>
+        <div id="hotListBottom">
+          <img id="hotListClose" src="./images/hotListClose.png" onClick={this.handleClose} />
+        </div>
+      </div>
+    );
+  },
+});

+ 113 - 0
src/index/views/home/components/menu/index.scss

@@ -0,0 +1,113 @@
+.pinBottom-container {
+  position: absolute;
+  bottom: 10px;
+  width: 100%;
+  transition: all 0.5s;
+  z-index: var(--z-index-top);
+
+  &.open {
+    bottom: 140px;
+
+    &.playing {
+      bottom: 160px;
+    }
+    &.noScroll {
+      bottom: 120px;
+
+      &.playing {
+        bottom: 140px;
+      }
+    }
+  }
+  &.playing:not(.open) {
+    bottom: 30px;
+  }
+}
+
+.pinBottom {
+  position: absolute;
+  bottom: 0;
+  line-height: 1;
+  transition: all 0.5s;
+
+  &.left {
+    left: 10px;
+    bottom: 0;
+    overflow: hidden;
+    border-radius: 10px;
+    background: rgba(0, 0, 0, 0.2);
+  }
+  &.right {
+    right: 0;
+    bottom: 0;
+    text-shadow: 0 0 1px rgba(0, 0, 0, 0.6);
+
+    .ui-icon {
+      margin-right: 10px;
+    }
+    > div {
+      width: 48px;
+      height: 48px;
+      border-radius: 10px;
+      background: rgba(0, 0, 0, 0.2);
+    }
+  }
+  > div {
+    float: left;
+    width: 94px;
+    height: 48px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    cursor: pointer;
+
+    &.active,
+    &:hover,
+    &.opened {
+      background: rgba(0, 0, 0, 0.25);
+    }
+    img {
+      width: 24px;
+      height: 24px;
+    }
+  }
+}
+
+@media only screen and (max-width: 600px) {
+  .pinBottom-container {
+    bottom: 5px;
+
+    &.open {
+      bottom: 105px;
+
+      &.noScroll {
+        bottom: 95px;
+
+        &.playing {
+          bottom: 115px;
+        }
+      }
+    }
+    &.playing:not(.open) {
+      bottom: 25px;
+    }
+  }
+  .pinBottom {
+    display: flex;
+
+    &.left {
+      flex-direction: column;
+      left: 5px;
+    }
+    &.right .ui-icon {
+      margin-right: 5px;
+    }
+    > div {
+      width: 64px;
+    }
+  }
+  #play,
+  #pause {
+    width: 64px;
+  }
+}

+ 201 - 0
src/index/views/home/components/menu/index.tsx

@@ -0,0 +1,201 @@
+import { defineComponent, ref, watch } from 'vue';
+import className from 'classname';
+import useBaseStore from '@/store/module/base';
+import { storeToRefs } from 'pinia';
+import { useRoute } from 'vue-router';
+import Guide from '../guide';
+import { useFullscreen } from '@vueuse/core';
+import { CAMERA_TYPE_ENUM } from '@/types/home';
+import Rules from '../rules/index.vue';
+import type { IRulesMethods } from '@/types';
+import './index.scss';
+
+export default defineComponent({
+  name: 'HomeMenu',
+  props: {
+    mode: {
+      type: Number,
+      default: CAMERA_TYPE_ENUM.PANORAMA,
+    },
+  },
+  setup() {
+    const baseStore = useBaseStore();
+    const route = useRoute();
+    const { isFullscreen, isSupported, toggle } = useFullscreen();
+    const { kankanInited, guidePlaying, hotList, bgMusicDom, bgMusicPlaying } =
+      storeToRefs(baseStore);
+    const guideVisible = ref(false);
+    const rulesRef = ref<IRulesMethods>();
+
+    const handlePlayGuide = async () => {
+      const player = await window.kankan.TourManager.player;
+      const playing = guidePlaying.value;
+
+      if (playing) {
+        player.pause();
+      } else {
+        player.play();
+      }
+
+      baseStore.setState('guidePlaying', !playing);
+      guideVisible.value = !playing;
+    };
+
+    const handleDollhouse = () => {
+      window.kankan.Camera.dollhouse();
+    };
+    const handleFloorplan = () => {
+      window.kankan.Camera.floorplan();
+    };
+    const handlePanorama = () => {
+      window.kankan.Camera.panorama();
+    };
+    const openHotList = () => {
+      baseStore.setState('hotListVisible', true);
+    };
+    const startMeasure = () => {
+      rulesRef.value?.startMeasure();
+    };
+
+    const handleBgMusic = () => {
+      if (bgMusicPlaying.value) {
+        bgMusicDom?.value?.pause();
+      } else {
+        bgMusicDom?.value?.play();
+      }
+
+      baseStore.setState('bgMusicPlaying', !bgMusicPlaying.value);
+    };
+
+    watch(kankanInited, async (v) => {
+      if (!v) return;
+
+      const detail = await window.kankan.store.get('metadata');
+
+      if (detail.musicFile) {
+        // 存在背景音乐
+        const dom = document.createElement('audio');
+        dom.src = `${import.meta.env.VITE_APP_BACKEND_URL}/scene_view_data/${
+          route.query.m
+        }/user/music-user.mp3`;
+        dom.loop = true;
+        document.body.appendChild(dom);
+        baseStore.setState('bgMusicDom', dom);
+      }
+    });
+
+    return {
+      hotList,
+      isSupported,
+      isFullscreen,
+      guideVisible,
+      guidePlaying,
+      bgMusicDom,
+      bgMusicPlaying,
+      rulesRef,
+      toggle,
+      openHotList,
+      startMeasure,
+      handlePlayGuide,
+      handleFloorplan,
+      handleDollhouse,
+      handlePanorama,
+      handleBgMusic,
+    };
+  },
+  render() {
+    return (
+      <>
+        {!this.rulesRef?.showRuleBox && (
+          <>
+            <div
+              class={className('pinBottom-container', {
+                open: this.guideVisible,
+                noScroll: !this.guidePlaying,
+              })}
+            >
+              <div class="pinBottom left">
+                <div id="play" class="ui-icon" onClick={this.handlePlayGuide}>
+                  <a>
+                    {!this.guidePlaying ? (
+                      <img src="images/play.png" width="24" height="24" />
+                    ) : (
+                      <img title="暂停" src="images/pause.png" width="24" height="24" />
+                    )}
+                  </a>
+                </div>
+                <div
+                  id="pullTab"
+                  class={className({ opened: this.guideVisible })}
+                  onClick={() => (this.guideVisible = !this.guideVisible)}
+                >
+                  <img
+                    class="icon icon-inside"
+                    src="images/auto.png"
+                    title="导览"
+                    data-default-url="images/auto.png"
+                    data-active-url="images/auto-suspend.png"
+                  />
+                </div>
+                {Boolean(this.hotList.length) && (
+                  <div id="hotList" onClick={this.openHotList}>
+                    <img class="icon icon-inside" src="images/hotlist.png" title="热点列表" />
+                  </div>
+                )}
+                <div
+                  id="gui-modes-inside"
+                  class={className({ active: this.mode === CAMERA_TYPE_ENUM.PANORAMA })}
+                  onClick={this.handlePanorama}
+                >
+                  <img class="icon icon-inside" src="images/inside.png" title="全景漫游" />
+                </div>
+                <div
+                  id="gui-modes-dollhouse"
+                  class={className({ active: this.mode === CAMERA_TYPE_ENUM.DOLLHOUSE })}
+                  onClick={this.handleDollhouse}
+                >
+                  <img class="icon icon-inside" src="images/dollhouse.png" title="迷你模型" />
+                </div>
+                <div
+                  id="gui-modes-floorplan"
+                  class={className({ active: this.mode === CAMERA_TYPE_ENUM.FLOORPLAN })}
+                  onClick={this.handleFloorplan}
+                >
+                  <img class="icon icon-inside" src="images/floor.png" title="俯视图" />
+                </div>
+              </div>
+              <div class="pinBottom right">
+                {Boolean(this.bgMusicDom) && (
+                  <div id="volume" class="ui-icon wide" onClick={this.handleBgMusic}>
+                    {this.bgMusicPlaying ? (
+                      <img src="images/Volume btn_on.png" width="24" height="24" />
+                    ) : (
+                      <img src="images/Volume btn_off.png" width="24" height="24" />
+                    )}
+                  </div>
+                )}
+
+                <div id="rules" class="ui-icon wide" onClick={this.startMeasure}>
+                  <img src="images/rules.png" width="24" height="24" />
+                </div>
+
+                {this.isSupported && (
+                  <div id="gui-fullscreen" class="ui-icon wide" onClick={this.toggle}>
+                    {this.isFullscreen ? (
+                      <img class="icon icon-fullscreen-exit" src="images/narrow_off.png" />
+                    ) : (
+                      <img class="icon icon-fullscreen" src="images/enlarge_on.png" />
+                    )}
+                  </div>
+                )}
+              </div>
+            </div>
+          </>
+        )}
+
+        <Guide open={this.guideVisible} playing={this.guidePlaying} />
+        <Rules ref="rulesRef" />
+      </>
+    );
+  },
+});

+ 60 - 0
src/index/views/home/components/other/index.scss

@@ -0,0 +1,60 @@
+#call-to-action #interaction-modal.desktop {
+  height: 350px;
+  width: 550px;
+  border-radius: 10px;
+}
+
+#interaction-modal.desktop hr,
+#interaction-modal.desktop img {
+  width: 100%;
+  height: 100%;
+}
+
+#call-to-action #interaction-modal.mobile {
+  height: auto;
+  width: 95%;
+  border-radius: 5px;
+}
+
+#interaction-modal.mobile img {
+  width: 100%;
+}
+
+#call-to-action #interaction-modal.fadeIn,
+#call-to-action #pause-icon.fadeIn {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+#call-to-action #interaction-modal {
+  position: fixed;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  opacity: 0;
+  -webkit-transition: all 0.5s;
+  transition: all 0.5s;
+  z-index: 201;
+  pointer-events: none;
+}
+
+.next-button {
+  right: 15px;
+  transform: translate(0, -50%) rotate(45deg);
+}
+
+.prev-button {
+  left: 15px;
+  transform: translate(0, -50%) rotate(-135deg);
+}
+
+.nav-help-page {
+  position: absolute;
+  top: 50%;
+  z-index: 10;
+  cursor: pointer;
+  width: 20px;
+  height: 20px;
+  border-top: 3px solid #ffffff;
+  border-right: 3px solid #ffffff;
+}

+ 80 - 0
src/index/views/home/components/other/index.tsx

@@ -0,0 +1,80 @@
+import { defineComponent } from 'vue';
+import './index.scss';
+
+export default defineComponent({
+  name: 'HomeOther',
+  render() {
+    return (
+      <div>
+        <div id="share-modal">
+          <div id="share-outer">
+            <div class="share-images">
+              <a id="facebook-share">
+                <div class="share-button">
+                  <span class="faceBookLink">
+                    <i class="icon icon-facebook"></i>
+                  </span>
+                </div>
+              </a>
+              <a id="twitter-share">
+                <div class="share-button">
+                  <span class="twitterLink">
+                    <i class="icon icon-twitter"></i>
+                  </span>
+                </div>
+              </a>
+              <a id="mail-share">
+                <div class="share-button">
+                  <span class="mailLink">
+                    <i class="icon icon-email"></i>
+                  </span>
+                </div>
+              </a>
+            </div>
+            <div id="share-url">
+              <span id="share-url-text"></span>
+            </div>
+            <div id="copy-success" class="hidden"></div>
+          </div>
+          <div id="share-close" class="close">
+            <i class="icon icon-close"></i>
+          </div>
+        </div>
+        <div id="terms-modal" class="fadeOut" style="display: none">
+          <div id="terms-text"></div>
+          <div class="close">
+            <a>
+              <i class="icon icon-close"></i>
+            </a>
+          </div>
+        </div>
+        <div id="quick-blackout" class="quick" style="display: none"></div>
+        <div id="quick-logo" class="quick-brand" style="display: none"></div>
+        <div id="hover-top" class="hover-row" style="display: none"></div>
+        <div id="hover-bottom" class="hover-row" style="display: none"></div>
+        <div id="call-to-action">
+          <div id="pause-overlay" style="display: none">
+            <div id="pause-icon">
+              <a>
+                <i class="icon icon-pause"></i>
+              </a>
+            </div>
+          </div>
+          <div id="interaction-modal">
+            <div id="interaction-modal-inner">
+              <div class="nav-icon">
+                <img src="./images/pc_step1.png" class="icon" title="导览" data-page="1" />
+
+                <div class="nav-help-button">
+                  <div class="next-button nav-help-page" data-id="plus"></div>
+                  <div class="prev-button nav-help-page"></div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div id="tag-billboards" style="display: none"></div>
+      </div>
+    );
+  },
+});

+ 173 - 0
src/index/views/home/components/popup/index.scss

@@ -0,0 +1,173 @@
+.hot-popup {
+  --el-dialog-width: 100%;
+  --el-dialog-bg-color: transparent;
+  --el-dialog-border-radius: 0;
+  --el-dialog-padding-primary: 0;
+  margin: 0;
+  height: 100%;
+
+  .el-dialog__body {
+    width: 100%;
+    height: 100%;
+  }
+  .el-dialog__headerbtn {
+    z-index: 999;
+  }
+}
+
+.hotspot-page {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: rgba(0, 0, 0, 0.8);
+
+  &-info {
+    color: white;
+    max-width: 1320px;
+    width: calc(100vw - 30vw);
+
+    h3 {
+      margin-bottom: 18px;
+      font-size: 16px;
+      font-weight: bold;
+      text-align: center;
+    }
+    p {
+      margin: 0 auto;
+      width: 80%;
+      font-size: 12px;
+    }
+  }
+
+  &-container {
+    width: 100%;
+    height: 80%;
+
+    .swiper {
+      width: 100%;
+      height: 100%;
+    }
+    iframe {
+      max-width: 1000px;
+      width: calc(100% - 120px);
+      height: 570px;
+      border-radius: 14px;
+    }
+    video,
+    .el-image {
+      max-height: 570px;
+      border-radius: 14px;
+      object-fit: contain;
+    }
+  }
+
+  &-scrollbar {
+    max-height: 19%;
+    overflow: auto;
+    width: 70%;
+    color: #fff;
+    margin: 0 auto;
+
+    &.isTop {
+      max-height: 65%;
+      height: 65%;
+      padding: 50px 0;
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+    }
+    h3 {
+      font-size: 20px;
+      font-weight: 600;
+    }
+    p {
+      line-height: 1.5;
+      margin-top: 10px;
+      font-size: 16px;
+      text-indent: 32px;
+    }
+  }
+
+  .swiper-slide {
+    text-align: center;
+    font-size: 18px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    transition: 0.3s;
+    transform: scale(0.8);
+    position: relative;
+    opacity: 0.5;
+  }
+  .swiper-slide-active,
+  .swiper-slide-duplicate-active {
+    transform: scale(1);
+    opacity: 1;
+    z-index: 999;
+  }
+
+  &-nav {
+    position: absolute;
+    right: 30px;
+    bottom: calc(20% - 20px);
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 10px;
+    z-index: 1;
+
+    &__item {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      color: #fff;
+      white-space: nowrap;
+      font-size: 14px;
+      width: 90px;
+      height: 32px;
+      line-height: 32px;
+      cursor: pointer;
+      border-radius: 10px;
+      border: 1px solid #fff;
+
+      &.active {
+        background: #19bbed;
+        border: none;
+      }
+    }
+  }
+}
+
+@media only screen and (max-width: 600px) {
+  .hotspot-page {
+    &-container {
+      height: calc(100% - 90px);
+
+      video,
+      .el-image {
+        max-height: 80%;
+        width: 90%;
+      }
+      iframe {
+        width: 100%;
+        height: calc(100% - 90px);
+      }
+    }
+    &-nav {
+      flex-wrap: wrap;
+      right: 10px;
+      bottom: 10px;
+
+      &__item {
+        width: 70px;
+      }
+    }
+    &-scrollbar {
+      padding: 10px;
+      width: 100%;
+      max-height: calc(100% - 90px);
+    }
+  }
+}

+ 266 - 0
src/index/views/home/components/popup/index.vue

@@ -0,0 +1,266 @@
+<template>
+  <el-dialog class="hot-popup" v-model="show" append-to-body destroy-on-close>
+    <div class="hotspot-page">
+      <div v-if="!isTextType" class="hotspot-page-container">
+        <!-- 音频播放器 -->
+        <audio
+          id="myAudio"
+          v-if="audio"
+          ref="volumeRef"
+          v-show="isOneAduio"
+          :src="audio"
+          controls
+        ></audio>
+
+        <!-- 模型页面 -->
+        <Swiper
+          :modules="[Navigation, Pagination]"
+          class="hotspot-page-swiper"
+          :slides-per-view="isMobile ? 1 : 3"
+          :centered-slides="true"
+          :navigation="Boolean(curList.length) && !isMobile"
+          :pagination="
+            isMobile
+              ? false
+              : {
+                  clickable: true,
+                }
+          "
+          @swiper="initSwiper"
+          @slideChange="handleChange"
+        >
+          <SwiperSlide v-for="(item, index) in curList" :key="item.url">
+            <template v-if="swiperInited">
+              <template v-if="currentType === 'video'">
+                <video ref="videoRef" controls :src="item.src" />
+              </template>
+              <template v-else-if="currentType === 'img'">
+                <el-image :src="item.src" fit="contain" @click="handlePreview(index)" />
+              </template>
+            </template>
+          </SwiperSlide>
+        </Swiper>
+      </div>
+
+      <!-- 底部的tab -->
+      <ul v-if="flooTab.length > 1 || (audio && !isOneAduio)" class="hotspot-page-nav">
+        <!-- 音频图标 -->
+        <li
+          v-if="audio && !isOneAduio"
+          :class="[
+            'hotspot-page-nav__item',
+            {
+              active: audioSta,
+            },
+          ]"
+          @click="audioSta = !audioSta"
+        >
+          <img :src="VolumeOn" :alt="audioSta ? '关闭音频' : '打开音频'" />
+          <span>音频</span>
+        </li>
+        <li
+          v-for="item in flooTab"
+          :key="item.type"
+          :class="[
+            'hotspot-page-nav__item',
+            {
+              active: currentType === item.type,
+            },
+          ]"
+          @click="handleTab(item)"
+        >
+          <img :class="`${item.type}-icon`" :src="item.icon" />
+          <span>{{ item.name }}</span>
+        </li>
+      </ul>
+
+      <div
+        v-if="isMobile ? isTextType : true"
+        class="hotspot-page-scrollbar"
+        :class="{ isTop: !flooTab.length && !isMobile }"
+      >
+        <h3>{{ hotData?.title }}</h3>
+        <div v-html="hotData?.content" />
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import type { HotDataType } from '@/types';
+  import { computed, nextTick, ref, watch } from 'vue';
+  import { Swiper, SwiperSlide } from 'swiper/vue';
+  import { Navigation, Pagination } from 'swiper/modules';
+  import 'swiper/css';
+  import 'swiper/css/navigation';
+  import 'swiper/css/pagination';
+
+  import { judgeIsMobile } from '@/utils';
+
+  import ImageIcon from '@/assets/images/img-icon.png';
+  import VideoIcon from '@/assets/images/video-icon.png';
+  import VolumeOn from '@/assets/images/audio-icon.png';
+  import infoIcon from '@/assets/images/info-icon.png';
+
+  import { api as viewerApi } from 'v-viewer';
+  import 'viewerjs/dist/viewer.css';
+  import type { SwiperClass } from 'swiper/react';
+  import { useRoute } from 'vue-router';
+  import { HOTSPOT_TYPE, type HotspotTabType } from '@/types/hotspot';
+
+  const props = defineProps<{
+    visible: boolean;
+    hotData: HotDataType | null;
+  }>();
+  const emits = defineEmits(['update:visible']);
+
+  const route = useRoute();
+  const isMobile = judgeIsMobile();
+  const videoRef = ref<HTMLVideoElement | null>(null);
+  const volumeRef = ref<HTMLAudioElement | null>(null);
+  // 音频地址
+  const audio = ref('');
+  // 如果只有单独的音频
+  const isOneAduio = ref(false);
+  // 音频状态
+  const audioSta = ref(false);
+  const swiperInited = ref(false);
+  // 当前 type
+  const currentType = ref<HOTSPOT_TYPE>(HOTSPOT_TYPE.TEXT);
+  const isTextType = computed(() => currentType.value === HOTSPOT_TYPE.TEXT);
+  const data = ref<any>({
+    // 视频数组
+    video: [],
+    // 图片数组
+    img: [],
+  });
+  const myInd = ref(0);
+  const flooTab = ref<HotspotTabType[]>([]);
+  const curList = computed(() => data.value[currentType.value] || []);
+  const show = computed({
+    get() {
+      return props.visible;
+    },
+    set(v) {
+      emits('update:visible', v);
+    },
+  });
+  let swiper: null | SwiperClass = null;
+
+  const initSwiper = (_swiper) => {
+    swiper = _swiper;
+    swiperInited.value = true;
+  };
+
+  const handleChange = ({ activeIndex }) => {
+    myInd.value = activeIndex;
+
+    switch (currentType.value) {
+      case 'video':
+        handleVideoPlay(data.value.video[activeIndex].src);
+        break;
+    }
+  };
+
+  let lastVideo: null | HTMLVideoElement = null;
+  const handleVideoPlay = (url: string) => {
+    if (!Array.isArray(videoRef.value)) return;
+
+    const video = videoRef.value?.find((i) => i.src === url);
+    lastVideo?.pause();
+    if (!video) return;
+    video.play();
+    lastVideo = video;
+  };
+
+  const handleTab = (item: any) => {
+    myInd.value = 0;
+    currentType.value = item.type;
+    swiper?.slideTo(0);
+
+    switch (currentType.value) {
+      case 'video':
+        nextTick(() => {
+          handleVideoPlay(data.value.video[0].src);
+        });
+        break;
+    }
+  };
+
+  const handlePreview = (idx: number) => {
+    viewerApi({ images: curList.value, options: { initialViewIndex: idx, zIndex: 9999 } });
+  };
+
+  watch(audioSta, (val) => {
+    if (!volumeRef.value) return;
+
+    if (val) {
+      volumeRef.value.play();
+      volumeRef.value.onended = () => {
+        // console.log("----音频播放完毕");
+        audioSta.value = false;
+      };
+    } else volumeRef.value.pause();
+  });
+
+  watch(
+    () => props.hotData,
+    (v) => {
+      if (!v) return;
+
+      if (v.bgm) {
+        audio.value = `${import.meta.env.VITE_APP_BACKEND_URL}/scene_view_data/${
+          route.query.m
+        }/user/${v.bgm.src}`;
+
+        // 只有单独的音频上传
+        if (!v.media.length) isOneAduio.value = true;
+      }
+
+      // 底部的tab
+      const arr: HotspotTabType[] = [];
+      const imgs = v?.media.filter((i) => i.type === 'image') ?? [];
+      const videos = v?.media.filter((i) => i.type === 'video') ?? [];
+
+      if (imgs.length) {
+        arr.push({ type: HOTSPOT_TYPE.IMG, name: '图片', icon: ImageIcon });
+      }
+      if (videos.length) {
+        arr.push({ type: HOTSPOT_TYPE.VIDEO, name: '视频', icon: VideoIcon });
+      } else {
+        nextTick(() => {
+          if (
+            !window.navigator.userAgent.match(
+              /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
+            )
+          ) {
+            audioSta.value = true;
+            volumeRef.value?.play();
+          }
+        });
+      }
+      if (isMobile) {
+        arr.push({ type: HOTSPOT_TYPE.TEXT, name: '介绍', icon: infoIcon });
+      }
+
+      const baseUrl = `${import.meta.env.VITE_APP_BACKEND_URL}/scene_view_data`;
+      data.value = {
+        video: videos.map((i) => ({ ...i, src: `${baseUrl}/${route.query.m}/user/${i.src}` })),
+        img: imgs.map((i) => ({
+          ...i,
+          src: `${baseUrl}/${route.query.m}/user/hotspot/${i.sid}/${i.src}`,
+        })),
+      };
+      flooTab.value = arr;
+      if (imgs.length) currentType.value = HOTSPOT_TYPE.IMG;
+      else if (videos.length) currentType.value = HOTSPOT_TYPE.VIDEO;
+    },
+    {
+      immediate: true,
+    }
+  );
+</script>
+
+<style lang="scss">
+  @use './index.scss';
+</style>

+ 0 - 0
src/index/views/home/components/rules/index.vue


Some files were not shown because too many files changed in this diff