Ver código fonte

Merge branch 'master' of http://192.168.0.115:3000/bill/fuse-code

xzw 3 anos atrás
pai
commit
43c8103396

+ 13 - 1
src/api/constant.ts

@@ -15,9 +15,21 @@ export const UPLOAD_HEADS = {
 
 // 模型列表
 export const MODEL_LIST = ''
+export const INSERT_MODEL = ''
+export const UPDATE_MODEL = ''
+export const DELETE_MODEL = ''
 
 // 标注列表
 export const TAGGING_LIST = ''
+export const INSERT_TAGGING = ''
+export const UPDATE_TAGGING = ''
+export const DELETE_TAGGING = ''
 
 // 标注样式类型列表
-export const TAGGING_STYLE_LIST = ''
+export const TAGGING_STYLE_LIST = ''
+
+// 路径
+export const GUIDE_LIST = ''
+export const INSERT_GUIDE = ''
+export const UPDATE_GUIDE = ''
+export const DELETE_GUIDE = ''

+ 75 - 0
src/api/guide.ts

@@ -0,0 +1,75 @@
+import axios from './instance'
+import { 
+  GUIDE_LIST,
+  INSERT_GUIDE,
+  UPDATE_GUIDE,
+  DELETE_GUIDE,
+} 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 fetchGuides = async () => {
+  // axios.post<Guides>(GUIDE_LIST)
+  return [
+    {
+      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 postAddGuide = async (guide: Guide) => {
+  console.log('add')
+  //  axios.post<Guide>(INSERT_GUIDE, guide)
+   return guide
+}
+
+export const postUpdateGuide = async (guide: Guide) => {
+  console.log('update')
+  // return axios.post<undefined>(UPDATE_GUIDE, guide)
+  // return
+}
+
+export const postDeleteGuide = (id: Guide['id']) => {
+  console.log('delete')
+  return axios.post<undefined>(DELETE_GUIDE)
+}
+
+  

+ 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'

+ 55 - 3
src/api/model.ts

@@ -1,6 +1,9 @@
 import axios from './instance'
 import { 
-  MODEL_LIST
+  MODEL_LIST,
+  INSERT_MODEL,
+  UPDATE_MODEL,
+  DELETE_MODEL
 } from './constant'
 
 export enum ModelType {
@@ -36,5 +39,54 @@ export interface Model extends ModelAttrs {
 
 export type Models = Model[]
 
-export const getModels = () => 
-  axios.post<Models>(MODEL_LIST)
+export const fetchModels = async () => {
+  // axios.post<Models>(MODEL_LIST)
+  return [
+    {
+      id: '123',
+      url: 'SS-t-7DUfWAUZ3V',
+      type: ModelType.SWSS,
+      title: 'SS-t-7DUfWAUZ3V',
+      size: 1000,
+      time: '2012-02-05',
+      scale: 1,
+      rotation: { x: 1, y: 1, z: 1},
+      position: { x: 1, y: 1, z: 1},
+      opacity: 0.1,
+      bottom: 1,
+      show: true
+    },
+    {
+      id: '124',
+      url: '/lib/resources/models/glb/coffeemat.glb',
+      type: ModelType.SWMX,
+      title: '某安外',
+      size: 1000,
+      time: '2012-02-05',
+      scale: 1,
+      rotation: { x: 1, y: 1, z: 1},
+      position: { x: 1, y: 1, z: 1},
+      opacity: 0.1,
+      bottom: 1,
+      show: true
+    }
+  ]
+} 
+
+export const postAddModel = (model: Model) => {
+  console.log('add')
+  return axios.post<Model>(INSERT_MODEL, model)
+}
+
+export const postUpdateModels = (model: Model) => {
+  console.log('update')
+  return axios.post<undefined>(UPDATE_MODEL, model)
+}
+  
+
+export const postDeleteModel = (id: Model['id']) => {
+  console.log('delete')
+  return axios.post<undefined>(DELETE_MODEL)
+}
+
+  

+ 62 - 3
src/api/tagging.ts

@@ -1,5 +1,10 @@
 import axios from './instance'
-import { TAGGING_LIST } from './constant'
+import { 
+  TAGGING_LIST,
+  DELETE_TAGGING,
+  INSERT_TAGGING,
+  UPDATE_TAGGING
+} from './constant'
 
 export interface Tagging {
   id: string
@@ -15,5 +20,59 @@ export interface Tagging {
 
 export type Taggings = Tagging[]
 
-export const getTaggings = () => 
-  axios.post<Taggings>(TAGGING_LIST)
+export const fetchTaggings = async () => {
+  // axios.post<Taggings>(TAGGING_LIST)
+  return [
+    {
+      id: '1231',
+      title: 'aaaa',
+      styleId: '1231',
+      desc: '123123',
+      part: '123asd',
+      method: '123123a',
+      principal: 'asdasd',
+      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: '1231a',
+      title: 'aaaa',
+      styleId: '1231',
+      desc: '123123',
+      part: '123asd',
+      method: '123123a',
+      principal: 'asdasd',
+      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 }
+      ]
+    }
+  ]
+}
+
+export const postAddTagging = (tagging: Tagging) => {
+  console.log('add')
+  return axios.post<Tagging>(INSERT_TAGGING, tagging)
+}
+
+export const postUpdateTagging = async (tagging: Tagging) => {
+  console.log('update')
+  // return axios.post<undefined>(UPDATE_TAGGING, tagging)
+}
+  
+
+export const postDeleteTagging = (id: Tagging['id']) => {
+  console.log('delete')
+  return axios.post<undefined>(DELETE_TAGGING)
+}
+
+  

+ 0 - 1
src/app.vue

@@ -3,7 +3,6 @@
 </template>
 
 <script lang="ts" setup>
-import MainCom from '@/layout/main.vue'
 import { computed } from 'vue'
 import { loaded, error, initialStore } from '@/store'
 import { loadComponent, loadPack } from '@/utils'

+ 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%;
+      }
+    }
+  }
+}
+

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

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

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

@@ -0,0 +1,126 @@
+<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.styleId)
+
+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>

+ 5 - 1
src/env/index.ts

@@ -6,12 +6,16 @@ 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 showTaggingsStack = stackFactory(ref<boolean>(true))
 
 export const custom = flatStacksValue({
   viewMode: viewModeStack,
   showToolbar: showToolbarStack,
   showRightPano: showRightPanoStack,
   showLeftPano: showLeftPanoStack,
-  showLeftCtrlPano: showLeftCtrlPanoStack
+  showLeftCtrlPano: showLeftCtrlPanoStack,
+  shwoRightCtrlPano: showRightCtrlPanoStack,
+  showTaggings: showTaggingsStack
 })
 

+ 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)
 }

