bill 3 роки тому
батько
коміт
ea78d71cb6

+ 4 - 1
src/api/constant.ts

@@ -20,4 +20,7 @@ export const MODEL_LIST = ''
 export const TAGGING_LIST = ''
 
 // 标注样式类型列表
-export const TAGGING_STYLE_LIST = ''
+export const TAGGING_STYLE_LIST = ''
+
+// 路径
+export const PATH_LIST = ''

+ 24 - 0
src/api/guide.ts

@@ -0,0 +1,24 @@
+import axios from './instance'
+import { PATH_LIST } from './constant'
+
+export interface GuidePath {
+  id: string,
+  position: ScenePos
+  target: ScenePos
+  time: number
+  speed: number
+  cover: string
+}
+
+export interface Guide {
+  id: string
+  cover: string
+  title: string
+  paths: GuidePath[]
+}
+
+export type Guides = Guide[]
+export type GuidePaths = GuidePath[]
+
+export const getGuides = () => 
+  axios.post<Guides>(PATH_LIST)

+ 2 - 1
src/api/index.ts

@@ -6,4 +6,5 @@ export type { ResData } from './setup'
 export * from './instance'
 export * from './model'
 export * from './tagging'
-export * from './tagging-style'
+export * from './tagging-style'
+export * from './guide'

+ 69 - 0
src/components/static-preview/index.vue

@@ -0,0 +1,69 @@
+<template>
+  <teleport to="body">
+    <span class="close pc" @click="$emit('close')">
+      <ui-icon type="close" ctrl />
+    </span>
+    <div class="pull-preview pc">
+      <div class="preview-layer">
+        <div class="pull-meta">
+            <video v-if="type === MediaType.video" controls autoplay playsinline webkit-playsinline>
+              <source :src="staticURL" />
+            </video>
+            <iframe v-else-if="type === MediaType.web" :src="staticURL"></iframe>
+            <div v-if="type === MediaType.img" class="full-img pc">
+              <img :src="staticURL" />
+            </div>
+        </div>
+      </div>
+    </div>
+  </teleport>
+</template>
+
+<script lang="ts">
+import { ref, watchEffect, defineComponent, PropType } from 'vue'
+
+export enum MediaType {
+  video,
+  img,
+  web
+}
+
+export const Preview =  defineComponent({
+  name: 'static-preview',
+  props: {
+    url: {
+      type: String as PropType<Blob | string>,
+      required: true
+    },
+    type: {
+      type: Number as PropType<MediaType>,
+      required: true
+    }
+  },
+  emits: {
+    close: () => true
+  },
+  setup(props) {
+    const staticURL = ref('')
+    watchEffect(() => {
+      const data = props.url
+      const url = typeof data === 'string'
+        ? data
+        : URL.createObjectURL(data)
+
+      staticURL.value = url
+      return () => URL.revokeObjectURL(url)
+    })
+    
+    return {
+      staticURL,
+      MediaType
+    }
+  }
+})
+
+
+export default Preview
+</script>
+
+<style scoped lang="scss" src="./style.scss"></style>

+ 95 - 0
src/components/static-preview/style.scss

@@ -0,0 +1,95 @@
+
+.close {
+  right: 0;
+  top: 0;
+  height: 25px;
+  position: absolute;
+  font-size: 18px;
+  color: #fff;
+  cursor: pointer;
+  width: 50px;
+  height: 50px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 99999;
+}
+
+.pull-preview {
+  position: absolute;
+  z-index: 9999;
+  display: flex;
+  align-items: center;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  background-color: rgba(0,0,0,0.1);
+  backdrop-filter: blur(1px);
+  
+  &:not(.pc) .preview-layer {
+    padding-top: 40px;
+  }
+
+  &.pc .preview-layer {
+    padding: 40px 20px 20px;
+  }
+
+
+  .preview-layer {
+    flex: 1;
+    background-color: rgba(0,0,0,.7);
+    color: #fff;
+    height: 100%;
+    position: relative;
+    display: flex;
+    flex-direction: column;
+
+
+    h3 {
+      font-size: 20px;
+      font-weight: 700;
+      letter-spacing: 1px;
+      margin-bottom: 10px;
+      word-break: break-all;
+    }
+
+
+    .pull-meta {
+      height: 100%;
+      width: 100%;
+      overflow-y: auto;
+      flex: 1;
+
+      .content {
+        margin-bottom: 10px;
+        font-size: 16px;
+        font-weight: 400;
+        line-height: 26px;
+        color: #ccc;
+        word-break: break-all;
+        letter-spacing: 1px;
+  
+      }
+
+      iframe,
+      video,
+      img {
+        width: 100%;
+        height: 100%;
+        display: block;
+      }
+
+      video, img {
+        object-fit: contain;
+      }
+
+      iframe{
+        border: none;
+        height: 100%;
+      }
+    }
+  }
+}
+

+ 17 - 0
src/components/tagging/list.vue

