Procházet zdrojové kódy

feat(架构调整): playground

rindy před 2 roky
rodič
revize
8d29d29534

+ 2 - 3
playground/demo.html

@@ -13,9 +13,8 @@
     />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>kankan component</title>
-    <!-- <script src="//4dkk.4dage.com/v4-test/www/sdk/kankan-sdk-deps.js?v=4.6.0-alpha.10"></script>
-        <script src="//4dkk.4dage.com/v4-test/www/sdk/kankan-sdk.js?v=4.6.0-alpha.10"></script> -->
-
+    <!-- <script src="//4dkk.4dage.com/v4/www/sdk/kankan-sdk-deps.js?v=4.8.4"></script>
+    <script src="//4dkk.4dage.com/v4/www/sdk/kankan-sdk.js?v=4.8.4"></script> -->
     <script src="http://localhost:3099/dist/sdk/kankan-sdk-deps.js"></script>
     <script src="http://localhost:3099/dist/sdk/kankan-sdk.js"></script>
   </head>

+ 207 - 0
playground/src/demos/cad/cad_defined.ts

@@ -0,0 +1,207 @@
+import { reactive } from 'vue'
+import { __sdk } from '../../sdk'
+
+interface ToolbarItem {
+  text: string
+  type: string
+  icon: string
+  data?: Array<any>
+  checked?: boolean
+  highLight?: boolean
+}
+
+interface Toolbar {
+  list: Array<ToolbarItem>
+}
+
+interface WidgetItem {
+  icon: string
+  name: string
+  text: string
+  type?: string
+}
+
+interface Widget {
+  type: string
+  name: string
+  hide?: boolean
+  list: Array<WidgetItem>
+}
+interface CadInfo {
+  cad: any
+  prop: any
+  toolbar: any
+  execute: Function
+  selected: Function
+  showProps: Function
+  hideProps: Function
+  widgets: Array<Widget>
+  furnitures: Array<Widget>
+  toolbars: Array<Toolbar>
+}
+export default reactive<CadInfo>({
+  get cad() {
+    return __sdk.Plugins.EditCAD
+  },
+  execute: function (name: string, value?: string) {
+    this.cad.uiControl.execute(name, value)
+  },
+  selected: function (name: string) {
+    this.cad.uiControl.selectUI = name
+  },
+  showProps: (name: string) => {},
+  hideProps: () => {},
+  prop: null,
+  toolbar: {},
+  toolbars: [
+    {
+      list: [
+        { icon: 'repeal', text: '撤销', type: 'recall', highLight: false },
+        { icon: 'recover', text: '恢复', type: 'recover', highLight: false },
+      ],
+    },
+    {
+      list: [
+        { icon: 'clear', text: '清空', type: 'clear', highLight: false },
+        { icon: 'rotate', text: '旋转', type: 'rotate', highLight: true },
+        { icon: 'reset', text: '恢复默认', type: 'default', highLight: true },
+      ],
+    },
+    {
+      list: [
+        { icon: 'adapt', text: '适应视图', type: 'flex', highLight: true },
+        {
+          icon: 'eye-s',
+          checked: false,
+          text: '显示设置',
+          type: 'viewSetting',
+          highLight: true,
+          data: [
+            {
+              icon: 'nor',
+              checked: false,
+              text: '漫游点',
+              type: 'panos',
+              highLight: true,
+            },
+            {
+              icon: 'nor',
+              checked: true,
+              text: '底图',
+              type: 'texture',
+              highLight: true,
+            },
+          ],
+        },
+      ],
+    },
+    {
+      list: [
+        { icon: 'download', text: '下载', type: 'download', highLight: true },
+        { icon: 'none', text: '测量单位:', type: 'settings', highLight: true },
+      ],
+    },
+  ],
+  widgets: [
+    {
+      type: 'wall',
+      name: '墙',
+      list: [
+        { icon: 'cad-neiqiang', name: 'Wall', text: '内墙' },
+        { icon: 'cad-waiqiang', name: 'OutWall', text: '外墙' },
+      ],
+    },
+    {
+      type: 'door',
+      name: '门',
+      list: [
+        { icon: 'cad-men', name: 'SingleDoor', text: '单开门' },
+        { icon: 'cad-shuangkaimen', name: 'DoubleDoor', text: '双开门' },
+        { icon: 'cad-yimen', name: 'SlideDoor', text: '移门' },
+        { icon: 'cad-yakou', name: 'Pass', text: '垭口' },
+      ],
+    },
+    {
+      type: 'window',
+      name: '窗',
+      list: [
+        { icon: 'cad-chuang', name: 'SingleWindow', text: '一字窗' },
+        { icon: 'cad-piaochuang', name: 'BayWindow', text: '一字飘窗' },
+        { icon: 'cad-luodichuang', name: 'FrenchWindow', text: '落地窗' },
+      ],
+    },
+    {
+      type: 'structure',
+      name: '构建',
+      list: [
+        { icon: 'cad-zhuzi', name: 'Beam', text: '柱子' },
+        { icon: 'cad-yandao', name: 'Flue', text: '烟道' },
+        { icon: 'cad-loudao', name: 'Corridor', text: '楼道' },
+      ],
+    },
+    {
+      type: 'namespace',
+      name: '标注',
+      list: [{ icon: 'cad-dange', name: 'Tag', text: '标注' }],
+    },
+    {
+      type: 'Compass',
+      name: '指南针',
+      hide: true,
+      list: [{ icon: 'compass', name: 'compass', text: '指南针' }],
+    },
+    {
+      type: 'WallCorner',
+      name: '点',
+      hide: true,
+      list: [{ icon: 'WallCorner', name: 'WallCorner', text: '点' }],
+    },
+  ],
+  furnitures: [
+    {
+      type: 'saloon',
+      name: '客餐厅',
+      list: [
+        { icon: 'TV', name: 'TV', text: '电视' },
+        { icon: 'CombinationSofa', name: 'CombinationSofa', text: '组合沙发' },
+        { icon: 'SingleSofa', name: 'SingleSofa', text: '单人沙发' },
+        { icon: 'TeaTable', name: 'TeaTable', text: '茶几' },
+        { icon: 'Carpet', name: 'Carpet', text: '地毯' },
+        { icon: 'Plant', name: 'Plant', text: '植物' },
+        { icon: 'DiningTable', name: 'DiningTable', text: '餐桌' },
+      ],
+    },
+    {
+      type: 'bedRoom',
+      name: '卧室',
+      list: [
+        { icon: 'DoubleBed', name: 'DoubleBed', text: '双人床' },
+        { icon: 'SingleBed', name: 'SingleBed', text: '单人床' },
+        { icon: 'Wardrobe', name: 'Wardrobe', text: '衣柜' },
+        { icon: 'Dresser', name: 'Dresser', text: '梳妆台' },
+        { icon: 'BedsideCupboard', name: 'BedsideCupboard', text: '床头柜' },
+        { icon: 'Pillow', name: 'Pillow', text: '抱枕' },
+      ],
+    },
+    {
+      type: 'kitchenToilet',
+      name: '厨卫',
+      list: [
+        { icon: 'GasStove', name: 'GasStove', text: '燃气灶' },
+        { icon: 'Cupboard', name: 'Cupboard', text: '橱柜' },
+        { icon: 'Bathtub', name: 'Bathtub', text: '浴缸' },
+        { icon: 'Closestool', name: 'Closestool', text: '马桶' },
+        { icon: 'Washstand', name: 'Washstand', text: '洗漱台' },
+      ],
+    },
+    {
+      type: 'other',
+      name: '其他',
+      list: [
+        { icon: 'Desk', name: 'Desk', text: '书桌' },
+        { icon: 'BalconyChair', name: 'BalconyChair', text: '阳台椅' },
+        { icon: 'Elevator', name: 'Elevator', text: '电梯' },
+      ],
+    },
+  ],
+})