+ 18 - 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
 }
   
 
@@ -63,6 +79,7 @@ export const initialSDK = async (props: InialSDKProps) => {
   await loadLib(`/lib/potree/potree.js`)
 
   sdk = cover(props.layout) as unknown as SDK
+  sdk.layout = props.layout
   setup(sdk, presetViewElement(props.layout))
 }
 

+ 71 - 0
src/store/guide.ts

@@ -0,0 +1,71 @@
+import { ref } from 'vue'
+import { TemploraryID } from './sys'
+import { autoSetModeCallback } from './sys'
+import { 
+  fetchGuides, 
+  postAddGuide, 
+  postDeleteGuide,
+  postUpdateGuide
+} from '@/api'
+import { 
+  deleteStoreItem, 
+  addStoreItem, 
+  updateStoreItem, 
+  fetchStoreItems,
+  saveStoreItems,
+  recoverStoreItems
+} from '@/utils'
+
+import type { GuidePath, Guide, Guides } from '@/api'
+
+export const guides = ref<Guides>([])
+
+export const createGuide = (guide: Partial<Guide> = {}): Guide => ({
+  id: TemploraryID,
+  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
+})
+
+
+let bcGuides: Guides = []
+export const getBackupGuides = () => bcGuides
+export const backupGuides = () => {
+  bcGuides = guides.value.map(guide => ({
+    ...guide,
+    paths: guide.paths.map(path => ({...path}))
+  }))
+}
+
+export const recoverGuides = recoverStoreItems(guides, getBackupGuides)
+export const addGuide = addStoreItem(guides, postAddGuide)
+export const updateGuide = updateStoreItem(guides, postUpdateGuide)
+export const deleteGuide = deleteStoreItem(guides, guide => postDeleteGuide(guide.id))
+export const initialGuides = fetchStoreItems(guides, fetchGuides, backupGuides)
+export const saveGuides = saveStoreItems(
+  guides,
+  getBackupGuides,
+  {
+    add: addGuide,
+    update: updateGuide,
+    delete: deleteGuide,
+  }
+)
+export const autoSaveGuides = autoSetModeCallback(guides, {
+  backup: getBackupGuides,
+  recovery: recoverGuides,
+  save: saveGuides,
+})
+
+export type { Guide, Guides, GuidePath, GuidePaths } from '@/api'