@@ -0,0 +1,17 @@
+<template>
+  <Sign 
+    v-for="(pos, index) in tagging.positions" 
+    :key="index"
+    :tagging="tagging"
+    :scene-pos="pos"
+  />
+</template>
+
+<script lang="ts" setup>
+import { Tagging } from '@/store';
+import Sign from './sign.vue'
+
+defineProps<{ tagging: Tagging }>()
+
+
+</script>

+ 125 - 0
src/components/tagging/sign.vue

@@ -0,0 +1,125 @@
+<template>
+  <div 
+    class="hot-item pc" 
+    :style="posStyle" 
+    @mouseenter="isHover = true"
+    @mouseleave="isHover = false"
+  >
+    <img :src="taggingStyle?.icon" @click="iconClickHandler" />
+    <div @click.stop>
+      <UIBubble
+        class="hot-bubble pc" 
+        :show="!~pullIndex && (isHover || show)" 
+        type="left" 
+        level="center"
+      >
+        <h2>{{ tagging.title }} </h2>
+        <div class="content">
+          <p><span>特征描述:</span>{{ tagging.desc }}</p>
+          <p><span>遗留部位:</span>{{ tagging.part }}</p>
+          <p><span>提取方法:</span>{{ tagging.method }}</p>
+          <p><span>提取人:</span>{{ tagging.principal }}</p>
+        </div>
+        <Images 
+          :tagging="tagging" 
+          :in-full="true" 
+          @pull="index => (pullIndex = index)" 
+        />
+      </UIBubble>
+
+      <Preview 
+        @close="pullIndex = -1"
+        :type="MediaType.img" 
+        :url="tagging.images[pullIndex]" 
+        v-if="!!~pullIndex" 
+      />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import UIBubble from 'bill/components/bubble/index.vue'
+import Images from '@/views/tagging/images.vue'
+import Preview, { MediaType } from '../static-preview/index.vue'
+import { Tagging, getTaggingStyle } from '@/store';
+
+export type SignProps = { tagging: Tagging, scenePos: Tagging['positions'][number], show?: boolean }
+
+
+const props = defineProps<SignProps>()
+
+const posStyle = computed(() => {
+  const screenPos = {
+    x: 700,
+    y: 400
+  } 
+  // sdk.getPositionByScreen(props.scenePos)
+  return {
+    left: screenPos.x + 'px',
+    top: screenPos.y + 'px',
+  }
+})
+const taggingStyle = getTaggingStyle(props.tagging.id)
+
+const pullIndex = ref(-1)
+
+const isHover = ref(false)
+const show = ref(false)
+
+const iconClickHandler = () => {
+  show.value = !show.value
+}
+
+</script>
+
+<style lang="scss" scoped>
+.hot-item {
+  pointer-events: all;
+  position: absolute;
+  cursor: pointer;
+
+  > img {
+    width: 32px;
+    height: 32px;
+  }
+
+  .hot-bubble {
+    cursor: initial;
+
+    &.pc {
+      width: 400px;
+    }
+    &:not(.pc) {
+      width: 80vw;
+      --bottom-left: 40vw;
+    }
+
+    h2 {
+      font-size: 20px;
+      margin-bottom: 10px;
+      color: #FFFFFF;
+      position: relative;
+    }
+
+    .content {
+      font-size: 14px;
+      font-family: MicrosoftYaHei;
+      color: #999999;
+      line-height: 1.35em;
+      margin-bottom: 20px;
+      word-break: break-all;
+
+      p {
+        margin-bottom: 10px;
+      }
+    }
+  }
+
+  &.active,
+  &:hover {
+    z-index: 3;
+  }
+}
+
+</style>

+ 3 - 1
src/env/index.ts

@@ -6,12 +6,14 @@ export const showToolbarStack = stackFactory(ref<boolean>(false))
 export const showRightPanoStack = stackFactory(ref<boolean>(true))
 export const showLeftPanoStack = stackFactory(ref<boolean>(false))
 export const showLeftCtrlPanoStack = stackFactory(ref<boolean>(true))
+export const showRightCtrlPanoStack = stackFactory(ref<boolean>(true))
 
 export const custom = flatStacksValue({
   viewMode: viewModeStack,
   showToolbar: showToolbarStack,
   showRightPano: showRightPanoStack,
   showLeftPano: showLeftPanoStack,
-  showLeftCtrlPano: showLeftCtrlPanoStack
+  showLeftCtrlPano: showLeftCtrlPanoStack,
+  shwoRightCtrlPano: showRightCtrlPanoStack
 })
 

+ 1 - 1
src/layout/right-fill-pano.vue

@@ -1,7 +1,7 @@
 <template>
   <span 
     class="ctrl-pano-c fun-ctrl strengthen-left strengthen-top strengthen-bottom"
-    v-if="custom.viewMode !== 'full'" 
+    v-if="custom.shwoRightCtrlPano" 
     @click="hidePano"
     :class="{ active: custom.showRightPano }">
     <ui-icon type="extend" class="icon"></ui-icon>

+ 4 - 0
src/layout/slide-menu.vue