+ 247 - 0
playground/src/demos/cad/index.vue

@@ -0,0 +1,247 @@
+<script setup lang="ts">
+import { onMounted, inject, ref } from 'vue'
+import $xui from './cad_defined'
+const __sdk: any = inject('__sdk')
+
+const classify = ref('elem')
+const selected = ref('')
+
+const onClassify = (name: string) => {
+  classify.value = name
+}
+const onSelected = (name: string) => {
+  $xui.selected((selected.value = name))
+}
+
+const onMenuClick = (name: string, value?: string) => {
+  // if (name == 'panos' || name == 'texture' || name == 'download') {
+  //       cad.uiControl.execute(name, values)
+  //       if (name == 'download') {
+  //           Dialog.toast({ type: 'success', content: `${t('common.downloadDone')}` })
+  //       }
+  //   } else {
+  //       if (name == 'default') {
+  //           await store.dispatch('cad/reset')
+  //       }
+  //       cad.uiControl.execute(name)
+  //   }
+  $xui.execute(name, value)
+}
+
+onMounted(() => {
+  __sdk.use('EditCAD').then(() => {
+    __sdk.Plugins.EditCAD.$xui = $xui
+    __sdk.Plugins.EditCAD.padding({
+      top: 100,
+      left: 50,
+      right: 290,
+    })
+    __sdk.Plugins.EditCAD.show()
+  })
+  __sdk.Scene.on('loaded', () => {})
+  __sdk.mount('#scene').render()
+})
+</script>
+
+<template>
+  <div id="menus">
+    <ul>
+      <li v-for="cate in $xui.toolbars">
+        <div v-for="item in cate.list" @click="onMenuClick(item.type)">
+          {{ item.text }}
+        </div>
+      </li>
+    </ul>
+  </div>
+  <div id="tools">
+    <div class="tabs">
+      <div @click="onClassify('elem')" :class="{ active: classify == 'elem' }">
+        绘制结构
+      </div>
+      <div class="split"></div>
+      <div @click="onClassify('furn')" :class="{ active: classify == 'furn' }">
+        添加家具
+      </div>
+    </div>
+    <div class="elem" v-show="classify == 'elem'">
+      <ul>
+        <template v-for="cate in $xui.widgets">
+          <li v-if="!cate.hide">
+            <div>{{ cate.name }}</div>
+            <div class="widgets">
+              <div
+                v-for="item in cate.list"
+                :class="{ selected: selected === item.name }"
+                @click="onSelected(item.name)"
+              >
+                {{ item.text }}
+              </div>
+            </div>
+          </li>
+        </template>
+      </ul>
+    </div>
+    <div class="furn" v-show="classify == 'furn'">
+      <ul>
+        <li v-for="cate in $xui.furnitures">
+          <div>{{ cate.name }}</div>
+          <div class="widgets">
+            <div
+              v-for="item in cate.list"
+              :class="{ selected: selected === item.name }"
+              @click="onSelected(item.name)"
+            >
+              {{ item.text }}
+            </div>
+          </div>
+        </li>
+      </ul>
+    </div>
+    <div class="prop"></div>
+  </div>
+  <div id="scene"></div>
+</template>
+
+<style scoped lang="scss">
+ul {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+#menus {
+  user-select: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: absolute;
+  left: 0;
+  top: 0;
+  right: 245px;
+  z-index: 1000;
+  background-color: rgba(27, 27, 28, 0.8);
+  font-size: 14px;
+  ul {
+    display: flex;
+  }
+  li {
+    position: relative;
+    height: 60px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 0 10px;
+    &::after {
+      content: '';
+      position: absolute;
+      right: 0;
+      top: 50%;
+      width: 1px;
+      height: 20px;
+      z-index: 1000;
+      background-color: hsla(0, 0%, 100%, 0.16);
+      transform: translateY(-50%);
+    }
+    &:last-child {
+      &::after {
+        display: none;
+      }
+    }
+    > div {
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      padding: 0 10px;
+    }
+  }
+}
+#tools {
+  user-select: none;
+  padding: 10px;
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  width: 225px;
+  z-index: 1000;
+  font-size: 14px;
+  background-color: rgba(27, 27, 28, 0.8);
+  li {
+    margin-top: 16px;
+  }
+}
+#scene {
+  width: 100vw;
+  height: 100vh;
+}
+
+.tabs {
+  position: relative;
+  display: flex;
+  margin-top: 10px;
+}
+.tabs div {
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 34px;
+  border: 1px solid hsla(0, 0%, 100%, 0.2);
+  width: 50%;
+  &:first-child {
+    border-right: none;
+    border-top-left-radius: 4px;
+    border-bottom-left-radius: 4px;
+  }
+  &:last-child {
+    border-left: none;
+    border-top-right-radius: 4px;
+    border-bottom-right-radius: 4px;
+  }
+  &.active {
+    color: aqua;
+    border-color: aqua;
+  }
+  &.split {
+    position: absolute;
+    top: 0;
+    left: 50%;
+    width: 1px;
+    height: 100%;
+    background-color: aqua;
+    border: none;
+  }
+}
+
+.widgets {
+  display: flex;
+  flex-wrap: wrap;
+  margin-left: -8px;
+}
+.widgets > div {
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 48px;
+  height: 48px;
+  background: hsla(0, 0%, 100%, 0.05);
+  border: 1px solid hsla(0, 0%, 100%, 0.2);
+  margin-top: 8px;
+  margin-left: 8px;
+  font-size: 12px;
+  white-space: pre-wrap;
+  word-break: break-all;
+  flex-wrap: wrap;
+}
+.widgets > div:hover {
+  color: aqua;
+}
+.widgets > div:last-child {
+  margin-right: 0;
+}
+.widgets > div.selected {
+  color: aqua;
+  border: solid 1px aqua;
+}
+</style>