+ 7 - 7
src/store/index.ts

@@ -1,21 +1,20 @@
+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([
+  await Promise.all([
     initialModels(),
     initialTaggingStyles(),
-    initialTaggings()
+    initialTaggings(),
+    initialGuides()
   ])
-
   try {
-    await init
     loaded.value = true
   } catch {
     error.value = true
@@ -26,4 +25,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'

+ 43 - 35
src/store/model.ts

@@ -1,46 +1,54 @@
-import { ref } from 'vue'
-import { getModels, ModelType } from '@/api'
+import { ref, watchEffect } from 'vue'
+import { autoSetModeCallback } from './sys'
+import { 
+  fetchModels, 
+  postAddModel, 
+  postDeleteModel,
+  postUpdateModels
+} from '@/api'
+import { 
+  deleteStoreItem, 
+  addStoreItem, 
+  updateStoreItem, 
+  fetchStoreItems,
+  saveStoreItems,
+  recoverStoreItems
+} from '@/utils'
 
 import type { Models, Model } from '@/api'
 
 export const currentModel = ref<Model | null>(null)
 export const models = ref<Models>([])
 
-
-export const initialModels = async () => {
-  // models.value = await getModels()
-  models.value = [
-    {
-      id: '123',
-      url: 'SS-t-7DUfWAUZ3V',
-      type: ModelType.SWSS,
-      title: 'SS-t-7DUfWAUZ3V',
-      size: 1000,
-      time: '2012-02-05',
-      scale: 1,
-      rotation: { x: 1, y: 1, z: 1},
-      position: { x: 1, y: 1, z: 1},
-      opacity: 0.1,
-      bottom: 1,
-      show: true
-    },
-    {
-      id: '124',
-      url: '/lib/resources/models/glb/coffeemat.glb',
-      type: ModelType.SWMX,
-      title: '某安外',
-      size: 1000,
-      time: '2012-02-05',
-      scale: 1,
-      rotation: { x: 1, y: 1, z: 1},
-      position: { x: 1, y: 1, z: 1},
-      opacity: 0.1,
-      bottom: 1,
-      show: true
-    }
-  ]
+let bcModels: Models = []
+export const getBackupModels = () => bcModels
+export const backupModels = () => {
+  bcModels = models.value.map(model => ({
+    ...model,
+    rotation: {...model.rotation},
+    position: {...model.position},
+  }))
 }
 
+export const recoverModels = () => recoverStoreItems(models, getBackupModels)
+export const addModel = addStoreItem(models, postAddModel)
+export const updateModel = updateStoreItem(models, postUpdateModels)
+export const deleteModel = deleteStoreItem(models, model => postDeleteModel(model.id))
+export const initialModels = fetchStoreItems(models, fetchModels, backupModels)
+export const saveModels = saveStoreItems(
+  models,
+  getBackupModels,
+  {
+    add: addModel,
+    update: updateModel,
+    delete: deleteModel,
+  }
+)
+export const autoSaveModels = autoSetModeCallback(models, {
+  backup: backupModels,
+  recovery: recoverModels,
+  save: saveModels,
+})
 
 export { ModelType, ModelTypeDesc } from '@/api'
 export type { Model, Models } from '@/api'

+ 10 - 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,21 +33,22 @@ 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()
+  leave()
 }
 
 // 离开
@@ -54,7 +56,7 @@ export const leave = async () => {
   if (isOld.value && !(await Dialog.confirm('您有操作未保存,确定要退出吗?'))) {
     return;
   }
-  await bus.emit('leave')
+  await sysBus.emit('leave')
   giveupLeave()
 }
 
@@ -78,6 +80,7 @@ export const autoSetModeCallback = <T extends object>(current: T, setting: AutoS
     })
   
   const saveCallback = async () => {
+    leaveCallback && sysBus.off('leave', leaveCallback, { last: true })
     isSave = true
     await setting.save()
     setting.backup && setting.backup()
@@ -89,9 +92,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, { last: true })
     }
-    leaveCallback && bus.on('leave', leaveCallback, { last: true })
+    leaveCallback && sysBus.on('leave', leaveCallback, { last: true })
   }
 
   return () => {

+ 60 - 46
src/store/tagging.ts

@@ -1,56 +1,70 @@
 import { ref } from 'vue'
-import { getTaggings } from '@/api'
+import { autoSetModeCallback, TemploraryID } from './sys'
+import { 
+  fetchTaggings, 
+  postAddTagging,
+  postDeleteTagging,
+  postUpdateTagging 
+} from '@/api'
+import { 
+  deleteStoreItem, 
+  addStoreItem, 
+  updateStoreItem, 
+  fetchStoreItems,
+  saveStoreItems,
+  recoverStoreItems
+} from '@/utils'
 
-import type { Taggings } from '@/api'
+
+import type { Taggings, Tagging } from '@/api'
 
 export const taggings = ref<Taggings>([])
 
-export const initialTaggings = async () => {
-  // taggings.value = await getTaggings()
-  taggings.value = [
-    {
-      id: '1231',
-      title: 'aaaa',
-      styleId: '1231',
-      desc: '123123',
-      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: '12321',
-      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'],
-      positions: [
-        { x: 1, y: 1, z: 1 }
-      ]
-    }
-  ]
-}
+export const createTagging = (tagging: Partial<Tagging> = {}): Tagging => ({
+  id: TemploraryID,
+  title: '',
+  styleId: '',
+  desc: '',
+  part: '',
+  method: '',
+  principal: '',
+  images: [],
+  positions: [],
+  ...tagging
+})
 
 
+let bcTaggings: Taggings = []
+export const getBackupTaggings = () => bcTaggings
+export const backupTaggings = () => {
+  bcTaggings = taggings.value.map(tagging => ({
+    ...tagging,
+    images: [...tagging.images],
+    positions: [...tagging.positions]
+  }))
+}
 
+export const recoverTaggings = recoverStoreItems(taggings, () => bcTaggings)
+export const addTagging = addStoreItem(taggings, postAddTagging)
+export const updateTagging = updateStoreItem(taggings, (newTagging, oldTagging) => {
+  console.log(newTagging, oldTagging)
+  return postUpdateTagging(newTagging)
+})
+export const deleteTagging = deleteStoreItem(taggings, tagging => postDeleteTagging(tagging.id))
+export const initialTaggings = fetchStoreItems(taggings, fetchTaggings, backupTaggings)
+export const saveTaggings = saveStoreItems(
+  taggings, 
+  getBackupTaggings,
+  {
+    add: addTagging,
+    update: updateTagging,
+    delete: deleteTagging,
+  }
+)
+export const autoSaveTaggings = autoSetModeCallback(taggings, {
+  backup: backupTaggings,
+  recovery: recoverTaggings,
+  save: saveTaggings,
+})
 
 export type { Taggings, Tagging } from '@/api'

+ 11 - 0
src/utils/basic.ts

@@ -0,0 +1,11 @@
+
+export const objectToString = Object.prototype.toString
+export const toTypeString = (value: unknown): string => objectToString.call(value)
+
+export const toRawType = (value: unknown): string => {
+  // extract "RawType" from strings like "[object RawType]"
+  return toTypeString(value).slice(8, -1)
+}
+
+
+export const isString = <T>(value: T): T extends string ? true : false => (toRawType(value) === 'String') as any

+ 81 - 8
src/utils/diff.ts

@@ -1,22 +1,95 @@
+import { toRawType } from "./basic";
 
-export const diffArrayChange = <T extends Array<any>>(newItems: T, oldItems: T) => {
-  const addedItems = [] as unknown as T
-  const deletedItems = [] as unknown as T
+// 是否修改
+const _deepIsRevise = (
+  raw1: any,
+  raw2: any,
+  readly: Set<[any, any]>
+): boolean => {
+  if (raw1 === raw2) return false;
+
+  const rawType1 = toRawType(raw1);
+  const rawType2 = toRawType(raw2);
+
+  if (rawType1 !== rawType2) {
+    console.log('===', rawType1, rawType2)
+    return true;
+  } else if (
+    rawType1 === "String" ||
+    rawType1 === "Number" ||
+    rawType1 === "Boolean"
+  ) {
+    if (rawType1 === "Number" && isNaN(raw1) && isNaN(raw2)) {
+      return false;
+    } else {
+      return raw1 !== raw2;
+    }
+  }
+
+  const rawsArray = Array.from(readly.values());
+  for (const raws of rawsArray) {
+    if (raws.includes(raw1) && raws.includes(raw2)) {
+      return false;
+    }
+  }
+  readly.add([raw1, raw2]);
+
+  if (rawType1 === "Array") {
+    return (
+      raw1.length !== raw2.length ||
+      raw1.some((item1: any, i: number) =>
+        _deepIsRevise(item1, raw2[i], readly)
+      )
+    );
+  } else if (rawType1 === "Object") {
+    const rawKeys1 = Object.keys(raw1).sort();
+    const rawKeys2 = Object.keys(raw2).sort();
+
+    return (
+      _deepIsRevise(rawKeys1, rawKeys2, readly) ||
+      rawKeys1.some((key) => _deepIsRevise(raw1[key], raw2[key], readly))
+    );
+  } else if (rawType1 === "Map") {
+    const rawKeys1 = Array.from(raw1.keys()).sort();
+    const rawKeys2 = Array.from(raw2.keys()).sort();
+
+    return (
+      _deepIsRevise(rawKeys1, rawKeys2, readly) ||
+      rawKeys1.some((key) =>
+        _deepIsRevise(raw1.get(key), raw2.get(key), readly)
+      )
+    );
+  } else if (rawType1 === "Set") {
+    return deepIsRevise(Array.from(raw1.values()), Array.from(raw2.values()));
+  } else {
+    return raw1 !== raw2;
+  }
+};
+
+export const deepIsRevise = (raw1: any, raw2: any) =>
+  _deepIsRevise(raw1, raw2, new Set());
+
+export const diffArrayChange = <T extends Array<any>>(
+  newItems: T,
+  oldItems: T
+) => {
+  const addedItems = [] as unknown as T;
+  const deletedItems = [] as unknown as T;
 
   for (const item of newItems) {
     if (!oldItems.includes(item)) {
-      addedItems.push(item)
+      addedItems.push(item);
     }
   }
 
   for (const item of oldItems) {
     if (!newItems.includes(item)) {
-      deletedItems.push(item)
+      deletedItems.push(item);
     }
   }
 
   return {
     added: addedItems,
-    deleted: deletedItems
-  }
-}
+    deleted: deletedItems,
+  };
+};