@@ -19,6 +19,10 @@ const items: Items = [
   {
     name: RoutesName.tagging,
     ...metas[RoutesName.tagging]
+  },
+  {
+    name: RoutesName.guide,
+    ...metas[RoutesName.guide]
   }
 ]
 

+ 2 - 0
src/main.ts

@@ -8,3 +8,5 @@ const app = createApp(App)
 app.use(Components)
 app.use(router)
 app.mount('#app')
+
+export default app

+ 6 - 0
src/router/config.ts

@@ -14,6 +14,12 @@ export const routes: RouteRecordRaw[] = [
     name: RoutesName.tagging,
     meta: metas.tagging,
     component: () => import('@/views/tagging/index.vue')
+  },
+  {
+    path: paths.guide,
+    name: RoutesName.guide,
+    meta: metas.guide,
+    component: () => import('@/views/guide/index.vue')
   }
 ]
 

+ 8 - 2
src/router/constant.ts

@@ -1,13 +1,15 @@
 export enum RoutesName {
   merge = 'merge',
-  tagging = 'tagging'
+  tagging = 'tagging',
+  guide = 'guide'
 }
 
 type RouteSeting<T> = {[key in RoutesName]: T}
 
 export const paths: RouteSeting<string> = {
   [RoutesName.merge]: '/merge',
-  [RoutesName.tagging]: '/tagging'
+  [RoutesName.tagging]: '/tagging',
+  [RoutesName.guide]: '/path'
 }
 
 export type Meta = { title: string, icon: string }
@@ -20,6 +22,10 @@ export const metas: RouteSeting<Meta> = {
   [RoutesName.tagging]: {
     icon: 'nav-browse',
     title: '标注'
+  },
+  [RoutesName.guide]: {
+    icon: 'nav-browse',
+    title: '路径'
   }
 }
 

+ 91 - 7
src/sdk/association.ts

@@ -1,5 +1,6 @@
-import { models } from '@/store'
-import { toRaw, createVNode, watchEffect } from 'vue'
+import { models, taggings, isEdit, sysBus } from '@/store'
+import { toRaw, watchEffect, ref, watch } from 'vue'
+import { viewModeStack } from '@/env'
 import { 
   mount, 
   diffArrayChange, 
@@ -7,8 +8,10 @@ import {
   arrayChildEffectScope 
 } from '@/utils'
 
-import type { SDK, SceneModel } from '.'
-import type { Model } from '@/api'
+import TaggingComponent from '@/components/tagging/list.vue'
+
+import type { SDK, SceneModel, SceneGuidePath } from '.'
+import { Model, Tagging } from '@/api'
 
 const sceneModelMap = new WeakMap<Model, SceneModel>()
 export const getSceneModel = (model: Model | null) => model && sceneModelMap.get(toRaw(model))
@@ -24,7 +27,6 @@ const associationModels = (sdk: SDK) => {
 
       const itemRaw = toRaw(item)
       const sceneModel = sdk.addModel(itemRaw)
-      console.log(sceneModel)
       sceneModelMap.set(itemRaw, sceneModel)
 
       sceneModel.bus.on('position', pos => item.position = pos)
@@ -43,9 +45,91 @@ const associationModels = (sdk: SDK) => {
   })
 }
 
+const associationTaggings = (el: HTMLDivElement) => {
+  const getTaggings = () => taggings.value
+  const taggingVMs = new WeakMap<Tagging, ReturnType<typeof mount>>()
+
+  shallowWatchArray(getTaggings, (taggings, oldTaggings) => {
+    const { added, deleted } = diffArrayChange(taggings, oldTaggings)
+    for (const item of added) {
+      taggingVMs.set(toRaw(item), mount(el, TaggingComponent, { tagging: item }))
+    }
+    for (const item of deleted) {
+      const unMount = taggingVMs.get(toRaw(item))
+      unMount && unMount()
+    }
+  })
+}
+
+
+const fullView = async (fn: () => void) => {
+  const popViewMode = viewModeStack.push(ref('full'))
+  await document.documentElement.requestFullscreen()
+  const driving = () => document.fullscreenElement || fn()
+
+  document.addEventListener('fullscreenchange', driving)
+  document.addEventListener('fullscreenerror', fn)
+
+  return () => {
+    popViewMode()
+    document.fullscreenElement && document.exitFullscreen()
+    document.removeEventListener('fullscreenchange', driving)
+    document.removeEventListener('fullscreenerror', fn)
+  }
+}
+
+export const isScenePlayIng = ref(false)
+export const playSceneGuide = async (sdk: SDK, paths: SceneGuidePath[], changeIndexCallback: (index: number) => void) => {
+  if (isScenePlayIng.value) {
+    throw new Error('导览正在播放')
+  }
+  isScenePlayIng.value = true
+
+  const sceneGuide = sdk.enterSceneGuide(paths)
+
+  sceneGuide.bus.on('changePoint', changeIndexCallback)
+
+  const quitHandler = () => (isScenePlayIng.value = false)
+  const clearHandler = isEdit.value ? null : await fullView(quitHandler)
+  if (!clearHandler) {
+    sysBus.on('leave', quitHandler, { last: true })
+    sysBus.on('save', quitHandler, { last: true })
+  }
+
+  sceneGuide.play()
+  const reces = [
+    new Promise(resolve => sceneGuide.bus.on('playComplete', resolve)),
+    new Promise<void>(resolve => {
+      const stop = watch(isScenePlayIng, () => {
+        if (!isScenePlayIng.value) {
+          resolve()
+          sceneGuide.pause()
+          stop()
+        }
+      })
+    }),
+  ]
+
+  await Promise.race(reces)
+  isScenePlayIng.value = false
+  if (clearHandler) {
+    clearHandler()
+  } else {
+    sysBus.off('leave', quitHandler)
+    sysBus.off('save', quitHandler)
+  }
+  sceneGuide.clear()
+  sceneGuide.bus.off('changePoint')
+}
+
+export const pauseSceneGuide = () => isScenePlayIng.value = false
 
 
 export const setup = (sdk: SDK, mountEl: HTMLDivElement) => {
-  associationModels(sdk)
-  mount(mountEl, () => createVNode('p', null, '123123'))
+  try {
+    associationModels(sdk)
+  } catch {
+
+  }
+  associationTaggings(mountEl)
 }