+ 22 - 14
playground/src/demos/extract.vue

@@ -1,15 +1,25 @@
 <script setup lang="ts">
 import '@kankan-components/theme-chalk/src/index.scss'
-import { onMounted, inject } from 'vue'
+import { onMounted, inject, ref } from 'vue'
 import { KkButton } from '@kankan-components/components'
-import UIButton from '../components/basic/button'
+
+const range = ref(100)
+const isEnter = ref(false)
 
 const __sdk: any = inject('__sdk')
 const onEnter = () => {
-  __sdk.Plugins.TagEditor.enter()
+  isEnter.value = true
+  __sdk.Camera.extract.enter()
 }
 const onLeave = () => {
-  __sdk.Plugins.TagEditor.exit()
+  isEnter.value = false
+  __sdk.Camera.extract.leave()
+}
+const onReset = (type: string) => {
+  __sdk.Camera.extract.reset(type)
+}
+const onChange = () => {
+  __sdk.Camera.extract.range(range.value)
 }
 onMounted(() => {
   __sdk.use('Xui')
@@ -19,20 +29,18 @@ onMounted(() => {
 
 <template>
   <div id="tools">
-    <KkButton>sdfsdf</KkButton>
-    <UIButton>sdfsdf</UIButton>
-    <!-- <button onclick="onEnter()">编辑</button
-    ><button onclick="onResetCamera()">水平重置</button
-    ><button onclick="onResetScale()">1:1</button>
+    <button @click="onEnter()">编辑</button>
+    <button @click="onLeave()">退出</button>
+    <button @click="onReset('camera')" :disabled="!isEnter">水平重置</button>
+    <button @click="onReset('scale')" :disabled="!isEnter">1:1</button>
     <input
-      id="range"
-      name="range"
       type="range"
       min="70"
       max="300"
-      value="100"
-      oninput="onChange(this.value)"
-    /> -->
+      v-model="range"
+      @input="onChange()"
+      :disabled="!isEnter"
+    />
   </div>
   <div id="scene"></div>
 </template>

+ 94 - 0
playground/src/demos/screenshot.vue

@@ -0,0 +1,94 @@
+<script setup lang="ts">
+import { onMounted, inject, ref } from 'vue'
+const __sdk: any = inject('__sdk')
+
+const types = ref(['blob', 'base64'])
+const resolutions = ref([
+  { width: 2048, height: 1024, name: '2k' },
+  { width: 1024, height: 512, name: '1k' },
+  { width: 128, height: 128, name: '128' },
+  { name: '全部' },
+])
+const type = ref(0)
+const resolution = ref(0)
+
+const onEntry = async () => {
+  let exports = types.value[type.value] === 'blob'
+  let options: Array<any> = []
+  let opiton = resolutions.value[resolution.value]
+  if (opiton.name == '全部') {
+    options = resolutions.value.filter((c) => c.name != '全部')
+  } else {
+    options = [opiton]
+  }
+  const images = await __sdk.Camera.screenshot(options, exports)
+  const info = __sdk.Camera.getScreenshotInfo()
+  console.log(images)
+  console.log(info)
+}
+const onNormal = () => {
+  let exports = types.value[type.value] === 'blob'
+  let options: Array<any> = []
+  let opiton = resolutions.value[resolution.value]
+  if (opiton.name == '全部') {
+    options = resolutions.value.filter((c) => c.name != '全部')
+  } else {
+    options = [opiton]
+  }
+  __sdk.Camera.screenshot(options, exports).then((result: any) => {
+    result.forEach((item: any) => {
+      window.KanKan.Utils.file.downloadFromURL(item.data, `${item.name}.jpg`)
+    })
+  })
+}
+
+onMounted(() => {
+  __sdk.mount('#scene').render()
+})
+</script>
+
+<template>
+  <div id="tools">
+    <div>
+      <label for="">类型</label>
+      <select v-model="type">
+        <option v-for="(item, index) in types" :value="index">
+          {{ item }}
+        </option>
+      </select>
+    </div>
+    <div>
+      <label for="">分辨率</label>
+      <select v-model="resolution">
+        <option v-for="(item, index) in resolutions" :value="index">
+          {{ item.name }}
+        </option>
+      </select>
+    </div>
+    <button @click="onEntry">首屏截图</button>
+    <button @click="onNormal">普通截图</button>
+  </div>
+  <div id="scene"></div>
+</template>
+
+<style scoped>
+#tools {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #444;
+}
+#scene {
+  width: 100%;
+  height: 100%;
+  font-style: normal;
+}
+select {
+  width: 60px;
+}
+</style>

+ 61 - 0
playground/src/demos/tag-delete.vue

@@ -0,0 +1,61 @@
+<script setup lang="ts">
+import { onMounted, inject, ref } from 'vue'
+
+const __sdk: any = inject('__sdk')
+const tags = ref<Array<Tag>>([])
+const onEnter = () => {
+  __sdk.Plugins.TagEditor.enter()
+}
+const onLeave = () => {
+  __sdk.Plugins.TagEditor.exit()
+}
+onMounted(() => {
+  __sdk.use('TagView')
+  __sdk.use('TagEditor')
+  __sdk.TagManager.on('loaded', (data: Array<Tag>) => (tags.value = data))
+  __sdk.mount('#scene').render()
+})
+</script>
+
+<template>
+  <div id="tools">
+    <ul>
+      <li v-for="tag in tags">{{ tag.title }}<button>删除</button></li>
+    </ul>
+  </div>
+  <div id="scene"></div>
+</template>
+
+<style scoped>
+#tools {
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  bottom: 20px;
+  width: 200px;
+  z-index: 1000;
+  border-radius: 8px;
+  background-color: rgba(0, 0, 0, 0.3);
+  border: solid 3px rgba(0, 0, 0, 0.6);
+}
+#tools ul {
+  margin: 0;
+  padding: 10px;
+}
+#tools li {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 10px;
+}
+#scene {
+  width: 100vw;
+  height: 100vh;
+}
+#scene-front {
+  position: absolute;
+  left: 0;
+  top: 0;
+  z-index: 2;
+}
+</style>