+ 6 - 0
src/utils/index.ts

@@ -31,7 +31,13 @@ export const loadLib = (() => {
   };
 })();
 
+export const togetherCallback = (cbs: (() => void)[]) => () => together(cbs)
 
+export const together = (cbs: (() => void)[]) => {
+  cbs.forEach(cb => cb())
+}
+
+export * from './store-help'
 export * from "./stack";
 export * from "./loading";
 export * from "./route";

+ 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)

+ 121 - 0
src/utils/store-help.ts

@@ -0,0 +1,121 @@
+import { Ref, toRaw } from 'vue'
+import { deepIsRevise } from './diff'
+
+export const storeSecurityPush = <T extends any>(items: T[], pushItem: T) => {
+  const index = items.indexOf(pushItem)
+  if (!~index) {
+    items.push(pushItem)
+    return true
+  } else {
+    return false
+  }
+} 
+export const storeSecurityDelete = <T extends any>(items: T[], pushItem: T) => {
+  const index = items.indexOf(pushItem)
+  if (~index) {
+    items.splice(index, 1)
+    return true
+  } else {
+    return false
+  }
+} 
+
+export const addStoreItem = <T extends {id: any}>(items: Ref<T[]>, addAction: (item: T) => Promise<T>) => {
+  return async (item: T) => {
+    const newItem = await addAction(item)
+    item.id = newItem.id
+    storeSecurityPush(items.value, item)
+  }
+}
+
+export const updateStoreItem = <T extends {id: any}>(items: Ref<T[]>, updateAction: (item: T, oldItem: T) => Promise<any>) => {
+  return async (item: T, oldItem: T) => {
+    await updateAction(item, oldItem)
+    const storeItem = items.value.find(atom => atom.id === item.id)
+    if (storeItem) {
+      Object.assign(storeItem, item)
+    }
+  }
+}
+
+
+export const deleteStoreItem = <T extends {id: any}>(items: Ref<T[]>, deleteAction: (item: T) => Promise<any>) => {
+  return async (item: T) => {
+    await deleteAction(item)
+    storeSecurityDelete(items.value, item)
+  }
+}
+
+export const fetchStoreItems = <T extends {id: any}>(items: Ref<T[]>, fetchAction: () => Promise<T[]>, callback?: () => void) => {
+  return async () => {
+    const fetchItems = await fetchAction()
+    items.value = fetchItems
+    callback && callback()
+  }
+}
+
+export const saveStoreItems = <T extends {id: any}>(
+  newItems: Ref<T[]>,
+  getOldItem: () => T[],
+  actions: {
+    add: ReturnType<typeof addStoreItem<T>>,
+    update: ReturnType<typeof updateStoreItem<T>>,
+    delete: ReturnType<typeof deleteStoreItem<T>>,
+  }
+) => () => {
+  const oldItems = getOldItem()
+  const {
+    deleted,
+    updated,
+    added
+  } = diffStoreItemsChange(newItems.value, oldItems)
+
+  const promiseAll: Promise<any>[] = []
+  
+  for (const delItem of deleted) {
+    promiseAll.push(actions.delete(delItem))
+  }
+  for (const [newItem, oldItem] of updated) {
+    promiseAll.push(actions.update(newItem, oldItem))
+  }
+  for (const addItem of added) {
+    promiseAll.push(actions.add(addItem))
+  }
+  return Promise.all(promiseAll)
+}
+
+export const diffStoreItemsChange = <T extends Array<{ id: any }>>(newItems: T, oldItems: T) => {
+  const addedItems = [] as unknown as T
+  const deletedItems = [] as unknown as T
+  const updateItems = [] as unknown as [T[number], T[number]][]
+  newItems = toRaw(newItems)
+  for (const newItem of newItems) {
+    const oldItem = oldItems.find(oldItem => newItem.id === oldItem.id)
+    if (!oldItem) {
+      storeSecurityPush(addedItems, newItem)
+    } else if (deepIsRevise(oldItem, newItem)) {
+      storeSecurityPush(updateItems, [newItem, oldItem] as any)
+    }
+  }
+
+  for (const oldItem of oldItems) {
+    const newItem = newItems.find(newItem => newItem.id === oldItem.id)
+    if (!newItem) {
+      storeSecurityPush(deletedItems, oldItem)
+    }
+  }
+
+  return {
+    added: addedItems,
+    deleted: deletedItems,
+    updated: updateItems
+  }
+}
+
+export const recoverStoreItems = <T extends Array<{ id: any }>>(items: Ref<T>, getBackupItems: () => T) => () => {
+  const backupItems = getBackupItems()
+  items.value = backupItems.map(oldItem => {
+    const model = items.value.find(item => item.id === oldItem.id)
+    return model ? Object.assign(model, oldItem) : oldItem
+  }) as T
+}

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