+ 17 - 1
src/sdk/index.ts

@@ -2,7 +2,7 @@ import cover from './cover'
 import { setup } from './association'
 import { loadLib } from '@/utils'
 
-import type { ModelAttrs, Model } from '@/api'
+import type { ModelAttrs, Model, GuidePath, GuidePaths } from '@/api'
 import type { Emitter } from 'mitt'
 
 
@@ -19,9 +19,25 @@ export type SceneModel = ToChangeAPI<Omit<SceneModelAttrs, 'position' | 'rotatio
 
 
 export type AddModelProps = Pick<Model, 'type' | 'url'> & ModelAttrs
+
+export type SceneGuidePath = Pick<GuidePath, 'position' | 'target' | 'speed' | 'time'>
+export interface SceneGuide {
+  bus: Emitter<{ changePoint: number; playComplete: void }>
+  play: () => void
+  pause: () => void
+  clear: () => void
+}
+
+
 export interface SDK {
   layout: HTMLDivElement,
   addModel: (props: AddModelProps) => SceneModel
+  getPositionByScreen: (screenPos: ScreenPos) => ScenePos
+  getScreenByPosition: (scenePos: ScenePos) => ScreenPos
+  screenshot: (width: number, height: number) => Promise<string>
+  getPose: () => { position: ScenePos, target: ScenePos }
+  comeTo: (pos: { position: ScenePos; target: ScenePos; dur?: number }) => void
+  enterSceneGuide: (data: SceneGuidePath[]) => SceneGuide
 }
   
 

+ 58 - 0
src/store/guide.ts

@@ -0,0 +1,58 @@
+import { ref } from 'vue'
+import { TemploraryID } from './sys'
+import { getGuides, GuidePath, Guide } from '@/api'
+
+import type { Guides } from '@/api'
+
+export const guides = ref<Guides>([])
+
+
+export const initialGuides = async () => {
+  // taggings.value = await getTaggingStyles()
+  guides.value = [
+    {
+      id: '123',
+      title: '路径1',
+      cover: 'https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png',
+      paths: [
+        {
+          id: '123a',
+          position: {x: 1, y: 1, z: 1},
+          target: {x: 1, y: 1, z: 1},
+          cover: 'https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png',
+          speed: 1,
+          time: 1
+        },
+        {
+          id: '123b',
+          position: {x: 1, y: 1, z: 1},
+          target: {x: 1, y: 1, z: 1},
+          cover: 'https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png',
+          speed: 1,
+          time: 1
+        }
+      ]
+    }
+  ]
+}
+
+export const createGuide = (guide: Partial<Guide> = {}): Guide => ({
+  id: '',
+  title: '',
+  cover: '',
+  paths: [],
+  ...guide
+})
+
+export const createGuidePath = (path: Partial<GuidePath> = {}): GuidePath => ({
+  id: TemploraryID,
+  cover: '',
+  time: 1,
+  speed: 1,
+  position: {x: 0, y: 0, z: 0},
+  target: {x: 0, y: 0, z: 0},
+  ...path
+})
+
+
+export type { Guide, Guides, GuidePath, GuidePaths } from '@/api'

+ 6 - 4
src/store/index.ts

@@ -1,17 +1,18 @@
+import { ref } from 'vue'
 import { initialModels } from './model'
 import { initialTaggings } from './tagging'
-import { ref } from 'vue'
 import { initialTaggingStyles } from './taging-style'
+import { initialGuides } from './guide'
 
 export const loaded = ref(false)
 export const error = ref(false)