+ 200 - 0
playground/src/demos/tour/play.vue

@@ -0,0 +1,200 @@
+<script setup lang="ts">
+import { onMounted, inject, ref } from 'vue'
+const __sdk: any = inject('__sdk')
+const tours = ref<Array<TourPart>>([])
+const playing = ref(false)
+
+const p_id = ref(-1)
+const f_id = ref(-1)
+// 片段进度
+const p_progress = ref(0)
+// 画面进度
+const f_progress = ref(0)
+// 获取用户资源地址
+const getURL = (file: string) => {
+  return __sdk.resource.getUserResourceURL(file)
+}
+
+const onPlay = () => {
+  if (playing.value) {
+    __sdk.Plugins.TourPlayer.pause()
+  } else {
+    __sdk.Plugins.TourPlayer.play()
+  }
+}
+
+const onSelect = (partId: number, frameId: number) => {
+  if (playing.value) {
+    __sdk.Plugins.TourPlayer.play(partId, frameId)
+  } else {
+    f_id.value = frameId
+    f_progress.value = 0
+    __sdk.Plugins.TourPlayer.selectFrame(frameId)
+    document.querySelector(`[index="${frameId}"]`)?.scrollIntoView()
+  }
+}
+
+onMounted(() => {
+  __sdk.use('TourPlayer').then((player: any) => {
+    player.on('play', () => (playing.value = true))
+    player.on('pause', () => (playing.value = false))
+    player.on('end', () => {
+      playing.value = false
+      // 兼容最后一个画面没有进度的问题
+      p_progress.value = 100
+      f_progress.value = 100
+      setTimeout(() => {
+        p_progress.value = 0
+        f_progress.value = 0
+      }, 1000)
+    })
+
+    let currPartId = 0
+    let currFrames = 0
+    player.on('progress', ({ partId, frameId, progress }: any) => {
+      if (frameId != f_id.value) {
+        document.querySelector(`[index="${frameId}"]`)?.scrollIntoView()
+      }
+
+      // 画面进度
+      p_id.value = partId
+      f_id.value = frameId
+      f_progress.value = Number(Number(progress * 100).toFixed(5))
+
+      // 片段进度
+      if (tours.value.length == 1) {
+        p_progress.value = f_progress.value
+      } else {
+        if (currPartId != partId) {
+          currPartId = partId
+          currFrames = tours.value[partId].list.length
+          p_progress.value = 0
+        }
+        p_progress.value += progress / currFrames
+      }
+    })
+  })
+
+  // 需要双向绑定时,重新设置数据
+  __sdk.TourManager.on('loaded', (data: any) => {
+    tours.value = data
+    __sdk.TourManager.load(tours.value)
+  })
+
+  __sdk.Scene.on('loaded', () => {
+    __sdk.core.get('Player').on('click', () => {
+      if (playing.value) {
+        __sdk.Plugins.TourPlayer.pause()
+      }
+    })
+  })
+
+  __sdk.mount('#scene').render()
+})
+</script>
+
+<template>
+  <div id="tools">
+    <button @click="onPlay">{{ playing ? '暂停' : '播放' }}</button>
+  </div>
+  <div id="tours">
+    <div class="list">
+      <div v-for="(part, partId) in tours">
+        <ul>
+          <li
+            v-for="(frame, frameId) in part.list"
+            :index="frameId"
+            :class="{ active: frameId == f_id }"
+            :style="{ backgroundImage: `url(${getURL(frame.enter.cover)})` }"
+            @click="onSelect(partId, frameId)"
+          >
+            <div class="progress" v-if="frameId == f_id">
+              <div :style="{ width: f_progress + '%' }"></div>
+            </div>
+          </li>
+        </ul>
+      </div>
+    </div>
+  </div>
+  <div id="scene"></div>
+</template>
+
+<style scoped lang="scss">
+#tools {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  z-index: 1000;
+}
+#tours {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  width: 100%;
+  z-index: 1000;
+  background-color: rgba(27, 27, 28, 0.6);
+  .list {
+    scroll-behavior: smooth;
+    width: 100%;
+    overflow: hidden;
+    overflow-x: auto;
+    transition: all ease-in 0.3s;
+    > div {
+      height: 120px;
+      display: flex;
+      align-items: center;
+    }
+  }
+  ul {
+    margin: 0;
+    padding: 0;
+    width: 100%;
+    display: flex;
+    align-items: center;
+    list-style: none;
+  }
+  li {
+    cursor: pointer;
+    position: relative;
+    display: flex;
+    flex-shrink: 0;
+    margin: 0;
+    padding: 0;
+    width: 120px;
+    height: 80px;
+    margin-right: 10px;
+    background-color: rgba(27, 27, 28, 0.6);
+    background-repeat: no-repeat;
+    background-size: 100%;
+    background-position: 50%;
+    border: solid 1px transparent;
+    .progress {
+      position: absolute;
+      left: 0;
+      bottom: 0;
+      width: 100%;
+      height: 4px;
+      div {
+        height: 100%;
+        background-color: aqua;
+        transition: width ease-in 0.1s;
+      }
+    }
+    &.active {
+      border-color: aqua;
+    }
+  }
+}
+#scene {
+  width: 100vw;
+  height: 100vh;
+}
+#scene-front {
+  position: absolute;
+  left: 0;
+  top: 0;
+  z-index: 2;
+}
+</style>