@@ -0,0 +1,265 @@
+<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, TemploraryID, useAutoSetMode, guides, enterOld } 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 { Guide, GuidePaths, GuidePath } from '@/store'
+
+type LocalPath = GuidePath & { blob?: Blob }
+type LocalPaths = LocalPath[]
+
+const props = defineProps< { data: Guide }>()
+const paths = ref<LocalPaths>([...props.data.paths])
+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() {
+    props.data.paths = paths.value
+    if (props.data.id === TemploraryID) {
+      guides.value.push(props.data)
+    }
+  }
+})
+
+setTimeout(() => {
+  paths.value = [{
+          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
+        }]
+}, 1000)
+
+
+
+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>
+

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

@@ -0,0 +1,57 @@
+<template>
+  <RightFillPano>
+    <ui-group borderBottom>
+      <template #header>
+        <ui-button @click="edit(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" v-if="currentGuide" />
+  </ui-editor-toolbar>
+</template>
+
+<script lang="ts" setup>
+import { RightFillPano } from '@/layout'
+import { guides, Guide, createGuide, enterEdit, sysBus, autoSaveGuides } from '@/store'
+import { ref } from 'vue';
+import GuideSign from './sign.vue'
+import EditPaths from './edit-paths.vue'
+import { useViewStack } from '@/hook'
+
+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)
+}
+
+useViewStack(autoSaveGuides)
+</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>