-export const TemploraryID = '-1'
 
 export const initialStore = async () => {
   const init = Promise.all([
     initialModels(),
     initialTaggingStyles(),
-    initialTaggings()
+    initialTaggings(),
+    initialGuides()
   ])
 
   try {
@@ -26,4 +27,5 @@ export const initialStore = async () => {
 export * from './sys'
 export * from './model'
 export * from './tagging'
-export * from './taging-style'
+export * from './taging-style'
+export * from './guide'

+ 8 - 7
src/store/sys.ts

@@ -17,9 +17,10 @@ export const isLogin = computed(() => !!(mode.value & Flags.LOGIN))
 export const isOld = computed(() => !(mode.value & Flags.NOW))
 export const isNow = computed(() => !!(mode.value & Flags.NOW))
 export const title = '融合平台'
+export const TemploraryID = '-1'
 
 
-const bus = asyncBusFactory<{ save: void; leave: void }>()
+export const sysBus = asyncBusFactory<{ save: void; leave: void }>()
 
 // 进入编辑界面
 export const enterEdit = () => {
@@ -32,20 +33,20 @@ export const enterOld = () => {
 
 // 放弃保存内容
 export const giveupSave = () => {
-  bus.off('save')
+  sysBus.off('save')
   mode.value |= Flags.NOW
 }
 
 // 放弃编辑内容
 export const giveupLeave = () => {
   giveupSave()
-  bus.off('leave')
+  sysBus.off('leave')
   mode.value &= ~Flags.EDIT
 }
 
 // 保存
 export const save = async () => {
-  await bus.emit('save')
+  await sysBus.emit('save')
   giveupSave()
 }
 
@@ -54,7 +55,7 @@ export const leave = async () => {
   if (isOld.value && !(await Dialog.confirm('您有操作未保存,确定要退出吗?'))) {
     return;
   }
-  await bus.emit('leave')
+  await sysBus.emit('leave')
   giveupLeave()
 }
 
@@ -89,9 +90,9 @@ export const autoSetModeCallback = <T extends object>(current: T, setting: AutoS
     if (!setting.isUpdate || setting.isUpdate(newv, oldv)) {
       isEdit.value || enterEdit()
       isOld.value ||  enterOld()
-      saveCallback && bus.on('save', saveCallback, { last: true })
+      saveCallback && sysBus.on('save', saveCallback, { pre: true })
     }
-    leaveCallback && bus.on('leave', leaveCallback, { last: true })
+    leaveCallback && sysBus.on('leave', leaveCallback, { pre: true })
   }
 
   return () => {

+ 10 - 16
src/store/tagging.ts

@@ -16,33 +16,27 @@ export const initialTaggings = async () => {
       part: '123asd',
       method: '123123a',
       principal: 'asdasd',
-      images: ['https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png'],
+      images: [
+        'https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png',
+        'https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png'
+      ],
       positions: [
         { x: 1, y: 1, z: 1 }
       ]
     },
+
     {
-      id: '12321',
+      id: '1231a',
       title: 'aaaa',
-      desc: '123123',
       styleId: '1231',
-      part: '123asd',
-      method: '123123a',
-      principal: 'asdasd',
-      images: ['https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png'],
-      positions: [
-        { x: 1, y: 1, z: 1 }
-      ]
-    },
-    {
-      id: '1234',
-      title: 'aaaa',
       desc: '123123',
       part: '123asd',
-      styleId: '1231',
       method: '123123a',
       principal: 'asdasd',
-      images: ['https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png'],
+      images: [
+        'https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png',
+        'https://gw.alicdn.com/tps/TB1W_X6OXXXXXcZXVXXXXXXXXXX-400-400.png'
+      ],
       positions: [
         { x: 1, y: 1, z: 1 }
       ]

+ 1 - 0
src/utils/asyncBus.ts

@@ -45,6 +45,7 @@ export const asyncBusFactory = <T extends AsyncEvents>() => {
             for (const key of keys) {
                 if (store[key]) {
                     for (const fn of store[key]) {
+                        console.log(fn)
                         await fn(args)
                     }
                 }

+ 5 - 0
src/utils/index.ts

@@ -31,6 +31,11 @@ export const loadLib = (() => {
   };
 })();
 
+export const togetherCallback = (cbs: (() => void)[]) => () => together(cbs)
+
+export const together = (cbs: (() => void)[]) => {
+  cbs.forEach(cb => cb())
+}
 
 export * from "./stack";
 export * from "./loading";

+ 3 - 0
src/utils/mount.ts

@@ -1,10 +1,13 @@
 import { createVNode, render, Teleport, createBlock, openBlock } from 'vue'
+import app from '@/main'
+
 
 import type { Component } from 'vue'
 
 export const mount = (to: HTMLDivElement, Component: Component, props?: Record<string, any>) => {
   const appEl = document.createElement('div')
   const vnode = createVNode(Component, props)
+  vnode.appContext = app._context
   openBlock()
   const portBlock =createBlock(Teleport as any, { to }, [ vnode ])
   render(portBlock, appEl)

+ 249 - 0
src/views/guide/edit-paths.vue

@@ -0,0 +1,249 @@
+<template>
+  <div class="video">
+    <div class="overflow">
+      <ui-icon 
+        ctrl 
+        :type="isScenePlayIng ? 'pausecircle-fill' : 'playon_fill'" 
+        :disabled="!paths.length" 
+        @click="play"
+      />
+      <ui-button 
+        type="primary" 
+        @click="addPath" 
+        width="200px" 
+        :class="{ disabled: isScenePlayIng }"
+      >
+        添加视角
+      </ui-button>
+    </div>
+    <div class="info" v-if="paths.length">
+      <div class="meta">
+        <div class="length">
+          <span>视频时长</span>
+        </div>
+        <div 
+          class="fun-ctrl clear" 
+          @click="deleteAll" 
+          :class="{ disabled: isScenePlayIng }"
+        >
+          <ui-icon type="del" />
+          <span>清空画面</span>
+        </div>
+      </div>
+
+      <div class="photo-list" ref="listVm">
+        <div 
+          v-for="(path, i) in paths" 
+          class="photo" 
+          :key="path.id"
+          :class="{ active: current === path, disabled: isScenePlayIng }"
+          @click="changeCurrent(path)"
+        >
+          <ui-icon 
+            type="del" 
+            ctrl 
+            @click.stop="deletePath(path)" 
+            :class="{ disabled: isScenePlayIng }" 
+          />
+          <img :src="path.cover" />
+        </div>
+      </div>
+    </div>
+    <p class="un-video" v-else>暂无导览</p>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { loadPack, togetherCallback } from '@/utils'
+import { sdk, playSceneGuide, pauseSceneGuide, isScenePlayIng } from '@/sdk'
+import { createGuidePath, leave, sysBus, useAutoSetMode } from '@/store'
+import { Dialog } from 'bill/index'
+import { useViewStack } from '@/hook'
+import { nextTick, ref, toRaw, watchEffect } from 'vue'
+import { showRightPanoStack, showLeftCtrlPanoStack, showLeftPanoStack, showRightCtrlPanoStack } from '@/env'
+
+import type { GuidePaths, GuidePath } from '@/store'
+
+type LocalPath = GuidePath & { blob?: Blob }
+type LocalPaths = LocalPath[]
+
+const props = defineProps< { data: GuidePaths }>()
+const paths = ref<LocalPaths>([...props.data])
+const current = ref<LocalPath>(paths.value[0])
+
+useViewStack(() => 
+  togetherCallback([
+    showRightPanoStack.push(ref(false)),
+    showLeftCtrlPanoStack.push(ref(false)),
+    showLeftPanoStack.push(ref(false)),
+    showRightCtrlPanoStack.push(ref(false)),
+  ])
+);
+
+useAutoSetMode(paths, { 
+  save() {
+    sysBus.on('save', () => setTimeout(leave), { last: true })
+  }
+})
+
+const addPath = () => {
+  loadPack(async () => {
+    const dataURL = await sdk.screenshot(260, 160)
+    const res = await fetch(dataURL)
+    const blob = await res.blob()
+
+    const pose = sdk.getPose()
+    const index = paths.value.indexOf(current.value) + 1
+    const path: LocalPath = Object.assign(
+      { blob }, 
+      createGuidePath({ ...pose, cover: dataURL })
+    )
+    paths.value.splice(index, 0, path)
+    current.value = path
+  })
+}
+
+const deletePath = async (path: GuidePath, fore: boolean = false) => {
+  if (fore || (await Dialog.confirm('确定要删除此画面吗?'))) {
+    const index = paths.value.indexOf(path)
+    if (~index) {
+      paths.value.splice(index, 1)
+    }
+    if (path === current.value) {
+      current.value = paths.value[index + (index === 0 ? 0 : -1)]
+    }
+  }
+}
+
+const deleteAll = async () => {
+  if (await Dialog.confirm('确定要清空画面吗?')) {
+    while (paths.value.length) {
+      deletePath(paths.value[0], true)
+    }
+    current.value = paths.value[0]
+  }
+}
+
+const changeCurrent = (path: GuidePath) => {
+  sdk.comeTo({ dur: 300, ...path })
+  current.value = path
+}
+
+const play = () => {
+  if (isScenePlayIng.value) {
+    pauseSceneGuide()
+  } else {
+    playSceneGuide(sdk, toRaw(paths.value), (index) => current.value = paths.value[index])
+  }
+}
+
+const listVm = ref<HTMLDivElement>()
+watchEffect(async () => {
+  const index = paths.value.indexOf(current.value)
+  if (~index && listVm.value) {
+    await nextTick()
+    const scrollWidth = listVm.value.scrollWidth / paths.value.length
+    const centerWidth = listVm.value.offsetWidth / 2
+    const offsetLeft = scrollWidth * index - centerWidth
+
+    listVm.value.scroll({
+      left: offsetLeft,
+      top: 0,
+    })
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.video {
+  position: relative;
+
+  .overflow {
+    position: absolute;
+    left: 50%;
+    bottom: 100%;
+    transform: translateX(-50%);
+    margin-bottom: 20px;
+    display: flex;
+    align-items: center;
+
+    .icon {
+      margin-right: 20px;
+      color: #fff;
+      font-size: 50px;
+    }
+  }
+
+  .meta {
+    font-size: 12px;
+    border-bottom: 1px solid rgba(255,255,255,.6);
+    padding: 10px 20px;
+    display: flex;
+    justify-content: space-between;
+    
+    .length span {
+      margin-right: 10px;
+    }
+
+    .clear {
+      display: flex;
+      align-items: center;
+      .icon {
+        font-size: 1.4em;
+        margin-right: 5px;
+      }
+    }
+  }
+
+
+  .photo-list {
+    padding: 10px 20px 20px;
+    overflow-x: auto;
+    display: flex;
+
+    .photo {
+      cursor: pointer;
+      flex: none;
+      position: relative;
+
+      &.active {
+        outline: 2px solid var(--colors-primary-base);
+      }
+
+      .icon {
+        position: absolute;
+        right: 10px;
+        top: 10px;
+        width: 24px;
+        font-size: 12px;
+        height: 24px;
+        background-color: rgba(0,0,0,0.6);
+        color: rgba(255,255,255,.6);
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        cursor: pointer;
+        border-radius: 50%;
+      }
+
+      &:not(:last-child) {
+        margin-right: 10px;
+      }
+
+      img {
+        width: 230px;
+        height: 160px;
+        display: block;
+      }
+    }
+  }
+}
+.un-video {
+  height: 100px;
+  line-height: 100px;
+  text-align: center;
+  color: rgba(255,255,255,0.6);
+  font-size: 1.2em;
+}
+</style>
+

+ 55 - 0
src/views/guide/index.vue

@@ -0,0 +1,55 @@
+<template>
+  <RightFillPano>
+    <ui-group borderBottom>
+      <template #header>
+        <ui-button @click="currentGuide = createGuide()">
+          <ui-icon type="add" />
+          新增
+        </ui-button>
+      </template>
+    </ui-group>
+    <ui-group title="导览列表">
+      <GuideSign 
+        v-for="guide in guides" 
+        :key="guide.id" 
+        :guide="guide" 
+        @edit="edit(guide)"
+        @delete="deleteGuide(guide)"
+      />
+    </ui-group>
+  </RightFillPano>
+
+  <ui-editor-toolbar :toolbar="!!currentGuide" class="video-toolbar">
+    <EditPaths :data="currentGuide.paths" v-if="currentGuide" />
+  </ui-editor-toolbar>
+</template>
+
+<script lang="ts" setup>
+import { RightFillPano } from '@/layout'
+import { guides, Guide, createGuide, enterEdit, sysBus } from '@/store'
+import { ref } from 'vue';
+import GuideSign from './sign.vue'
+import EditPaths from './edit-paths.vue'
+
+const currentGuide = ref<Guide | null>()
+const leaveEdit = () => currentGuide.value = null
+const edit = (guide: Guide) => {
+  currentGuide.value = guide
+  enterEdit()
+  sysBus.on('leave', leaveEdit)
+}
+
+const deleteGuide = (guide: Guide) => {
+  const index = guides.value.indexOf(guide)
+  guides.value.splice(index, 1)
+}
+
+</script>
+
+<style lang="scss" scoped>
+.video-toolbar {
+  height: auto;
+  display: block;
+}
+
+</style>

+ 106 - 0
src/views/guide/sign.vue

@@ -0,0 +1,106 @@
+<template>
+  <ui-group-option class="sign-guide">
+    <div class="info">
+      <div class="guide-cover">
+        <img :src="guide.cover" />
+        <ui-icon type="preview" class="icon" ctrl />
+      </div>
+      <div>
+        <p>{{ guide.title }}</p>
+      </div>
+    </div>
+    <div class="actions">
+      <ui-more 
+        :options="menus" 
+        style="margin-left: 20px" 
+        @click="(action: keyof typeof actions) => actions[action]()" 
+      />
+    </div>
+  </ui-group-option>
+</template>
+
+<script setup lang="ts">
+import { Guide } from '@/store'
+
+
+defineProps<{ guide: Guide }>()
+const emit = defineEmits<{ 
+  (e: 'delete'): void 
+  (e: 'edit'): void 
+}>()
+
+const menus = [
+  { label: '编辑', value: 'edit' },
+  { label: '删除', value: 'delete' },
+]
+const actions = {
+  edit: () => emit('edit'),
+  delete: () => emit('delete')
+}
+
+</script>
+
+<style lang="scss" scoped>
+.sign-guide {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20px 0;
+  border-bottom: 1px solid var(--colors-border-color);
+  &:first-child {
+    border-top: 1px solid var(--colors-border-color);
+  }
+
+  .info {
+    flex: 1;
+
+    display: flex;
+    align-items: center;
+
+    .guide-cover {
+      position: relative;
+      &::after {
+        content: '';
+        position: absolute;
+        inset: 0;
+        background: rgba(0,0,0,.6)
+      }
+
+      .icon {
+        position: absolute;
+        z-index: 1;
+        left: 50%;
+        top: 50%;
+        transform: translate(-50%, -50%);
+        font-size: 16px;
+      }
+
+      img {
+        width: 48px;
+        height: 48px;
+        object-fit: cover;
+        border-radius: 4px;
+        overflow: hidden;
+        background-color: rgba(255,255,255,.6);
+        display: block;
+      }
+    }
+
+    div {
+      margin-left: 10px;
+
+      p {
+        color: #fff;
+        font-size: 14px;
+        margin-bottom: 6px;
+      }
+    }
+  }
+  
+  .actions {
+    flex: none;
+  }  
+}
+
+
+</style>

+ 19 - 2
src/views/tagging/edit.vue

@@ -2,7 +2,7 @@
   <div class="edit-hot-layer">
     <div class="edit-hot-item">
       <h3 class="edit-title">
-        热点
+        标注
         <ui-icon type="close" ctrl @click.stop="$emit('quit')" class="edit-close" />
       </h3>
       <!-- <StylesManage 
@@ -83,6 +83,12 @@
               </Images>
           </template>
       </ui-input>
+      <div class="edit-hot" >
+        <span @click="$emit('save', tagging)" class="fun-ctrl">
+          <ui-icon type="edit" />
+          确定
+        </span>
+      </div>
     </div>
   </div>
 </template>
@@ -111,7 +117,7 @@ export type ImageFile = { file: File; preview: { url: string, name: string } } |
 export type LocalTagging = Omit<Tagging, 'images'> & {images: ImageFile[]}
 
 const props = defineProps<EditProps>()
-const emit = defineEmits<{ (e: 'quit'): void }>()
+const emit = defineEmits<{ (e: 'quit'): void, (e: 'save', data: LocalTagging): void }>()
 const tagging = ref<LocalTagging>({...props.data, images: [...props.data.images]})
 
 // const styles = computed(() => 
@@ -233,6 +239,17 @@ const delImageHandler = async (file: ImageFile) => {
     background-color: rgba(255, 255, 255, 0.16);;
   }
 }
+
+.edit-hot {
+  margin-top: 20px;
+  text-align: right;
+
+  span {
+    font-size: 14px;
+    color: rgba(255, 255, 255, 0.6);
+    cursor: pointer;
+  }
+}
 </style>
 <style>
 .edit-hot-item .preplace input{

+ 33 - 5
src/views/tagging/index.vue

@@ -2,7 +2,7 @@
   <RightFillPano>
     <ui-group borderBottom>
       <template #header>
-        <ui-button>
+        <ui-button @click="currentTagging = createTagging()">
           <ui-icon type="add" />
           新增
         </ui-button>
@@ -23,20 +23,48 @@
         v-for="tagging in taggings" 
         :key="tagging.id" 
         :tagging="tagging" 
-        @edit="currentTagging = tagging"/>
+        @edit="currentTagging = tagging"
+        @delete="deleteTagging(tagging)"
+      />
     </ui-group>
   </RightFillPano>
 
-  <Edit v-if="currentTagging" :data="currentTagging" @quit="currentTagging = void 0" />
+  <Edit 
+    v-if="currentTagging" 
+    :data="currentTagging" 
+    @quit="currentTagging = null" 
+    @save="saveHandler"
+  />
 </template>
 
 <script lang="ts" setup>
 import Edit from './edit.vue'
 import { RightFillPano } from '@/layout'
-import { Tagging, taggings } from '@/store'
+import { Tagging, taggings, TemploraryID } from '@/store'
 import TagingSign from './sign.vue'
 import { ref } from 'vue';
 
-const currentTagging = ref<Tagging>()
+import type { LocalTagging } from './edit.vue'
 
+const createTagging = (): Tagging => ({
+  id: TemploraryID,
+  title: '',
+  styleId: '',
+  desc: '',
+  part: '',
+  method: '',
+  principal: '',
+  images: [],
+  positions: []
+})
+
+const currentTagging = ref<Tagging | null>(null)
+const saveHandler = (tagging: LocalTagging) => {
+  currentTagging.value = null
+}
+
+const deleteTagging = (tagging: Tagging) => {
+  const index = taggings.value.indexOf(tagging)
+  taggings.value.splice(index, 1)
+}
 </script>

+ 1 - 0
src/views/tagging/style.scss

@@ -18,6 +18,7 @@
       border-radius: 4px;
       overflow: hidden;
       background-color: rgba(255,255,255,.6);
+      display: block;
     }
 
     div {

+ 2 - 1
src/vite-env.d.ts

@@ -10,4 +10,5 @@ type ToChangeAPI<T extends Record<string, any>> = {
   [key in keyof T as `change${Capitalize<key & string>}`]: (prop: T[key]) => void
 }
 
-type ScenePos = { x: number, y: number, z: number }
+type ScenePos = { x: number, y: number, z: number }
+type ScreenPos = { x: number, y: number }