+ 203 - 0
playground/src/demos/tour/record.vue

@@ -0,0 +1,203 @@
+<script setup lang="ts">
+import { onMounted, inject, ref } from 'vue'
+const __sdk: any = inject('__sdk')
+const tours = ref<Array<TourPart>>([])
+const playing = ref(false)
+
+const p_id = ref(-1)
+const f_id = ref(-1)
+// 片段进度
+const p_progress = ref(0)
+// 画面进度
+const f_progress = ref(0)
+// 获取用户资源地址
+const getURL = (file: string) => {
+  return __sdk.resource.getUserResourceURL(file)
+}
+
+const onPlay = () => {
+  if (playing.value) {
+    __sdk.Plugins.TourPlayer.pause()
+  } else {
+    __sdk.Plugins.TourPlayer.play()
+  }
+}
+
+const onSelect = (partId: number, frameId: number) => {
+  if (playing.value) {
+    __sdk.Plugins.TourPlayer.play(partId, frameId)
+  } else {
+    f_id.value = frameId
+    f_progress.value = 0
+    __sdk.Plugins.TourPlayer.selectFrame(frameId)
+    document.querySelector(`[index="${frameId}"]`)?.scrollIntoView()
+  }
+}
+
+onMounted(() => {
+  __sdk.use('TourPlayer').then((player: any) => {
+    player.on('play', () => (playing.value = true))
+    player.on('pause', () => (playing.value = false))
+    player.on('end', () => {
+      playing.value = false
+      // 兼容最后一个画面没有进度的问题
+      p_progress.value = 100
+      f_progress.value = 100
+      setTimeout(() => {
+        p_progress.value = 0
+        f_progress.value = 0
+      }, 1000)
+    })
+
+    let currPartId = 0
+    let currFrames = 0
+    player.on('progress', ({ partId, frameId, progress }: any) => {
+      if (frameId != f_id.value) {
+        document.querySelector(`[index="${frameId}"]`)?.scrollIntoView()
+      }
+
+      // 画面进度
+      p_id.value = partId
+      f_id.value = frameId
+      f_progress.value = Number(Number(progress * 100).toFixed(5))
+
+      // 片段进度
+      if (tours.value.length == 1) {
+        p_progress.value = f_progress.value
+      } else {
+        if (currPartId != partId) {
+          currPartId = partId
+          currFrames = tours.value[partId].list.length
+          p_progress.value = 0
+        }
+        p_progress.value += progress / currFrames
+      }
+    })
+  })
+
+  // 需要双向绑定时,重新设置数据
+  __sdk.TourManager.on('loaded', (data: any) => {
+    tours.value = data
+    __sdk.TourManager.load(tours.value)
+  })
+
+  __sdk.Scene.on('loaded', () => {
+    __sdk.core.get('Player').on('click', () => {
+      if (playing.value) {
+        __sdk.Plugins.TourPlayer.pause()
+      }
+    })
+  })
+
+  __sdk.mount('#scene').render()
+})
+</script>
+
+<template>
+  <div id="tools">
+    <!-- <button :disabled="playing" @click="addFrame">添加画面</button>
+    <button :disabled="playing" @click="addPart">添加片段</button>
+    <button :disabled="playing" @click="clear">清空</button> -->
+    <button @click="onPlay">{{ playing ? '暂停' : '播放' }}</button>
+  </div>
+  <div id="tours">
+    <div class="list">
+      <div v-for="(part, partId) in tours">
+        <ul>
+          <li
+            v-for="(frame, frameId) in part.list"
+            :index="frameId"
+            :class="{ active: frameId == f_id }"
+            :style="{ backgroundImage: `url(${getURL(frame.enter.cover)})` }"
+            @click="onSelect(partId, frameId)"
+          >
+            <div class="progress" v-if="frameId == f_id">
+              <div :style="{ width: f_progress + '%' }"></div>
+            </div>
+          </li>
+        </ul>
+      </div>
+    </div>
+  </div>
+  <div id="scene"></div>
+</template>
+
+<style scoped lang="scss">
+#tools {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  z-index: 1000;
+}
+#tours {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  width: 100%;
+  z-index: 1000;
+  background-color: rgba(27, 27, 28, 0.6);
+  .list {
+    scroll-behavior: smooth;
+    width: 100%;
+    overflow: hidden;
+    overflow-x: auto;
+    transition: all ease-in 0.3s;
+    > div {
+      height: 120px;
+      display: flex;
+      align-items: center;
+    }
+  }
+  ul {
+    margin: 0;
+    padding: 0;
+    width: 100%;
+    display: flex;
+    align-items: center;
+    list-style: none;
+  }
+  li {
+    cursor: pointer;
+    position: relative;
+    display: flex;
+    flex-shrink: 0;
+    margin: 0;
+    padding: 0;
+    width: 120px;
+    height: 80px;
+    margin-right: 10px;
+    background-color: rgba(27, 27, 28, 0.6);
+    background-repeat: no-repeat;
+    background-size: 100%;
+    background-position: 50%;
+    border: solid 1px transparent;
+    .progress {
+      position: absolute;
+      left: 0;
+      bottom: 0;
+      width: 100%;
+      height: 4px;
+      div {
+        height: 100%;
+        background-color: aqua;
+        transition: width ease-in 0.1s;
+      }
+    }
+    &.active {
+      border-color: aqua;
+    }
+  }
+}
+#scene {
+  width: 100vw;
+  height: 100vh;
+}
+#scene-front {
+  position: absolute;
+  left: 0;
+  top: 0;
+  z-index: 2;
+}
+</style>