+ 2 - 1
src/views/merge/index.vue

@@ -28,7 +28,7 @@
 
 <script lang="ts" setup>
 import { RightPano } from '@/layout'
-import { currentModel } from '@/store'
+import { currentModel, autoSaveModels } from '@/store'
 import Actions from '@/components/actions/index.vue'
 import { getSceneModel } from '@/sdk'
 import { useViewStack } from '@/hook'
@@ -68,5 +68,6 @@ useViewStack(() => {
     pops.forEach(pop => pop())
   }
 })
+useViewStack(autoSaveModels)
 
 </script>

+ 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{

+ 62 - 7
src/views/tagging/index.vue

@@ -2,7 +2,7 @@
   <RightFillPano>
     <ui-group borderBottom>
       <template #header>
-        <ui-button>
+        <ui-button @click="editTagging = createTagging()">
           <ui-icon type="add" />
           新增
         </ui-button>
@@ -10,7 +10,11 @@
     </ui-group>
     <ui-group title="标注">
       <template #icon>
-        <ui-icon type="eye-s" />
+        <ui-icon 
+          ctrl
+          :type="custom.showTaggings ? 'eye-s' : 'eye-n'" 
+          @click="custom.showTaggings = !custom.showTaggings" 
+        />
       </template>
       <ui-group-option>
         <ui-input type="text" width="100%" placeholder="搜索">
@@ -23,20 +27,71 @@
         v-for="tagging in taggings" 
         :key="tagging.id" 
         :tagging="tagging" 
-        @edit="currentTagging = tagging"/>
+        :selected="selectTagging === tagging"
+        @edit="editTagging = tagging"
+        @delete="deleteTagging(tagging)"
+        @select="selectTagging = tagging"
+      />
     </ui-group>
   </RightFillPano>
 
-  <Edit v-if="currentTagging" :data="currentTagging" @quit="currentTagging = void 0" />
+  <Edit 
+    v-if="editTagging" 
+    :data="editTagging" 
+    @quit="editTagging = null" 
+    @save="saveHandler"
+  />
 </template>
 
 <script lang="ts" setup>
 import Edit from './edit.vue'
+import { Message } from 'bill/index'
 import { RightFillPano } from '@/layout'
-import { Tagging, taggings } from '@/store'
+import { useViewStack } from '@/hook'
+import { taggings, TemploraryID, Tagging, autoSaveTaggings, createTagging } from '@/store'
 import TagingSign from './sign.vue'
-import { ref } from 'vue';
+import { ref, watchEffect } from 'vue';
+import { custom } from '@/env'
+import { sdk } from '@/sdk'
 
-const currentTagging = ref<Tagging>()
+import type { LocalTagging } from './edit.vue'
 
+const editTagging = ref<Tagging | null>(null)
+const saveHandler = (tagging: LocalTagging) => {
+  if (!editTagging.value) return;
+  if (editTagging.value.id === TemploraryID) {
+    // taggings.value.push(tagging)
+  } else {
+    Object.assign(editTagging.value, tagging)
+  }
+  editTagging.value = null
+}
+
+const deleteTagging = (tagging: Tagging) => {
+  const index = taggings.value.indexOf(tagging)
+  taggings.value.splice(index, 1)
+}
+
+
+const selectTagging = ref<Tagging | null>(null)
+watchEffect(() => {
+  if (selectTagging.value) {
+    const handler = (ev: MouseEvent) => {
+      const position = sdk.getPositionByScreen({
+        x: ev.clientX,
+        y: ev.clientY
+      })
+      
+      if (!position) {
+        Message.error('当前位置无法添加')
+      } else {
+        selectTagging.value?.positions.push(position)
+      }
+    }
+    sdk.layout.addEventListener('click', handler, false)
+    return () => sdk.layout.removeEventListener('click', handler, false)
+  }
+})
+
+useViewStack(autoSaveTaggings)
 </script>

+ 6 - 4
src/views/tagging/sign.vue

@@ -1,5 +1,5 @@
 <template>
-  <ui-group-option class="sign-tagging">
+  <ui-group-option class="sign-tagging" :class="{active: selected}" @click="emit('select')">
     <div class="info">
       <img :src="tagging.images[0]">
       <div>
@@ -7,7 +7,7 @@
         <a>放置:{{ tagging.positions.length }}</a>
       </div>
     </div>
-    <div class="actions">
+    <div class="actions" @click.stop>
       <ui-icon type="pin" ctrl />
       <ui-more 
         :options="menus" 
@@ -22,10 +22,12 @@
 import { Tagging } from '@/store'
 
 
-defineProps<{ tagging: Tagging }>()
+defineProps<{ tagging: Tagging, selected?: boolean }>()
+
 const emit = defineEmits<{ 
   (e: 'delete'): void 
-  (e: 'edit'): void 
+  (e: 'edit'): void
+  (e: 'select'): void
 }>()
 
 const menus = [

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

@@ -3,7 +3,18 @@
   justify-content: space-between;
   align-items: center;
   padding: 20px 0;
+  margin: 0;
   border-bottom: 1px solid var(--colors-border-color);
+  cursor: pointer;
+  position: relative;
+
+  &.active::after {
+    content: '';
+    position: absolute;
+    pointer-events: none;
+    inset: 0 -20px;
+    background-color: rgba(0, 200, 175, 0.16);
+  }
 
   .info {
     flex: 1;
@@ -18,6 +29,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 }