+ 55 - 2
playground/src/pages/Index.vue

@@ -2,18 +2,63 @@
 import { ref } from 'vue'
 import TreeItem from './index.menu.vue'
 
-const menu = ref([
+interface MenuItem {
+  name: string
+  text: string
+  next?: Array<MenuItem> | undefined
+  active?: Boolean
+}
+
+const menu = ref<Array<MenuItem>>([
   { text: '基础用法', name: 'basic' },
   {
     text: '热点管理',
+    name: 'tag',
     next: [
       { text: '添加热点', name: 'tag-add' },
       { text: '编辑热点', name: 'tag-add' },
-      { text: '删除热点', name: 'tag-add' },
+      { text: '删除热点', name: 'tag-delete' },
     ],
   },
+  {
+    text: '自动导览',
+    name: 'tour',
+    next: [
+      { text: '录制导览', name: 'tour/record' },
+      { text: '播放导览', name: 'tour/play' },
+    ],
+  },
+  { text: '固定截图', name: 'screenshot' },
   { text: '动态截图', name: 'extract' },
+  { text: '户型图', name: 'cad/index' },
 ])
+
+const menuActive = ref(
+  (window.location.hash || '').replace(/#\/?/, '').split('=')[1] || 'basic'
+)
+const menuSearch = (data: Array<MenuItem> | undefined) => {
+  if (data === undefined) {
+    data = menu.value
+  }
+
+  for (let i = 0; i < data.length; i++) {
+    delete data[i].active
+    if (data[i].next) {
+      menuSearch(data[i].next)
+    } else if (data[i].name == menuActive.value) {
+      data[i].active = true
+    }
+  }
+}
+
+window.onhashchange = () => {
+  menuActive.value = (window.location.hash || '')
+    .replace(/#\/?/, '')
+    .split('=')[1]
+  menuSearch(undefined)
+}
+menuSearch(undefined)
+console.log(menu)
 let params = (window.location.hash || '').replace(/#\/?/, '')
 if (params.indexOf('app') === -1) {
   params = `app=${menu.value[0].name}`
@@ -23,6 +68,7 @@ const onLoad = () => {
   let iframe = document.getElementById('demo') as HTMLIFrameElement
   if (iframe.contentWindow) {
     window.__sdk = iframe.contentWindow.__sdk
+    window.KanKan = iframe.contentWindow.KanKan
     window.location.hash = iframe.contentWindow.location.search.substring(1)
   }
 }
@@ -60,13 +106,20 @@ const onLoad = () => {
 }
 aside {
   width: 180px;
+  height: 100%;
+  overflow: hidden;
 }
 main {
   flex: 1;
   width: 100%;
   height: 100%;
+  overflow: hidden;
+  position: relative;
 }
 iframe {
+  position: absolute;
+  left: 0;
+  top: 0;
   width: 100%;
   height: 100%;
   border: none;

+ 6 - 5
playground/src/pages/demo.ts

@@ -2,11 +2,12 @@ import '../style.css'
 import { createApp } from 'vue'
 import { installSDK } from '../sdk'
 
-let demo = new URLSearchParams(window.location.search).get('app')
+let demo = decodeURIComponent(
+  new URLSearchParams(window.location.search).get('app') || ''
+)
 if (demo) {
-  //   let modules = import.meta.glob(`../demos/*.vue`)
-  //   const App = modules[`../demos/${demo}.vue`]
-  //   createApp(App).mount('#app')
-  const app = createApp((await import(`../demos/${demo}.vue`)).default)
+  const modules = import.meta.glob([`../demos/*/*.vue`, `../demos/*.vue`])
+  const App: any = modules[`../demos/${demo}.vue`]
+  const app = createApp((await App()).default)
   app.use(installSDK).mount('#app')
 }

+ 10 - 5
playground/src/pages/index.menu.vue

@@ -6,12 +6,10 @@ defineOptions({
 const props = defineProps({
   data: Object,
 })
-console.log(props.data)
 const isOpen = ref(false)
 const isFolder = computed(() => {
   return props.data?.next && props.data?.next.length
 })
-
 function toggle() {
   isOpen.value = !isOpen.value
 }
@@ -20,9 +18,13 @@ function toggle() {
   <li class="item">
     <div @click="toggle">
       <span v-if="isFolder">{{ props.data?.text }}</span>
-      <a v-else target="demo" :href="`demo.html?app=${props.data?.name}`">{{
-        props.data?.text
-      }}</a>
+      <a
+        v-else
+        target="demo"
+        :href="`demo.html?app=${props.data?.name}`"
+        :class="{ active: props.data?.active }"
+        >{{ props.data?.text }}</a
+      >
       <span v-if="isFolder" class="folder">[{{ isOpen ? '-' : '+' }}]</span>
     </div>
     <ul v-if="isFolder" :style="{ display: isOpen ? 'block' : 'none' }">
@@ -70,4 +72,7 @@ li > div a:hover {
 .folder i {
   font-style: normal;
 }
+.active {
+  color: #f60;
+}
 </style>

+ 26 - 0
playground/src/style.css

@@ -15,6 +15,27 @@
   -webkit-text-size-adjust: 100%;
 }
 
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::-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;
+}
+
 html,
 body {
   width: 100%;
@@ -23,3 +44,8 @@ body {
   overflow: hidden;
   padding: 0;
 }
+
+#app {
+  width: 100%;
+  height: 100%;
+}

+ 19 - 0
playground/src/typings/index.d.ts

@@ -0,0 +1,19 @@
+interface Window {
+  KanKan: any
+}
+
+interface KanKan {}
+
+interface Tag {
+  x: Number
+  y: Number
+  sid: string
+  visible: boolean
+  title: string
+}
+interface TourPart {
+  list: Array<TourFrame>
+}
+interface TourFrame {
+  enter: { cover: string }
+}

+ 1 - 0
typings/env.d.ts

@@ -3,6 +3,7 @@ import type { INSTALLED_KEY } from '@element-plus/constants'
 declare global {
   interface Window {
     __sdk: any
+    KanKan: any
   }
 
   const process: {

+ 8 - 0
typings/index.d.ts

@@ -27,3 +27,11 @@ declare type ComponentRef<T extends HTMLElement = HTMLDivElement> =
   ComponentElRef<T> | null
 
 declare type ElRef<T extends HTMLElement = HTMLDivElement> = Nullable<T>
+
+interface Tag {
+  x: number
+  y: number
+  sid: string
+  visible: boolean
+  title: string
+}