Bladeren bron

对接初始画面,测量,屏幕录制接口

bill 3 jaren geleden
bovenliggende
commit
2d745d2b6f
75 gewijzigde bestanden met toevoegingen van 2645 en 327 verwijderingen
  1. 2 0
      package.json
  2. 38 0
      pnpm-lock.yaml
  3. 22 0
      src/api/constant.ts
  4. 1 1
      src/api/fuse-model.ts
  5. 4 0
      src/api/index.ts
  6. 69 0
      src/api/mesasure.ts
  7. 45 0
      src/api/record-fragment.ts
  8. 75 0
      src/api/record.ts
  9. 3 2
      src/api/sys.ts
  10. 2 1
      src/api/tagging-position.ts
  11. 74 0
      src/api/view.ts
  12. 16 22
      src/app.vue
  13. 3 0
      src/components/bill-ui/assets/scss/_base-vars.scss
  14. 16 6
      src/components/bill-ui/assets/scss/components/_input.scss
  15. 3 3
      src/components/bill-ui/assets/scss/editor/_toolbar.scss
  16. 72 3
      src/components/bill-ui/components/icon/iconfont/demo_index.html
  17. 15 3
      src/components/bill-ui/components/icon/iconfont/iconfont.css
  18. 1 1
      src/components/bill-ui/components/icon/iconfont/iconfont.js
  19. 21 0
      src/components/bill-ui/components/icon/iconfont/iconfont.json
  20. BIN
      src/components/bill-ui/components/icon/iconfont/iconfont.ttf
  21. BIN
      src/components/bill-ui/components/icon/iconfont/iconfont.woff
  22. BIN
      src/components/bill-ui/components/icon/iconfont/iconfont.woff2
  23. 6 0
      src/components/bill-ui/components/input/index.vue
  24. 63 0
      src/components/bill-ui/components/input/multiple.vue
  25. 17 8
      src/components/bill-ui/components/input/select.vue
  26. 9 0
      src/components/bill-ui/components/input/state.js
  27. 9 5
      src/components/list/index.vue
  28. 7 1
      src/env/index.ts
  29. 53 0
      src/layout/edit/fuse-edit.vue
  30. 72 0
      src/layout/edit/fuse-left-pano.vue
  31. 0 0
      src/layout/edit/fuse-slide-menu.vue
  32. 2 2
      src/layout/switch.vue
  33. 0 2
      src/layout/header/index.vue
  34. 0 0
      src/layout/edit/header/style.scss
  35. 37 0
      src/layout/edit/scene-edit.vue
  36. 0 0
      src/layout/edit/scene-select.vue
  37. 2 2
      src/layout/left-pano.vue
  38. 31 33
      src/layout/main.vue
  39. 29 38
      src/layout/model-list/index.vue
  40. 6 6
      src/layout/model-list/sign.vue
  41. 83 0
      src/layout/model/index.vue
  42. 1 1
      src/layout/right-fill-pano.vue
  43. 77 0
      src/layout/scene-list/index.vue
  44. 58 29
      src/router/config.ts
  45. 21 9
      src/router/constant.ts
  46. 1 1
      src/sdk/sdk.ts
  47. 1 0
      src/store/fuse-model.ts
  48. 3 29
      src/store/index.ts
  49. 48 19
      src/store/measure.ts
  50. 52 0
      src/store/record-fragment.ts
  51. 111 0
      src/store/record.ts
  52. 1 0
      src/store/sys.ts
  53. 1 0
      src/store/tagging.ts
  54. 67 0
      src/store/view.ts
  55. 168 0
      src/utils/file-serve.ts
  56. 3 1
      src/utils/index.ts
  57. 0 1
      src/utils/store-help.ts
  58. 48 0
      src/utils/video-cover.ts
  59. 2 2
      src/views/guide/edit-paths.vue
  60. 0 1
      src/views/proportion/index.vue
  61. 3 0
      src/views/record/help.ts
  62. 86 0
      src/views/record/index.vue
  63. 190 0
      src/views/record/shot.vue
  64. 137 0
      src/views/record/sign.vue
  65. 205 0
      src/views/record/style.scss
  66. 105 0
      src/views/tagging-position/index.vue
  67. 47 0
      src/views/tagging-position/sign.vue
  68. 46 0
      src/views/tagging-position/style.scss
  69. 24 85
      src/views/tagging/index.vue
  70. 4 9
      src/views/tagging/sign.vue
  71. 4 1
      src/views/tagging/style.scss
  72. 45 0
      src/views/view/index.vue
  73. 81 0
      src/views/view/sign.vue
  74. 79 0
      src/views/view/style.scss
  75. 18 0
      vite.config.ts

+ 2 - 0
package.json

@@ -9,11 +9,13 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@simaq/core": "^1.1.0",
     "ant-design-vue": "^3.3.0-beta.3",
     "axios": "^0.27.2",
     "mitt": "^3.0.0",
     "vue": "^3.2.37",
     "vue-cropper": "1.0.2",
+    "vuedraggable": "^4.1.0",
     "vue-router": "^4.1.3"
   },
   "devDependencies": {

+ 38 - 0
pnpm-lock.yaml

@@ -1,6 +1,7 @@
 lockfileVersion: 5.3
 
 specifiers:
+  '@simaq/core': ^1.1.0
   '@types/node': ^18.6.5
   '@vitejs/plugin-vue': ^3.0.0
   ant-design-vue: ^3.3.0-beta.3
@@ -13,14 +14,17 @@ specifiers:
   vue-cropper: 1.0.2
   vue-router: ^4.1.3
   vue-tsc: ^0.38.4
+  vuedraggable: ^4.1.0
 
 dependencies:
+  '@simaq/core': 1.1.0
   ant-design-vue: 3.3.0-beta.3_vue@3.2.37
   axios: 0.27.2
   mitt: 3.0.0
   vue: 3.2.37
   vue-cropper: 1.0.2
   vue-router: 4.1.3_vue@3.2.37
+  vuedraggable: 4.1.0_vue@3.2.37
 
 devDependencies:
   '@types/node': 18.6.5
@@ -80,6 +84,13 @@ packages:
     dev: true
     optional: true
 
+  /@simaq/core/1.1.0:
+    resolution: {integrity: sha512-GzeaqGJv08eaaTDRpiTGpCu4jL3ZK8YcyQ9LxpsIOw8FqKd5V8ISsKivdiu1ti4Z6oWYYZSK3OLZ4RG7b57FqQ==}
+    dependencies:
+      eventemitter3: 4.0.7
+      rxjs: 7.5.6
+    dev: false
+
   /@simonwep/pickr/1.8.2:
     resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==}
     dependencies:
@@ -543,6 +554,10 @@ packages:
   /estree-walker/2.0.2:
     resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
 
+  /eventemitter3/4.0.7:
+    resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+    dev: false
+
   /fill-range/7.0.1:
     resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
     engines: {node: '>=8'}
@@ -740,6 +755,12 @@ packages:
       fsevents: 2.3.2
     dev: true
 
+  /rxjs/7.5.6:
+    resolution: {integrity: sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==}
+    dependencies:
+      tslib: 2.4.0
+    dev: false
+
   /sass/1.54.3:
     resolution: {integrity: sha512-fLodey5Qd41Pxp/Tk7Al97sViYwF/TazRc5t6E65O7JOk4XF8pzwIW7CvCxYVOfJFFI/1x5+elDyBIixrp+zrw==}
     engines: {node: '>=12.0.0'}
@@ -760,6 +781,10 @@ packages:
     resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
     dev: false
 
+  /sortablejs/1.14.0:
+    resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
+    dev: false
+
   /source-map-js/1.0.2:
     resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
     engines: {node: '>=0.10.0'}
@@ -783,6 +808,10 @@ packages:
       is-number: 7.0.0
     dev: true
 
+  /tslib/2.4.0:
+    resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
+    dev: false
+
   /typescript/4.7.4:
     resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==}
     engines: {node: '>=4.2.0'}
@@ -860,6 +889,15 @@ packages:
       '@vue/shared': 3.2.37
     dev: false
 
+  /vuedraggable/4.1.0_vue@3.2.37:
+    resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
+    peerDependencies:
+      vue: ^3.0.1
+    dependencies:
+      sortablejs: 1.14.0
+      vue: 3.2.37
+    dev: false
+
   /warning/4.0.3:
     resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
     dependencies:

+ 22 - 0
src/api/constant.ts

@@ -38,6 +38,12 @@ export const TAGGING_STYLE_LIST = '/fusion/edit/hotIcon/list'
 export const INSERT_TAGGING_STYLE = '/fusion/edit/hotIcon/add'
 export const DELETE_TAGGING_STYLE = '/fusion/edit/hotIcon/delete'
 
+// 测量线
+export const MESASURE_LIST = `/fusion/fusionMeter/allList`
+export const INSERT_MESASURE = `/fusion/fusionMeter/add`
+export const UPDATE_MESASURE = `/fusion/fusionMeter/updateMeterTitle`
+export const DELETE_MESASURE = `/fusion/fusionMeter/delete`
+
 // 导览
 export const GUIDE_LIST = `/fusion/fusionGuide/allList`
 export const INSERT_GUIDE = `/fusion/fusionGuide/add`
@@ -50,6 +56,22 @@ export const INSERT_GUIDE_PATH = `/fusion/fusionGuidePath/add`
 export const UPDATE_GUIDE_PATH = `/fusion/fusionGuidePath/update`
 export const DELETE_GUIDE_PATH = `/fusion/fusionGuidePath/delete`
 
+// 屏幕录制
+export const RECORD_LIST = `/fusion/caseVideoFolder/allList`
+export const INSERT_RECORD = `/fusion/caseVideo/add`
+export const MERGE_RECORD = `/fusion/caseVideo/add`
+export const UPDATE_RECORD = `/fusion/caseVideoFolder/updateNameOrSort`
+export const DELETE_RECORD = `/fusion/caseVideoFolder/delete`
+
+// 录制片段
+export const RECORD_FRAGMENT_LIST = `/fusion/caseVideo/allList`
+export const DELETE_RECORD_FRAGMENT = `/fusion/caseVideo/delete`
+
+// 视图提取
+export const VIEW_LIST = `/fusion/caseView/allList`
+export const INSERT_VIEW = `/fusion/caseView/add`
+export const UPDATE_VIEW = `/fusion/caseView/updateNameOrSort`
+export const DELETE_VIEW = `/fusion/caseView/delete`
 
 // 文件上传
 export const UPLOAD_FILE = `/fusion/upload/file`

+ 1 - 1
src/api/fuse-model.ts

@@ -25,7 +25,7 @@ export interface FuseModel extends FuseModelAttrs {
   fusionNumId: number,
   url: string
   title: string
-  fusionId?: number,
+  fusionId: number,
   type: SceneType
   size: number,
   time: string

+ 4 - 0
src/api/index.ts

@@ -28,3 +28,7 @@ export * from './tagging-position'
 export * from './guide'
 export * from './guide-path'
 export * from './sys'
+export * from './mesasure'
+export * from './record'
+export * from './record-fragment'
+export * from './view'

+ 69 - 0
src/api/mesasure.ts

@@ -0,0 +1,69 @@
+import axios from './instance'
+import { MESASURE_LIST, INSERT_MESASURE, DELETE_MESASURE, UPDATE_MESASURE } from './constant'
+
+export enum MeasureType {
+  free = 0,
+  vertical,
+  area
+}
+
+export interface Measure {
+  id: string,
+  fusionId: number,
+  desc: string,
+  title: string,
+  positions: SceneLocalPos[],
+  type: MeasureType,
+}
+
+interface ServiceMeasure {
+  fusionMeterId: number,
+  fusionId: number,
+  meterTitle: string,
+  meterType: MeasureType,
+  position: string,
+  length: number,
+}
+
+const toLocal = (serviceMeasure: ServiceMeasure) : Measure => ({
+  id: serviceMeasure.fusionMeterId.toString(),
+  fusionId: serviceMeasure.fusionId,
+  title: serviceMeasure.meterTitle,
+  desc: serviceMeasure.length.toString(),
+  positions: JSON.parse(serviceMeasure.position),
+  type: serviceMeasure.meterType,
+})
+
+const toService = (measure: Measure, isUpdate = true): PartialProps<ServiceMeasure, 'fusionMeterId'> => ({
+  fusionMeterId: isUpdate ? Number(measure.id): undefined,
+  fusionId: measure.fusionId,
+  meterTitle: measure.title,
+  meterType: measure.type,
+  position: JSON.stringify(measure.positions),
+  length: Number(measure.desc),
+
+})
+
+export type Measures = Measure[]
+
+export const fetchMeasures = async (fusionId: { fusionId: Measure['fusionId'] }) => {
+  const data = await axios.get<ServiceMeasure[]>(MESASURE_LIST, { params: { fusionId } })
+  return data.map(toLocal)
+}
+
+export const postAddMeasure = async (measure: Measure) => {
+  const serviceMeasure = await axios.post<ServiceMeasure>(INSERT_MESASURE, toService(measure, false))
+  return toLocal(serviceMeasure)
+}
+
+export const postUpdateMeasure = async (measure: Measure) => {
+  await axios.post(UPDATE_MESASURE, toService(measure))
+}
+
+export const postDeleteMeasure = async (id: Measure['id']) => {
+  await axios.post(DELETE_MESASURE, { fusionMeterId: Number(id) })
+}
+
+export const postChangeShowMeasure = async (fusionId: Measure['fusionId'], show: boolean) => {
+  await axios.post<ServiceMeasure>(INSERT_MESASURE, { fusionId, hide: Number(!show) })
+}

+ 45 - 0
src/api/record-fragment.ts

@@ -0,0 +1,45 @@
+import axios from './instance'
+import { RECORD_FRAGMENT_LIST, DELETE_RECORD_FRAGMENT } from './constant'
+import type { Record } from './record'
+
+interface ServiceRecordFragment {
+  videoId: number
+  videoPath: string,
+  voideName: string,
+  videoCover: string,
+  sort: number,
+}
+
+export type RecordFragment = {
+  id: string, 
+  cover: string,
+  sort: number, 
+  url: string
+}
+
+const toLocal = (serviceRecordFragment: ServiceRecordFragment) : RecordFragment => ({
+  id: serviceRecordFragment.videoId.toString(),
+  cover: serviceRecordFragment.videoCover,
+  url: serviceRecordFragment.videoPath,
+  sort: serviceRecordFragment.sort,
+})
+
+const toService = (recordFragment: RecordFragment, isUpdate = true): PartialProps<ServiceRecordFragment, 'videoId'> => ({
+  videoId: isUpdate ? Number(recordFragment.id): undefined,
+  videoCover: recordFragment.cover,
+  videoPath: recordFragment.url,
+  sort: recordFragment.sort,
+  voideName: ''
+})
+
+export type RecordFragments = RecordFragment[]
+
+
+export const fetchRecordFragments = async (recordId: Record['id']) => {
+  const data = await axios.get<ServiceRecordFragment[]>(RECORD_FRAGMENT_LIST, { params: { folderId: recordId } })
+  return data.map(toLocal)
+}
+
+export const postDeleteRecordFragment = async (id: RecordFragment['id']) => {
+  await axios.post(DELETE_RECORD_FRAGMENT, { videoId: Number(id) })
+}

+ 75 - 0
src/api/record.ts

@@ -0,0 +1,75 @@
+import axios from './instance'
+import { RECORD_LIST, DELETE_RECORD, UPDATE_RECORD, MERGE_RECORD } from './constant'
+import { params } from '@/env'
+
+export enum RecordStatus {
+  ERR = -1,
+  RUN = 0,
+  SUCCESS = 1
+}
+
+export interface Record {
+  id: string
+  cover: string
+  title: string
+  url: string
+  sort: number
+  status: RecordStatus
+}
+
+interface ServiceRecord {
+  videoFolderId: number,
+  videoFolderName: string,
+  videoFolderCover: string,
+  videoMergeUrl: string,
+  sort: number,
+  uploadStatus: RecordStatus
+}
+
+const toLocal = (serviceRecord: ServiceRecord) : Record => ({
+  id: serviceRecord.videoFolderId.toString(),
+  cover: serviceRecord.videoFolderCover,
+  title: serviceRecord.videoFolderName,
+  url: serviceRecord.videoMergeUrl,
+  sort: serviceRecord.sort,
+  status: serviceRecord.uploadStatus
+})
+
+const toService = (record: Record, isUpdate = true): PartialProps<ServiceRecord, 'videoFolderId'> => ({
+  videoFolderId: isUpdate ? Number(record.id): undefined,
+  videoFolderName: record.title,
+  videoFolderCover: record.cover,
+  videoMergeUrl: record.url,
+  sort: record.sort,
+  uploadStatus: record.status
+})
+
+
+export type Records = Record[]
+
+export const fetchRecords = async () => {
+  const data = await axios.get<ServiceRecord[]>(RECORD_LIST, { params: { caseId: params.caseId } })
+  return data.map(toLocal)
+}
+
+export const postAddRecord = async (record: Record, files: File[]) => {
+  const serviceRecord = await postMegerRecord(files)
+  return toLocal(serviceRecord)
+}
+
+export const postUpdateRecord = async (record: Record) => {
+  const serviceRecord = toService(record)
+  await axios.post(UPDATE_RECORD, serviceRecord)
+}
+
+export const postMegerRecord = (files: File[], recordId?: Record['id']) => {
+  return axios.post<ServiceRecord>(MERGE_RECORD, {
+    folderId: recordId && Number(recordId),
+    caseId: params.caseId,
+    files
+  })
+}
+
+export const postDeleteRecord = async (id: Record['id']) => {
+  await axios.post(DELETE_RECORD, { fusionMeterId: Number(id) })
+}

+ 3 - 2
src/api/sys.ts

@@ -4,12 +4,13 @@ import { jsonToForm } from '@/utils'
 
 type UploadFile = LocalFile | string
 
+export const blobToFile = (blob: Blob, suffix = '.png') => new File([blob], `aaa${suffix}`)
+
 export const uploadFile = async (file: UploadFile, suffix = '.png') => {
   if (typeof file === 'string') {
     return file
   } else {
-    const uploadFile = file.blob instanceof File ? file.blob : new File([file.blob], `aaa${suffix}`)
-    console.log(uploadFile)
+    const uploadFile = file.blob instanceof File ? file.blob : blobToFile(file.blob, suffix)
     const url = await axios<string>({
       method: 'POST',
       url: UPLOAD_FILE, 

+ 2 - 1
src/api/tagging-position.ts

@@ -47,6 +47,7 @@ export const fetchTaggingPositions = async (taggingId: Tagging['id']) => {
 }
 
 export const postAddTaggingPosition = async (position: TaggingPosition) => {
+  console.error(position, localToService(position))
   const servicePosition = await axios.post<ServicePosition>(INSERT_TAGGING_POINT, localToService(position))
   return serviceToLocal(servicePosition)
 }
@@ -56,7 +57,7 @@ export const postUpdateTaggingPosition = (position: TaggingPosition) => {
 }
   
 export const postDeleteTaggingPosition = (position: TaggingPosition) => {
-  return axios.post<undefined>(DELETE_TAGGING_POINT, { ids: [position.id] })
+  return axios.post<undefined>(DELETE_TAGGING_POINT, { tagPointId: Number(position.id) })
 }
 
   

+ 74 - 0
src/api/view.ts

@@ -0,0 +1,74 @@
+import axios from './instance'
+import { VIEW_LIST, INSERT_VIEW, DELETE_VIEW, UPDATE_VIEW } from './constant'
+
+import type { Scene } from './scene'
+import { params } from '@/env'
+
+export type View = {
+  id: string
+  cover: string
+  title: string
+  sort: number
+  flyData: string,
+} & ({ isFuse: true } | { isFuse: false, sceneId: Scene['id'] })
+
+type ServiceView = {
+  viewId: number
+  viewTitle:	string	
+  viewPoint:	string	
+  viewImg:	string	
+  sort:	number	
+}& ({ isFuse: true } | { isFuse: false, sceneId: Scene['id'] })
+
+const toLocal = (serviceView: ServiceView) : View => {
+  const base = {
+    id: serviceView.viewId.toString(),
+    cover: serviceView.viewImg,
+    title: serviceView.viewTitle,
+    sort: serviceView.sort,
+    flyData: JSON.parse(serviceView.viewPoint),
+    isFuse: serviceView.isFuse,
+  }
+  if (!serviceView.isFuse) {
+    return { ...base, sceneId: serviceView.sceneId }
+  } else {
+    return base as View
+  }
+}
+
+const toService = (view: View, isUpdate = true): PartialProps<ServiceView, 'viewId'> => {
+  const base = {
+    viewId: isUpdate ? Number(view.id) : undefined,
+    viewTitle: view.title,	
+    viewPoint: JSON.stringify(view.flyData),
+    viewImg: view.cover,
+    sort:	view.sort,
+    isFuse: view.isFuse
+  }
+  if (!view.isFuse) {
+    return { ...base, sceneId: view.sceneId } as ServiceView
+  } else {
+    return base
+  }
+}
+
+export type Views = View[]
+
+export const fetchViews = async () => {
+  const data = await axios.get<ServiceView[]>(VIEW_LIST, { params: { caseId: params.caseId } })
+  return data.map(toLocal)
+}
+
+export const postAddView = async (view: View) => {
+  const serviceView = await axios.post<ServiceView>(INSERT_VIEW, { ...toService(view, false), caseId: params.caseId })
+  return toLocal(serviceView)
+}
+
+export const postUpdateView = async (view: View) => {
+  await axios.post(UPDATE_VIEW, toService(view))
+}
+
+export const postDeleteView = async (id: View['id']) => {
+  await axios.post(DELETE_VIEW, { viewId: Number(id) })
+}
+

+ 16 - 22
src/app.vue

@@ -3,13 +3,23 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, ref, watch } from 'vue'
-import { loaded, error, initialStore, save, isOld, enterEdit } from '@/store'
-import { loadComponent, loadPack, togetherCallback } from '@/utils'
-import { router, currentMeta } from '@/router'
-import { showLeftPanoStack, showRightPanoStack } from '@/env'
+import { computed, ref } from 'vue'
+import { initialFuseModels, initialScenes } from '@/store'
+import { loadComponent, loadPack } from '@/utils'
 
-loadPack(initialStore)
+const loaded = ref(false)
+const error = ref(false)
+loadPack(async () => {
+  try {
+    await Promise.all([
+      initialFuseModels(),
+      initialScenes()
+    ])
+    loaded.value = true
+  } catch {
+    error.value = true
+  }
+})
 
 const Main = loadComponent(() => import('@/layout/main.vue'))
 const Err = loadComponent(() => import('@/components/error/index.vue'))
@@ -19,20 +29,4 @@ const Component = computed(() => {
     return error.value ? Err : Main
   }
 })
-
-router.beforeEach(async (to, from, next) => {
-  if (to.params.save && isOld.value) {
-    await save()
-  }
-  next()
-})
-watch(currentMeta, (meta, _, onClean) => {
-  if (meta && 'full' in meta && meta.full) {
-    enterEdit(() => router.back())
-    onClean(togetherCallback([
-      showLeftPanoStack.push(ref(false)),
-      showRightPanoStack.push(ref(false)),
-    ]))
-  }
-}, { flush: 'post' })
 </script>

+ 3 - 0
src/components/bill-ui/assets/scss/_base-vars.scss

@@ -52,4 +52,7 @@
 
   --editor-toolbox-width: 340px;
   --editor-toolbox-back: var(--editor-menu-back);
+
+  --editor-toolbar-height: 60px;
+  --editor-toolbar-bottom: 0;
 }

+ 16 - 6
src/components/bill-ui/assets/scss/components/_input.scss

@@ -109,6 +109,7 @@
         left: 50%;
         top: 50%;
         transform: translate(-50%, -50%) scale(0);
+        opacity: 0;
         transition: all .1s linear;
       }
     }
@@ -116,6 +117,7 @@
 
     &:checked+.replace {
       .icon{
+        opacity: 1;
         transform: translate(-50%, -50%) scale(1);
       }
     }
@@ -165,17 +167,11 @@
         color: var(--colors-color);
       }
     }
-    .input-value {
-      position: absolute;
-    }
 
     &.pre-suffix {
       input {
         padding-left: 30px;
       }
-      .input-value {
-        left: 30px;
-      }
       .pre-icon {
         left: 10px;
         top: 50%;
@@ -350,6 +346,7 @@
   }
 
 
+
   .range {
     width: 100%;
     display: flex;
@@ -688,3 +685,16 @@
 
 }
 
+
+.multiple {
+  .multiple-option {
+    display: flex;
+    align-items: center;
+    p {
+      margin-left: 10px;
+    }
+  }
+  .select-options-atom {
+    background: none !important;
+  }
+}

+ 3 - 3
src/components/bill-ui/assets/scss/editor/_toolbar.scss

@@ -2,17 +2,17 @@
 
 .ui-editor-toolbar {
     position: absolute;
-    bottom: 0;
+    bottom: var(--editor-toolbar-bottom);
     right: calc(var(--editor-toolbox-width) + var(--editor-menu-right));
     display: flex;
     align-items: center;
     justify-content: center;
     flex: 1;
-    height:60px;
+    height:var(--editor-toolbar-height);
     background-color: var(--editor-menu-back);
     pointer-events: all;
     left: calc(var(--editor-menu-left) + var(--editor-menu-width));
-    z-index: 1;
+    z-index: 2;
     backdrop-filter: blur(4px);
     transition: all .3s ease;
 }

+ 72 - 3
src/components/bill-ui/components/icon/iconfont/demo_index.html

@@ -55,6 +55,24 @@
           <ul class="icon_lists dib-box">
           
             <li class="dib">
+              <span class="icon iconfont">&#xe63b;</span>
+                <div class="name">video</div>
+                <div class="code-name">&amp;#xe63b;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6dd;</span>
+                <div class="name">order</div>
+                <div class="code-name">&amp;#xe6dd;</div>
+              </li>
+          
+            <li class="dib">
+              <span class="icon iconfont">&#xe6e3;</span>
+                <div class="name">pin</div>
+                <div class="code-name">&amp;#xe6e3;</div>
+              </li>
+          
+            <li class="dib">
               <span class="icon iconfont">&#xe64a;</span>
                 <div class="name">nav-measure</div>
                 <div class="code-name">&amp;#xe64a;</div>
@@ -288,9 +306,9 @@
 <pre><code class="language-css"
 >@font-face {
   font-family: 'iconfont';
-  src: url('iconfont.woff2?t=1660900397303') format('woff2'),
-       url('iconfont.woff?t=1660900397303') format('woff'),
-       url('iconfont.ttf?t=1660900397303') format('truetype');
+  src: url('iconfont.woff2?t=1661163199866') format('woff2'),
+       url('iconfont.woff?t=1661163199866') format('woff'),
+       url('iconfont.ttf?t=1661163199866') format('truetype');
 }
 </code></pre>
           <h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
@@ -317,6 +335,33 @@
         <ul class="icon_lists dib-box">
           
           <li class="dib">
+            <span class="icon iconfont icon-video1"></span>
+            <div class="name">
+              video
+            </div>
+            <div class="code-name">.icon-video1
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-order"></span>
+            <div class="name">
+              order
+            </div>
+            <div class="code-name">.icon-order
+            </div>
+          </li>
+          
+          <li class="dib">
+            <span class="icon iconfont icon-pin1"></span>
+            <div class="name">
+              pin
+            </div>
+            <div class="code-name">.icon-pin1
+            </div>
+          </li>
+          
+          <li class="dib">
             <span class="icon iconfont icon-nav-measure"></span>
             <div class="name">
               nav-measure
@@ -669,6 +714,30 @@
           
             <li class="dib">
                 <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-video1"></use>
+                </svg>
+                <div class="name">video</div>
+                <div class="code-name">#icon-video1</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-order"></use>
+                </svg>
+                <div class="name">order</div>
+                <div class="code-name">#icon-order</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
+                  <use xlink:href="#icon-pin1"></use>
+                </svg>
+                <div class="name">pin</div>
+                <div class="code-name">#icon-pin1</div>
+            </li>
+          
+            <li class="dib">
+                <svg class="icon svg-icon" aria-hidden="true">
                   <use xlink:href="#icon-nav-measure"></use>
                 </svg>
                 <div class="name">nav-measure</div>

+ 15 - 3
src/components/bill-ui/components/icon/iconfont/iconfont.css

@@ -1,8 +1,8 @@
 @font-face {
   font-family: "iconfont"; /* Project id 3549513 */
-  src: url('iconfont.woff2?t=1660900397303') format('woff2'),
-       url('iconfont.woff?t=1660900397303') format('woff'),
-       url('iconfont.ttf?t=1660900397303') format('truetype');
+  src: url('iconfont.woff2?t=1661163199866') format('woff2'),
+       url('iconfont.woff?t=1661163199866') format('woff'),
+       url('iconfont.ttf?t=1661163199866') format('truetype');
 }
 
 .iconfont {
@@ -13,6 +13,18 @@
   -moz-osx-font-smoothing: grayscale;
 }
 
+.icon-video1:before {
+  content: "\e63b";
+}
+
+.icon-order:before {
+  content: "\e6dd";
+}
+
+.icon-pin1:before {
+  content: "\e6e3";
+}
+
 .icon-nav-measure:before {
   content: "\e64a";
 }

File diff suppressed because it is too large
+ 1 - 1
src/components/bill-ui/components/icon/iconfont/iconfont.js


+ 21 - 0
src/components/bill-ui/components/icon/iconfont/iconfont.json

@@ -6,6 +6,27 @@
   "description": "",
   "glyphs": [
     {
+      "icon_id": "23781429",
+      "name": "video",
+      "font_class": "video1",
+      "unicode": "e63b",
+      "unicode_decimal": 58939
+    },
+    {
+      "icon_id": "30948192",
+      "name": "order",
+      "font_class": "order",
+      "unicode": "e6dd",
+      "unicode_decimal": 59101
+    },
+    {
+      "icon_id": "31365069",
+      "name": "pin",
+      "font_class": "pin1",
+      "unicode": "e6e3",
+      "unicode_decimal": 59107
+    },
+    {
       "icon_id": "25631400",
       "name": "nav-measure",
       "font_class": "nav-measure",

BIN
src/components/bill-ui/components/icon/iconfont/iconfont.ttf


BIN
src/components/bill-ui/components/icon/iconfont/iconfont.woff


BIN
src/components/bill-ui/components/icon/iconfont/iconfont.woff2


+ 6 - 0
src/components/bill-ui/components/input/index.vue

@@ -25,6 +25,7 @@ import file from './file.vue'
 import search from './search.vue'
 import richtext from './richtext.vue'
 import color from './color.vue'
+import multiple from './multiple.vue'
 import {
     inputPropsDesc,
     textPropsDesc,
@@ -41,6 +42,7 @@ import {
     colorPropsDesc,
     inputEmitDesc,
     textEmitsDesc,
+    multiplePropsDesc
 } from './state'
 
 const types = {
@@ -92,6 +94,10 @@ const types = {
         component: color,
         propsDesc: colorPropsDesc,
     },
+    multiple: {
+        component: multiple,
+        propsDesc: multiplePropsDesc
+    }
 }
 
 const props = defineProps(inputPropsDesc)

+ 63 - 0
src/components/bill-ui/components/input/multiple.vue

@@ -0,0 +1,63 @@
+<template>
+  <Select
+    floatingClass="multiple"
+    :options="options" 
+    :placeholder="placeholder"
+    :labelValue="fillValue"
+    stopEl="*">
+      <template v-slot:option="{ raw }">
+        <div class="multiple-option">
+          <div class="ui-input">
+            <Checkbox
+              @update:modelValue="checked => updateItem(checked, raw)"
+              :modelValue="isCheck(raw)"/>
+          </div>
+          <p>{{ raw.label }}</p>
+        </div>
+      </template>
+  </Select>
+</template>
+
+<script>
+import { defineComponent, computed } from 'vue'
+import { multiplePropsDesc } from './state'
+import Select from './select.vue'
+import Checkbox from './checkbox.vue'
+
+export default defineComponent({
+  props: multiplePropsDesc,
+  emits: {
+    'update:modelValue': () => true
+  },
+  setup(props, { emit }) {
+    const isCheck = (item) => props.modelValue.some(value => item.value === value)
+
+    const updateItem = (checked, item) => {
+      const index = props.modelValue.findIndex(value => item.value === value)
+      const newValue = [...props.modelValue]
+      if (~index && !checked) {
+        newValue.splice(index, 1)
+      } else if (!~index && checked) {
+        newValue.push(item.value)
+      }
+      emit('update:modelValue', newValue)
+    }
+
+    const fillValue = computed(() => 
+      props.modelValue.map(
+        value => props.options.find(option => option.value === value).label
+      ).join(',')
+    )
+
+    return {
+      fillValue,
+      isCheck,
+      updateItem
+    }
+  },
+  components: {
+    Select,
+    Checkbox
+  }
+})
+</script>

+ 17 - 8
src/components/bill-ui/components/input/select.vue

@@ -11,7 +11,7 @@
         :width="props.width"
         :height="props.height"
         :readonly="readonly"
-        :placeholder="inputValue ? '' : props.placeholder"
+        :placeholder="props.placeholder"
         @blur="blurHandler"
         @focus="showHandler"
         @click="clickShowHandler"
@@ -23,13 +23,10 @@
         <template v-slot:preIcon v-if="$slots.preIcon">
             <slot name="preIcon" />
         </template>
-
-        <template v-slot:value v-if="$slots.value && selectOption">
-            <slot name="value" :option="selectOption" />
-        </template>
     </UItext>
 
     <UIFloating
+        ref="floatRef"
         :mount="mountEl"
         :refer="vmRef && vmRef.root"
         width="100%"
@@ -93,8 +90,10 @@ const vmRef = ref(null)
 const showOption = ref(false)
 const mountEl = document.body
 
-const selectOption = computed(() => props.options.find(({ value }) => value === props.modelValue))
-const inputValue = computed(() => selectOption.value ? selectOption.value.label : '')
+const inputValue = computed(() => {
+    const selectOption = props.options.find(({ value }) => value === props.modelValue)
+    return selectOption ? selectOption.label : ''
+})
 
 const repeatClickHandler = () => {
     setTimeout(() => {
@@ -105,8 +104,18 @@ watchEffect(() => {
     emit(showOption.value ? 'focus' : 'blur')
 })
 
+const floatRef = ref()
+const stopEls = computed(() => {
+    const root = floatRef.value?.vmRef 
+    if (root && props.stopEl) {
+        return  Array.from(root.querySelectorAll(props.stopEl))
+    } else {
+        return []
+    }
+})
+
 const optionClickHandler = (ev, option) => {
-    if (props.stopEl && props.stopEl.toUpperCase() === ev.target.tagName.toUpperCase()) {
+    if (stopEls.value.includes(ev.target)) {
         repeatClickHandler()
     } else {
         clickCount = 0

+ 9 - 0
src/components/bill-ui/components/input/state.js

@@ -179,6 +179,15 @@ export const numberPropsDesc = {
     },
 }
 
+export const multiplePropsDesc = {
+    ...selectPropsDesc,
+    modelValue: {
+        required: true,
+        default: [],
+        type: Array
+    },
+}
+
 export const rangePropsDesc = {
     ...numberPropsDesc,
     min: { ...numberPropsDesc.min, require: true },

+ 9 - 5
src/components/list/index.vue

@@ -1,12 +1,12 @@
 <template>
   <ul class="list">
-    <li class="header">
-      <h3>数据列表</h3>
+    <li class="header" v-if="title">
+      <h3>{{ title }}</h3>
       <div class="action" v-if="$slots.action">
         <slot name="action"></slot>
       </div>
     </li>
-    <ul class="content">
+    <ul class="content" v-if="showContent">
       <li 
         v-for="(item, i) in data" 
         :key="key ? item[key] : i" 
@@ -23,9 +23,13 @@
 
 <script lang="ts" setup>
 type Item = Record<string, any> & {select?: boolean}
-type ListProps = { title: string, key?: string, data: Array<Item>}
+type ListProps = { title?: string, key?: string, data: Array<Item>, showContent?: boolean}
+
+withDefaults(
+  defineProps<ListProps>(),
+  { showContent: true }
+)
 
-defineProps<ListProps>()
 
 defineEmits<{ (e: 'changeSelect', item: Item): void }>()
 </script>

+ 7 - 1
src/env/index.ts

@@ -5,10 +5,13 @@ import type { FuseModel, TaggingPosition } from '@/store'
 
 export const viewModeStack = stackFactory(ref<'full' | 'auto'>('auto'))
 export const showToolbarStack = stackFactory(ref<boolean>(false))
+export const showHeadBarStack = stackFactory(ref<boolean>(true))
 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 showBottomBarStack = stackFactory(ref<boolean>(false))
+export const bottomBarHeightStack = stackFactory(ref<string>('60px'))
 export const showTaggingsStack = stackFactory(ref<boolean>(true))
 export const showMeasuresStack = stackFactory(ref<boolean>(false))
 export const currentModelStack = stackFactory(ref<FuseModel | null>(null))
@@ -29,7 +32,10 @@ export const custom = flatStacksValue({
   currentModel: currentModelStack,
   showModelsMap: showModelsMapStack,
   modelsChangeStore: modelsChangeStoreStack,
-  showTaggingPositions: showTaggingPositionsStack
+  showTaggingPositions: showTaggingPositionsStack,
+  showBottomBar: showBottomBarStack,
+  bottomBarHeight: bottomBarHeightStack,
+  showHeadBar: showHeadBarStack
 })
 
 

+ 53 - 0
src/layout/edit/fuse-edit.vue

@@ -0,0 +1,53 @@
+<template>
+  <template v-if="loaded" style="height: 100%">
+    <Model :type="FUSE" />
+    <Header></Header>
+    <router-view v-slot="{ Component }">
+      <keep-alive>
+        <component :is="Component" />
+      </keep-alive>
+    </router-view>
+  </template>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue'
+import { currentMeta, router } from '@/router'
+import { showLeftPanoStack, showRightPanoStack } from '@/env'
+import { togetherCallback } from '@/utils'
+import { Model, FUSE } from '../model/index.vue'
+import { 
+  enterEdit, 
+  isOld, 
+  save, 
+  initialTaggingStyles, 
+  initialTaggings, 
+  initialGuides 
+} from '@/store'
+
+import Header from './header/index.vue'
+
+const loaded = ref(false)
+Promise.all([
+  initialTaggingStyles(),
+  initialTaggings(),
+  initialGuides()
+])
+.then(() => loaded.value = true)
+
+router.beforeEach(async (to, from, next) => {
+  if (to.params.save && isOld.value) {
+    await save()
+  }
+  next()
+})
+watch(currentMeta, (meta, _, onClean) => {
+  if (meta && 'full' in meta && meta.full) {
+    enterEdit(() => router.back())
+    onClean(togetherCallback([
+      showLeftPanoStack.push(ref(false)),
+      showRightPanoStack.push(ref(false)),
+    ]))
+  }
+}, { flush: 'post' })
+</script>

+ 72 - 0
src/layout/edit/fuse-left-pano.vue

@@ -0,0 +1,72 @@
+<template>
+  <LeftPano>
+    <ModelList 
+      :can-change="custom.modelsChangeStore"
+      @delete-model="modelDelete"
+      @click-model="modelChangeSelect"
+    >
+      <template #action>
+        <ui-icon 
+          type="add" 
+          @click="insertMode = true" 
+          ctrl 
+          v-if="custom.modelsChangeStore" 
+        />
+      </template>
+    </ModelList>
+  </LeftPano>
+
+  <SelectModel 
+    :select-ids="fuseModels.map(item => item.modelId)"
+    @close="insertMode = false" 
+    @select="addModelsHandler"
+    v-if="insertMode" 
+  />
+</template>
+
+<script lang="ts" setup>
+import { watchEffect, ref } from 'vue'
+import { custom } from '@/env'
+import { getSceneModel } from '@/sdk'
+import { fuseModels, getFuseModelShowVariable, createFuseModels, Scene, addFuseModel } from '@/store'
+
+import ModelList from '../model-list/index.vue'
+import SelectModel from './scene-select.vue'
+import { LeftPano } from '@/layout'
+
+import type { FuseModel } from '@/store'
+
+const insertMode = ref(false)
+const modelChangeSelect = (model: FuseModel) => {
+  if (getFuseModelShowVariable(model).value) {
+    if (custom.currentModel !== model) {
+      getSceneModel(model)?.changeSelect(true)
+      custom.currentModel = model
+    } else {
+      getSceneModel(custom.currentModel)?.changeSelect(false)
+      custom.currentModel = null
+    }
+  }
+}
+
+watchEffect(() => {
+  if (custom.currentModel && !getFuseModelShowVariable(custom.currentModel).value) {
+    custom.currentModel = null
+  }
+})
+
+const modelDelete = (model: FuseModel) => {
+  const index = fuseModels.value.indexOf(model)
+  if (~index) {
+    fuseModels.value.splice(index, 1)
+  }
+  console.log(fuseModels.value.length)
+}
+
+const addModelsHandler = (modelIds: Scene['modelId'][]) => {
+  modelIds
+    .filter(modelId => !fuseModels.value.some(model => model.modelId === modelId))
+    .map(modelId => createFuseModels({ modelId }))
+    .forEach(addFuseModel)
+}
+</script>

src/layout/slide-menu.vue → src/layout/edit/fuse-slide-menu.vue


+ 2 - 2
src/layout/switch.vue

@@ -11,7 +11,7 @@
 </template>
 
 <script lang="ts" setup>
-import SlideMenu from './slide-menu.vue'
+import SlideMenu from './fuse-slide-menu.vue'
 import Header from './header/index.vue'
-import ModelList from './model-list/index.vue'
+import ModelList from './fuse-left-pano.vue'
 </script>

+ 0 - 2
src/layout/header/index.vue

@@ -29,8 +29,6 @@ const props = defineProps<{ title?: string }>()
 const sysTitle = computed(() => props.title || title)
 
 watchEffect(() => (document.title = sysTitle.value))
-
-
 </script>
 
 <style lang="sass" scoped>

src/layout/header/style.scss → src/layout/edit/header/style.scss


+ 37 - 0
src/layout/edit/scene-edit.vue

@@ -0,0 +1,37 @@
+<template>
+  <Header></Header>
+  <LeftPano>
+    <SceneList v-model:current="currentModelType" />
+  </LeftPano>
+  <Model :type="currentModelType" />
+
+  <router-view v-slot="{ Component }">
+    <keep-alive>
+      <component :is="Component" />
+    </keep-alive>
+  </router-view>
+  
+</template>
+
+<script setup lang="ts">
+import { ref, watchEffect } from 'vue'
+import Header from './header/index.vue'
+import SceneList from '../scene-list/index.vue'
+import { Model, FUSE } from '../model/index.vue'
+import { LeftPano } from '@/layout'
+import { custom } from '@/env'
+
+import type { ModelType } from '../model/index.vue'
+
+const currentModelType = ref<ModelType>(FUSE)
+
+watchEffect(() => console.log(currentModelType.value))
+custom.showLeftPano = true
+
+</script>
+
+<style>
+:root {
+  --editor-menu-width: 0px;
+}
+</style>

src/layout/model-list/select.vue → src/layout/edit/scene-select.vue


+ 2 - 2
src/layout/left-pano.vue

@@ -22,7 +22,7 @@ import { custom } from '@/env'
   background: rgba(27,27,28,0.8);
   top: calc(var(--editor-head-height) + var(--header-top));
   left: var(--left-pano-left);
-  bottom: 0;
+  bottom: var(--editor-menu-bottom);
   z-index: 1000;
   backdrop-filter: blur(4px);
   overflow-y: auto;
@@ -38,7 +38,7 @@ import { custom } from '@/env'
   border-radius: 0 6px 6px 0;
   top: 50%;
   transform: translateY(-50%);
-  z-index: 1;
+  z-index: 1000;
   display: flex;
   align-items: center;
   justify-content: center;

+ 31 - 33
src/layout/main.vue

@@ -1,27 +1,27 @@
 <template>
-  <ui-editor-layout @click.stop id="layout-app" class="editor-layout" :class="layoutClassNames">
-    <div class="laser-layer">
-      <div class="scene" ref="sceneRef"></div>
-    </div>
-
-    <template v-if="loaded" style="height: 100%">
-      <Header></Header>
+  <ui-editor-layout 
+    @click.stop 
+    id="layout-app" 
+    class="editor-layout" 
+    :style="layoutStyles" 
+    :class="layoutClassNames"
+  >
+    <div :ref="el => appEl = el as HTMLDivElement">
       <router-view v-slot="{ Component }">
         <keep-alive>
           <component :is="Component" />
         </keep-alive>
       </router-view>
-    </template>
+    </div>
   </ui-editor-layout>
 </template>
 
 <script lang="ts" setup>
 import { custom } from '@/env'
-import { computed, ref, watchEffect, watch } from 'vue'
-import { isEdit, loaded, enterEdit } from '@/store'
-import { initialSDK } from '@/sdk'
-import { currentRouteNames, router } from '@/router'
-import Header from './header/index.vue'
+import { computed, watchEffect } from 'vue'
+import { isEdit, appEl } from '@/store'
+
+watchEffect(() => console.log(appEl.value))
 
 const layoutClassNames = computed(() => {
   return {
@@ -30,22 +30,19 @@ const layoutClassNames = computed(() => {
     'setting-mode': custom.showToolbar,
     'hide-right-box-mode': !custom.showRightPano,
     'hide-left-box-mode': !custom.showLeftPano,
+    'show-bottom-box-mode': custom.showBottomBar,
+    'hide-top-bar-mode': !custom.showHeadBar
   }
 })
 
-const sceneRef = ref<HTMLDivElement>()
-const stopSdkInstalWatch = watchEffect(() => {
-  if (loaded.value && sceneRef.value) {
-    initialSDK({ layout: sceneRef.value })
-    stopSdkInstalWatch()
+const layoutStyles = computed(() => {
+  const styles: {[key in string]: string} = {}
+  if (custom.showBottomBar) {
+    styles['--editor-menu-bottom'] = custom.bottomBarHeight
   }
-})
 
-watch(currentRouteNames, () => {
-  if (currentRouteNames.value.includes('full')) {
-    enterEdit(() => router.back())
-  }
-}, { immediate: true })
+  return styles
+})
 
 </script>
 
@@ -62,23 +59,24 @@ watch(currentRouteNames, () => {
   --search-left: 52px;
 }
 
-.hide-right-box-mode {
-  --editor-menu-right: calc(-1 * var(--editor-toolbox-width)) !important;
-}
-
-.hide-left-box-mode {
-  --left-pano-left: calc(var(--editor-menu-left) + var(--editor-menu-width) - var(--left-pano-width)) !important;
-}
 
 .sys-view-auto {
   --header-top: var(--show-header-top);
   --search-left: 0px;
 }
 
-.setting-mode {
-  --editor-menu-bottom: 60px;
+
+.hide-top-bar-mode {
+  --header-top: var(--hide-header-top);
 }
 
+.hide-right-box-mode {
+  --editor-menu-right: calc(-1 * var(--editor-toolbox-width)) !important;
+}
+
+.hide-left-box-mode {
+  --left-pano-left: calc(var(--editor-menu-left) + var(--editor-menu-width) - var(--left-pano-width)) !important;
+}
 .edit-mode {
   --editor-menu-left: calc(-1 * var(--editor-menu-width));
 }

+ 29 - 38
src/layout/model-list/index.vue

@@ -1,44 +1,43 @@
 <template>
-  <LeftPano>
-    <List 
-      title="数据列表" 
-      key="id" 
-      :data="modelList" 
-    >
-      <template #action>
-        <ui-icon type="add" @click="insertMode = true" ctrl/>
-      </template>
-      <template #atom="{ item }">
-        <ModelSign 
-          :model="item.raw" 
-          @delete="modelDelete(item.raw)" 
-          @click="modelChangeSelect(item.raw)"
-        />
-      </template>
-    </List>
-  </LeftPano>
-
-  <SelectModel 
-    :select-ids="fuseModels.map(item => item.modelId)"
-    @close="insertMode = false" 
-    @select="addModelsHandler"
-    v-if="insertMode" 
-  />
+  <List :title="title" key="id" :data="modelList" :showContent="showContent">
+    <template #action>
+      <slot name="action" />
+    </template>
+    <template #atom="{ item }">
+      <ModelSign
+        :canChange="canChange"
+        :model="item.raw" 
+        @delete="modelDelete(item.raw)" 
+        @click="modelChangeSelect(item.raw)"
+      />
+    </template>
+  </List>
 </template>
 
 <script lang="ts" setup>
-import { computed, watchEffect, ref } from 'vue'
-import { LeftPano } from '@/layout'
+import { computed, watchEffect } from 'vue'
 import { custom } from '@/env'
 import { getSceneModel } from '@/sdk'
-import SelectModel from './select.vue'
 import List from '@/components/list/index.vue'
 import ModelSign from './sign.vue'
-import { fuseModels, getFuseModelShowVariable, createFuseModels, Scene, addFuseModel } from '@/store'
+import { fuseModels, getFuseModelShowVariable } from '@/store'
 
 import type { FuseModel } from '@/store'
 
-const insertMode = ref(false)
+export type ModelListProps = { 
+  title?: string, 
+  canChange?: boolean,
+  showContent?: boolean
+}
+withDefaults(
+  defineProps<ModelListProps>(),
+  { title: '数据列表', change: false, showContent: true }
+)
+defineEmits<{
+  (e: 'deleteModel', model: FuseModel): void,
+  (e: 'clickModel', model: FuseModel): void
+}>()
+
 const modelList = computed(() => 
   fuseModels.value.map(model => ({
     raw: model,
@@ -69,13 +68,5 @@ const modelDelete = (model: FuseModel) => {
   if (~index) {
     fuseModels.value.splice(index, 1)
   }
-  console.log(fuseModels.value.length)
-}
-
-const addModelsHandler = (modelIds: Scene['modelId'][]) => {
-  modelIds
-    .filter(modelId => !fuseModels.value.some(model => model.modelId === modelId))
-    .map(modelId => createFuseModels({ modelId }))
-    .forEach(addFuseModel)
 }
 </script>

+ 6 - 6
src/layout/model-list/sign.vue

@@ -1,30 +1,30 @@
 <template>
-  <div class="model-header" @click="!model.error && $emit('click')">
+  <div class="model-header" @click.stop="!model.error && $emit('click')">
     <p>{{ model.title }}</p>
     <div class="model-action" @click.stop>
       <ui-input type="checkbox" v-model="show" :class="{disabled: model.error}"/>
       <ui-icon 
-        v-if="model.type !== SceneType.SWSS && custom.modelsChangeStore" 
+        v-if="custom.modelsChangeStore" 
         type="del" 
         ctrl 
         @click="$emit('delete')" 
       />
     </div>
   </div>
-  <div class="model-desc" @click="$emit('click')" v-if="custom.currentModel === model">
+  <div class="model-desc" @click="$emit('click')" v-if="canChange">
     <p><span>数据来源:</span>{{ SceneTypeDesc[model.type] }}</p>
     <p><span>数据大小:</span>{{ model.size }}</p>
-    <p v-if="model.type === SceneType.SWSS"><span>拍摄时间:</span>{{ model.time }}</p>
+    <p><span>拍摄时间:</span>{{ model.time }}</p>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { getFuseModelShowVariable, SceneTypeDesc, SceneType } from '@/store'
+import { getFuseModelShowVariable, SceneTypeDesc } from '@/store'
 import { custom } from '@/env'
 
 import type { FuseModel } from '@/store'
 
-type ModelProps = { model: FuseModel }
+type ModelProps = { model: FuseModel, canChange?: boolean }
 const props = defineProps<ModelProps>()
 
 type ModelEmits = {

+ 83 - 0
src/layout/model/index.vue

@@ -0,0 +1,83 @@
+<template>
+  <iframe class="external" :src="url" v-if="url"></iframe>
+  <div class="laser-layer" v-show="!url">
+    <div class="scene-canvas" ref="fuseRef"></div>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, watchEffect, computed } from 'vue'
+import { initialSDK, initialed } from '@/sdk'
+import { getScene, SceneType } from '@/store'
+import { showModelsMapStack } from '@/env'
+
+import type { PropType } from 'vue'
+import type { Scene } from '@/store'
+
+export const FUSE = Symbol('fuse')
+export type ModelType = symbol | Scene['id']
+
+export const Model = defineComponent({
+  name: 'model',
+  props: {
+    type: {
+      type: [Number, Symbol] as PropType<ModelType>,
+      required: true
+    }
+  },
+  setup(props) {
+    const fuseRef = ref<HTMLDivElement>()
+    const stopSDKEffect = watchEffect(async () => {
+      if (!initialed && props.type === FUSE && fuseRef.value) {
+        await initialSDK({ layout: fuseRef.value })
+        stopSDKEffect()
+      }
+    })
+    const scene = computed(() => props.type !== FUSE && getScene(props.type as number))
+    const url = computed(() => {
+      if (!scene.value) return;
+      const type = scene.value.type
+      const kk = [SceneType.SWKK || SceneType.SWKJ]
+      const pathname = kk.includes(type) ? 'swkk/spg.html' : 'swss/uat/index.html'
+
+      return `/${pathname}?m=${scene.value.num}`
+    })
+    watchEffect((onCleanup) => {
+      if (url.value) {
+        onCleanup(showModelsMapStack.push(ref(new Map())))
+      }
+    })
+
+    return {
+      FUSE,
+      scene,
+      fuseRef,
+      url
+    }
+  }
+})
+
+export default Model
+</script>
+
+<style scoped lang="scss">
+.external,
+.laser-layer {
+  position: absolute;
+  z-index: 1;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+
+  .scene-canvas {
+    width: 100%;
+    height: 100%;
+    background-color: #ccc;
+  }
+}
+
+.external {
+  border: none;
+}
+</style>

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

@@ -25,7 +25,7 @@ import { custom } from '@/env'
   border-radius: 6px 0px 0px 6px;
   top: 50%;
   transform: translateY(-50%);
-  z-index: 1;
+  z-index: 2;
   display: flex;
   align-items: center;
   justify-content: center;

+ 77 - 0
src/layout/scene-list/index.vue

@@ -0,0 +1,77 @@
+<template>
+  <List key="id" :data="list" class="scene-list">
+    <template #action>
+      <slot name="action" />
+    </template>
+    <template #atom="{ item }">
+      <div 
+        v-if="item.raw === FUSE" 
+        @click="$emit('update:current', current === item.raw ? null : item.raw)"
+      >
+        <ModelList
+          class="model-list"
+          title="融合场景"
+          :show-content="current === FUSE"
+        >
+          <template #action>
+            <ui-icon 
+              :type="`pull-${current === FUSE ? 'up' : 'down'}`" 
+              ctrl 
+            />
+          </template>
+        </ModelList>
+      </div>
+      <div class="scene" @click="$emit('update:current', item.raw.id)" v-else>
+        <p>{{ item.raw.name }}</p>
+        <p>{{ SceneTypeDesc[item.raw.type as SceneType] }}</p>
+      </div>
+    </template>
+  </List>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { scenes, SceneType, SceneTypeDesc } from '@/store'
+import { FUSE } from '../model/index.vue'
+import List from '@/components/list/index.vue'
+import ModelList from '../model-list/index.vue'
+
+import type { ModelType } from '../model/index.vue'
+
+defineEmits<{ (e: 'update:current', data: ModelType): void }>()
+const props = defineProps<{ current: ModelType }>()
+
+const list = computed(() => {
+  const sceneList = scenes.value.map(scene => ({
+    raw: scene,
+    select: props.current === scene.id
+  }))
+  return [{ raw: FUSE }, ...sceneList]
+})
+
+</script>
+
+<style lang="scss">
+.scene-list > .content > li {
+  padding: 0;
+}
+
+.scene {
+  padding: 0 20px;
+}
+
+.model-list.list {
+  margin-bottom: -20px;
+  .header {
+    padding: 10px 20px 20px;
+    h3 {
+      font-size: 20px;
+      font-weight: bold;
+      color: #FFFFFF;
+    }
+  }
+  .content li:last-child .atom-content {
+    border: none;
+  }
+}
+</style>

+ 58 - 29
src/router/config.ts

@@ -4,45 +4,74 @@ import type { RouteRecordRaw } from 'vue-router'
 
 export const routes: RouteRecordRaw[] = [
   {
-    path: paths.switch,
-    name: RoutesName.switch,
-    component: () => import('@/layout/switch.vue'),
+    path: paths.fuseEdit,
+    name: RoutesName.fuseEdit,
+    component: () => import('@/layout/edit/fuse-edit.vue'),
     children: [
       {
-        path: paths.merge,
-        name: RoutesName.merge,
-        meta: metas.merge,
-        component: () => import('@/views/merge/index.vue')
+        path: paths.switch,
+        name: RoutesName.switch,
+        component: () => import('@/layout/edit/fuse-switch.vue'),
+        children: [
+          {
+            path: paths.merge,
+            name: RoutesName.merge,
+            meta: metas.merge,
+            component: () => import('@/views/merge/index.vue')
+          },
+          {
+            path: paths.tagging,
+            name: RoutesName.tagging,
+            meta: metas.tagging,
+            component: () => import('@/views/tagging/index.vue')
+          },
+          {
+            path: paths.taggingPosition,
+            name: RoutesName.taggingPosition,
+            component: () => import('@/views/tagging-position/index.vue')
+          },
+          {
+            path: paths.measure,
+            name: RoutesName.measure,
+            meta: metas.measure,
+            component: () => import('@/views/measure/index.vue')
+          },
+          {
+            path: paths.guide,
+            name: RoutesName.guide,
+            meta: metas.guide,
+            component: () => import('@/views/guide/index.vue')
+          }
+        ]
       },
       {
-        path: paths.tagging,
-        name: RoutesName.tagging,
-        meta: metas.tagging,
-        component: () => import('@/views/tagging/index.vue')
+        path: paths.registration,
+        name: RoutesName.registration,
+        component: () => import('@/views/registration/index.vue')
       },
       {
-        path: paths.measure,
-        name: RoutesName.measure,
-        meta: metas.measure,
-        component: () => import('@/views/measure/index.vue')
-      },
-      {
-        path: paths.guide,
-        name: RoutesName.guide,
-        meta: metas.guide,
-        component: () => import('@/views/guide/index.vue')
+        path: paths.proportion,
+        name: RoutesName.proportion,
+        component: () => import('@/views/proportion/index.vue')
       }
     ]
   },
   {
-    path: paths.registration,
-    name: RoutesName.registration,
-    component: () => import('@/views/registration/index.vue')
-  },
-  {
-    path: paths.proportion,
-    name: RoutesName.proportion,
-    component: () => import('@/views/proportion/index.vue')
+    path: paths.sceneEdit,
+    name: RoutesName.sceneEdit,
+    component: () => import('@/layout/edit/scene-edit.vue'),
+    children: [
+      {
+        path: paths.record,
+        name: RoutesName.record,
+        component: () => import('@/views/record/index.vue')
+      },
+      {
+        path: paths.view,
+        name: RoutesName.view,
+        component: () => import('@/views/view/index.vue')
+      }
+    ]
   }
 ]
 

+ 21 - 9
src/router/constant.ts

@@ -4,23 +4,35 @@ export enum RoutesName {
   proportion = 'proportion',
 
   tagging = 'tagging',
+  taggingPosition = 'taggingPosition',
   guide = 'guide',
   measure = 'measure',
 
-  switch = 'switch'
+  fuseEdit = 'fuseEdit',
+  switch = 'switch',
+
+  sceneEdit = 'sceneEdit',
+  record = 'record',
+  view = 'view'
 }
 
 
 export const paths = {
-  [RoutesName.merge]: '/merge',
-  [RoutesName.registration]: '/registration/:id',
-  [RoutesName.proportion]: '/proportion/:id',
-  
-  [RoutesName.tagging]: '/tagging',
-  [RoutesName.guide]: '/path',
-  [RoutesName.measure]: '/measure',
+  [RoutesName.fuseEdit]: '/fuseEdit',
 
-  [RoutesName.switch]: '/',
+  [RoutesName.switch]: '',
+  [RoutesName.merge]: 'merge',
+  [RoutesName.registration]: 'registration/:id',
+  [RoutesName.proportion]: 'proportion/:id',
+
+  [RoutesName.tagging]: 'tagging',
+  [RoutesName.taggingPosition]: 'taggingPosition/:id',
+  [RoutesName.guide]: 'path',
+  [RoutesName.measure]: 'measure',
+  
+  [RoutesName.sceneEdit]: '/sceneEdit',
+  [RoutesName.record]: 'record',
+  [RoutesName.view]: 'view'
 }
 
 export const metas = {

+ 1 - 1
src/sdk/sdk.ts

@@ -85,7 +85,7 @@ export interface SDK {
 
 export let sdk: SDK
 export type InialSDKProps = { layout: HTMLDivElement }
-let initialed = false
+export let initialed = false
 export const initialSDK = async (props: InialSDKProps) => {
   if (initialed) return;
   initialed = true

+ 1 - 0
src/store/fuse-model.ts

@@ -37,6 +37,7 @@ export const createFuseModels = (model: Partial<FuseModel> = {}): FuseModel => s
   modelId: 0,
   fusionNumId: 0,
   url: '',
+  fusionId: 0,
   title: '',
   type: SceneType.SWMX,
   size: 0,

+ 3 - 29
src/store/index.ts

@@ -1,31 +1,3 @@
-import { ref } from 'vue'
-import { initialScenes } from './scene'
-import { initialFuseModels } from './fuse-model'
-import { initialTaggings } from './tagging'
-import { initialTaggingStyles } from './tagging-style'
-import { initialGuides } from './guide'
-
-export const loaded = ref(false)
-export const error = ref(false)
-
-export const initialStore = async () => {
-  await Promise.all([
-    initialScenes(),
-    initialFuseModels(),
-    initialTaggingStyles(),
-    initialTaggings(),
-    initialGuides()
-  ])
-  try {
-    loaded.value = true
-    console.log('初始化成功')
-  } catch(e) {
-    console.error('初始化错误', e)
-    error.value = true
-  }
-}
-
-
 export * from './sys'
 export * from './scene'
 export * from './fuse-model'
@@ -34,4 +6,6 @@ export * from './tagging-style'
 export * from './guide'
 export * from './guide-path'
 export * from './tagging-positions'
-export * from './measure'
+export * from './measure'
+export * from './record'
+export * from './view'

+ 48 - 19
src/store/measure.ts

@@ -1,28 +1,36 @@
-import { createTemploraryID } from './sys'
 import { ref } from 'vue'
+import { fuseModels } from './fuse-model'
+import { createTemploraryID, autoSetModeCallback } from './sys'
+import { 
+  addStoreItem, 
+  deleteStoreItem, 
+  fetchStoreItems, 
+  recoverStoreItems, 
+  saveStoreItems, 
+  updateStoreItem
+} from '@/utils'
+import {
+  MeasureType,
+  fetchMeasures,
+  postAddMeasure,
+  postUpdateMeasure,
+  postDeleteMeasure
+} from '@/api'
+
+import type { Measure, Measures } from '@/api'
 
-export enum MeasureType {
-  free,
-  vertical,
-  area
-}
 export const MeasureTypeMeta = {
   [MeasureType.area]: { icon: 'v-l', desc: '自由', unit: '长度' },
   [MeasureType.free]: { icon: 'f-l', desc: '垂直', unit: '长度' },
   [MeasureType.vertical]: { icon: 'h-r', desc: '面积', unit: '面积' }
 }
 
-export interface Measure {
-  id: string,
-  desc: string,
-  positions: SceneLocalPos[],
-  type: MeasureType,
-}
-
-export type Measures = Measure[]
+export const measures = ref<Measures>([])
 
 export const createMeasure = (measure: Partial<Measure> = {}): Measure => ({
   id: createTemploraryID(),
+  fusionId: fuseModels.value[0].fusionId,
+  title: '',
   positions: [],
   desc: '',
   type: MeasureType.free,
@@ -30,8 +38,29 @@ export const createMeasure = (measure: Partial<Measure> = {}): Measure => ({
 })
 
 
-export const measures = ref<Measures>([
-  createMeasure({ desc: '11' }),
-  createMeasure({ desc: '11', type: MeasureType.vertical }),
-  createMeasure({ desc: '11', type: MeasureType.area })
-])
+let bcMeasures: Measures = []
+export const getBackupMeasures = () => bcMeasures
+export const backupMeasures = () => {
+  bcMeasures = measures.value.map(measure => ({...measure, positions: [...measure.positions]}))
+}
+export const recoverMeasures = recoverStoreItems(measures, getBackupMeasures)
+
+
+export const initialMeasure = fetchStoreItems(measures, () => fetchMeasures(fuseModels.value[0]), backupMeasures)
+export const addMeasure = addStoreItem(measures, postAddMeasure)
+export const updateMeasure = updateStoreItem(measures, postUpdateMeasure)
+export const deleteMeasure = deleteStoreItem(measures, measure => postDeleteMeasure(measure.id))
+export const saveMeasures = saveStoreItems(
+  measures, 
+  getBackupMeasures,
+  {
+    add: addMeasure,
+    delete: deleteMeasure,
+    update: updateMeasure
+  }
+)
+export const autoSaveMeasures = autoSetModeCallback(measures, {
+  backup: backupMeasures,
+  recovery: recoverMeasures,
+  save: saveMeasures
+})

+ 52 - 0
src/store/record-fragment.ts

@@ -0,0 +1,52 @@
+import { ref } from "vue";
+import { autoSetModeCallback, createTemploraryID } from './sys'
+import { deleteStoreItem, recoverStoreItems, saveStoreItems } from '@/utils'
+import { fetchRecordFragments, postDeleteRecordFragment } from '@/api'
+
+import type { RecordFragment as SRecordFragment } from '@/api'
+import type { Record } from './record'
+
+export type RecordFragment = LocalMode<SRecordFragment, 'url'> & { recordId: Record['id'] }
+export type RecordFragments = RecordFragment[]
+
+export const recordFragments = ref<RecordFragments>([])
+
+export const createRecordFragment = (): RecordFragment => ({
+  id: createTemploraryID(),
+  recordId: '',
+  cover: '',
+  url: '',
+  sort: Math.min(...recordFragments.value.map(item => item.sort)) + 1,
+})
+
+export const getRecordFragments = (record: Record) => 
+  recordFragments.value.filter(fragment => fragment.recordId === record.id)
+
+let bcRecordFragments: RecordFragments = []
+export const getBackupRecordFragments = () => bcRecordFragments
+export const backupRecordFragments = () => {
+  bcRecordFragments = recordFragments.value.map(fragment => ({...fragment}))
+}
+export const recoverRecordFragments = recoverStoreItems(recordFragments, getBackupRecordFragments)
+
+
+export const initRecordFragmentsByRecord = async (record: Record) => {
+  const fragments = await fetchRecordFragments(record.id)
+  recordFragments.value = recordFragments.value
+    .filter(fragment => fragment.recordId !== record.id)
+    .concat(fragments.map(fragment => ({...fragment, recordId: record.id})))
+
+  getBackupRecordFragments()
+}
+
+export const deleteRecordFragment = deleteStoreItem(recordFragments, fragment => postDeleteRecordFragment(fragment.id))
+export const saveRecordFragments = saveStoreItems(
+  recordFragments, 
+  getBackupRecordFragments,
+  { delete: deleteRecordFragment }
+)
+export const autoSaveViews = autoSetModeCallback(recordFragments, {
+  backup: backupRecordFragments,
+  recovery: recoverRecordFragments,
+  save: saveRecordFragments
+})

+ 111 - 0
src/store/record.ts

@@ -0,0 +1,111 @@
+import { ref } from "vue";
+import { autoSetModeCallback, createTemploraryID } from './sys'
+import { 
+  addStoreItem, 
+  deleteStoreItem, 
+  fetchStoreItems, 
+  recoverStoreItems, 
+  saveStoreItems, 
+  updateStoreItem,
+  togetherCallback,
+  diffArrayChange
+} from '@/utils'
+import {
+  fetchRecords,
+  postAddRecord,
+  postUpdateRecord,
+  postDeleteRecord,
+  RecordStatus,
+  postMegerRecord,
+  blobToFile,
+  uploadFile
+} from '@/api'
+import { 
+  getRecordFragments, 
+  initRecordFragmentsByRecord,
+  deleteRecordFragment,
+  recordFragments,
+  backupRecordFragments,
+  recoverRecordFragments,
+  saveRecordFragments
+} from './record-fragment'
+
+import type { Record as SRecord } from '@/api'
+
+export type Record = LocalMode<SRecord, 'cover'> 
+export type Records = Record[]
+
+export const records = ref<Records>([])
+
+export const createRecord = (): Record => ({
+  id: createTemploraryID(),
+  title: '讲解视频',
+  cover: '',
+  url: '',
+  status: RecordStatus.SUCCESS,
+  sort: Math.min(...records.value.map(item => item.sort)) - 1
+})
+
+
+let bcRecords: Records = []
+export const getBackupRecords = () => bcRecords
+export const backupRecords = () => {
+  bcRecords = records.value.map(record => ({...record }))
+}
+export const recoverRecords = recoverStoreItems(records, getBackupRecords)
+
+const getRecordMergeFiles = (record: Record) => {
+  const fragments = getRecordFragments(record)
+  const files = fragments
+    .filter(fragment => typeof fragment.cover !== 'string')
+    .map(fragment => blobToFile((fragment.url as LocalFile).blob, '.mp4'))
+  return files
+}
+
+export const initialRecords = fetchStoreItems(records, async () => {
+  const records = await fetchRecords()
+  await Promise.all(records.map(initRecordFragmentsByRecord))
+  return records
+}, getBackupRecords)
+
+export const addRecord = addStoreItem(records, async (record) => {
+  const cover = await uploadFile(record.cover)
+  return await postAddRecord({ ...record, cover }, getRecordMergeFiles(record))
+})
+export const updateRecord = updateStoreItem(records, async (record) => {
+  const cover = await uploadFile(record.cover)
+  return await postUpdateRecord({ ...record, cover })
+})
+export const deleteRecord = deleteStoreItem(records, async record => {
+  recordFragments.value = recordFragments.value.filter(fragment => fragment.recordId !== record.id)
+  await postDeleteRecord(record.id)
+})
+export const saveRecords = saveStoreItems(
+  records, 
+  getBackupRecords,
+  {
+    add: addRecord,
+    delete: deleteRecord,
+    update: updateRecord
+  }
+)
+export const autoSaveRecords = autoSetModeCallback(
+  [records, recordFragments], 
+  {
+    backup: togetherCallback([backupRecordFragments, backupRecords]),
+    recovery: togetherCallback([recoverRecordFragments, recoverRecords]),
+    save: async () => {
+      const oldRecords = getBackupRecords()
+      const { added } = diffArrayChange(records.value, oldRecords)
+      await saveRecords()
+      await saveRecordFragments()
+      const files = records.value
+        .filter(record => !added.includes(record))
+        .map(record => ({ record, merge: getRecordMergeFiles(record) }))
+        .filter(({merge}) => merge.length)
+
+      await Promise.all(files.map(({record, merge}) => postMegerRecord(merge, record.id)))
+      await initialRecords()
+    }
+  }
+)

+ 1 - 0
src/store/sys.ts

@@ -19,6 +19,7 @@ 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 appEl = ref<HTMLDivElement | null>(null)
 
 let currentTempIndex = 0
 export const isTemploraryID = (id: string) => id.includes('__currentTempIndex__')

+ 1 - 0
src/store/tagging.ts

@@ -40,6 +40,7 @@ export type Tagging = LocalMode<STagging, 'images'>
 export type Taggings = Tagging[]
 
 export const taggings = ref<Taggings>([])
+export const getTagging = (id: Tagging['id']) => taggings.value.find(tagging => tagging.id === id)
 
 export const createTagging = (tagging: Partial<Tagging> = {}): Tagging => ({
   id: createTemploraryID(),

+ 67 - 0
src/store/view.ts

@@ -0,0 +1,67 @@
+import { ref } from "vue";
+import { autoSetModeCallback, createTemploraryID } from './sys'
+import { 
+  addStoreItem, 
+  deleteStoreItem, 
+  fetchStoreItems, 
+  recoverStoreItems, 
+  saveStoreItems, 
+  updateStoreItem
+} from '@/utils'
+import {
+  fetchViews,
+  postAddView,
+  postUpdateView,
+  postDeleteView,
+  uploadFile
+} from '@/api'
+
+import type { View as SView } from '@/api'
+
+export type View = LocalMode<SView, 'cover'>
+export type Views = View[]
+
+export const views = ref<Views>([])
+
+export const createView = (): View => ({
+  id: createTemploraryID(),
+  title: '视图',
+  cover: 'https://4dkk.4dage.com/scene_view_data/KK-t-F8e5M46wcQ/images/floor_0.png?t=1659422513133?v=0&rnd=0.9219648338739086&x-oss-process=image/resize,m_fill,w_80,h_60/quality,q_70&rnd=0.25420557086595965',
+  flyData: '',
+  isFuse: true,
+  sort: Math.min(...views.value.map(item => item.sort)) - 1,
+})
+
+
+let bcViews: Views = []
+export const getBackupViews = () => bcViews
+export const backupViews = () => {
+  bcViews = views.value.map(view => ({...view}))
+}
+export const recoverViews = recoverStoreItems(views, getBackupViews)
+
+
+export const initialViews = fetchStoreItems(views, fetchViews, backupViews)
+export const addView = addStoreItem(views, async (view) => {
+  const cover = await uploadFile(view.cover)
+  return await postAddView({ ...view, cover })
+})
+export const updateView = updateStoreItem(views, async (view) => {
+  const cover = await uploadFile(view.cover)
+  return await postUpdateView({ ...view, cover })
+})
+export const deleteView = deleteStoreItem(views, view => postDeleteView(view.id))
+export const saveViews = saveStoreItems(
+  views, 
+  getBackupViews,
+  {
+    add: addView,
+    delete: deleteView,
+    update: updateView
+  }
+)
+export const autoSaveViews = autoSetModeCallback(views, {
+  backup: backupViews,
+  recovery: recoverViews,
+  save: saveViews
+})

+ 168 - 0
src/utils/file-serve.ts

@@ -0,0 +1,168 @@
+function bom(blob: Blob, opts: any) {
+  if (typeof opts === "undefined") opts = { autoBom: false };
+  else if (typeof opts !== "object") {
+    console.warn("Deprecated: Expected third argument to be a object");
+    opts = { autoBom: !opts };
+  }
+
+  if (
+    opts.autoBom &&
+    /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(
+      blob.type
+    )
+  ) {
+    return new Blob([String.fromCharCode(0xfeff), blob], { type: blob.type });
+  }
+  return blob;
+}
+
+function download(url: string, name?: string, opts?: any): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open("GET", url);
+    xhr.responseType = "blob";
+    xhr.onload = function () {
+      saveAs(xhr.response, name, opts).then(resolve);
+    };
+    xhr.onerror = function () {
+      reject("could not download file");
+    };
+    xhr.send();
+  });
+}
+
+function corsEnabled(url: string) {
+  const xhr = new XMLHttpRequest();
+  // use sync to avoid popup blocker
+  xhr.open("HEAD", url, false);
+  try {
+    xhr.send();
+  } catch (e) {}
+  return xhr.status >= 200 && xhr.status <= 299;
+}
+
+function click(node: HTMLElement) {
+  return new Promise<void>((resolve) => {
+    setTimeout(() => {
+      try {
+        node.dispatchEvent(new MouseEvent("click"));
+      } catch (e) {
+        const evt = document.createEvent("MouseEvents");
+        evt.initMouseEvent(
+          "click",
+          true,
+          true,
+          window,
+          0,
+          0,
+          0,
+          80,
+          20,
+          false,
+          false,
+          false,
+          false,
+          0,
+          null
+        );
+        node.dispatchEvent(evt);
+      }
+      resolve();
+    }, 0);
+  });
+}
+
+const isMacOSWebView =
+  navigator &&
+  /Macintosh/.test(navigator.userAgent) &&
+  /AppleWebKit/.test(navigator.userAgent) &&
+  !/Safari/.test(navigator.userAgent);
+
+type SaveAs = (
+  blob: Blob | string,
+  name?: string,
+  opts?: { autoBom: boolean }
+) => Promise<void>;
+
+export const saveAs: SaveAs =
+  "download" in HTMLAnchorElement.prototype && !isMacOSWebView
+    ? (blob, name = "download", opts) => {
+        const URL = global.URL || global.webkitURL;
+        const a = document.createElement("a");
+
+        a.download = name;
+        a.rel = "noopener";
+
+        if (typeof blob === "string") {
+          a.href = blob;
+          if (a.origin !== location.origin) {
+            if (corsEnabled(a.href)) {
+              return download(blob, name, opts);
+            }
+            a.target = "_blank";
+          }
+          return click(a);
+        } else {
+          a.href = URL.createObjectURL(blob);
+          setTimeout(function () {
+            URL.revokeObjectURL(a.href);
+          }, 4e4); // 40s
+          return click(a);
+        }
+      }
+    : "msSaveOrOpenBlob" in navigator
+    ? (blob, name = "download", opts) => {
+        if (typeof blob === "string") {
+          if (corsEnabled(blob)) {
+            return download(blob, name, opts);
+          } else {
+            const a = document.createElement("a");
+            a.href = blob;
+            a.target = "_blank";
+            return click(a);
+          }
+        } else {
+          return (navigator as any).msSaveOrOpenBlob(bom(blob, opts), name)
+            ? Promise.resolve()
+            : Promise.reject("unknown");
+        }
+      }
+    : (blob, name, opts) => {
+        if (typeof blob === "string") return download(blob, name, opts);
+
+        const force = blob.type === "application/octet-stream";
+        const isSafari =
+          /constructor/i.test(HTMLElement.toString()) || (global as any).safari;
+        const isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent);
+
+        if (
+          (isChromeIOS || (force && isSafari) || isMacOSWebView) &&
+          typeof FileReader !== "undefined"
+        ) {
+          return new Promise<void>((resolve, reject) => {
+            const reader = new FileReader();
+            reader.onloadend = function () {
+              let url = reader.result as string;
+              url = isChromeIOS
+                ? url
+                : url.replace(/^data:[^;]*;/, "data:attachment/file;");
+              location.href = url;
+              resolve();
+            };
+            reader.onerror = function () {
+              reject();
+            };
+            reader.readAsDataURL(blob);
+          });
+        } else {
+          const URL = global.URL || global.webkitURL;
+          const url = URL.createObjectURL(blob);
+          location.href = url;
+          setTimeout(function () {
+            URL.revokeObjectURL(url);
+          }, 4e4); // 40s
+          return Promise.resolve();
+        }
+      };
+
+export default saveAs;

+ 3 - 1
src/utils/index.ts

@@ -62,4 +62,6 @@ export * from "./asyncBus";
 export * from './mount'
 export * from './watch'
 export * from './diff'
-export * from './params'
+export * from './params'
+export * from './file-serve'
+export * from './video-cover'

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

@@ -211,7 +211,6 @@ export const recoverStoreItems = <T extends Array<{ id: any }>>(items: Ref<T>, g
   const backupItems = getBackupItems()
   items.value = backupItems.map(oldItem => {
     const model = items.value.find(item => item.id === oldItem.id)
-    console.log(model, oldItem)
     return model ? Object.assign(model, oldItem) : oldItem
   }) as T
 }

+ 48 - 0
src/utils/video-cover.ts

@@ -0,0 +1,48 @@
+
+export const getVideoCover = (data: string | Blob,  seekTo: number = 0.0, width?: number, height?: number) : Promise<string> => {
+  const url = typeof data !== 'string' 
+    ? URL.createObjectURL(data)
+    : data
+
+  return new Promise(function (resolve, reject) {
+    const video = document.createElement('video')
+
+    video.setAttribute('crossOrigin', 'anonymous')// 处理跨域,需要服务器支持跨域
+    video.setAttribute('src', url)
+    video.setAttribute('muted', '')
+    if (width && height) {
+      video.setAttribute('width', `${width}px`)
+      video.setAttribute('height', `${height}px`)
+      video.setAttribute('style', 'object-fit:scale-down')
+    }
+    video.load()
+    video.addEventListener('loadedmetadata', function () {
+      if (video.duration < seekTo) {
+        reject(new Error('视频长度不够'));
+        return;
+      }
+
+      setTimeout(() => video.currentTime = seekTo, 200);
+
+      video.addEventListener('seeked', () => {
+        const canvas = document.createElement('canvas')
+        if (width && height) {
+          canvas.width = width
+          canvas.height = height
+        } else {
+          canvas.width = video.videoWidth
+          canvas.height = video.videoHeight
+        }
+        canvas.getContext('2d')!.drawImage(video, 0, 0, width!, height!)
+
+        const dataURL = canvas.toDataURL('image/jpeg') 
+
+        if (typeof data !== 'string') {
+          URL.revokeObjectURL(url)
+        }
+        resolve(dataURL)
+      })
+    })
+  })
+
+}

+ 2 - 2
src/views/guide/edit-paths.vue

@@ -149,7 +149,7 @@ const addPath = () => {
     current.value = path
     if (paths.value.length > 1) {
       const index = paths.value.length - 2
-      updatePathInfo(index, { speed: paths.value[index].speed })
+      updatePathInfo(index, { time: 3 })
     }
   })
 }
@@ -256,7 +256,7 @@ watchEffect(async () => {
     display: flex;
 
     .set-phone-attr {
-      width: 80px;
+      padding: 0 10px;
       display: flex;
       flex-direction: column;
       justify-content: space-evenly;

+ 0 - 1
src/views/proportion/index.vue

@@ -11,7 +11,6 @@
 <script lang="ts" setup>
 import { Message } from 'bill/index'
 import { useViewStack } from '@/hook'
-import { getCurrentInstance } from 'vue'
 
 useViewStack(() => {
   const hide = Message.show({ msg: '请选择两点标记一段已知长度,并输入真实长度' })

+ 3 - 0
src/views/record/help.ts

@@ -0,0 +1,3 @@
+import { Record } from '@/store/record'
+
+export type RecordProcess = Record & { immediately?: boolean }

+ 86 - 0
src/views/record/index.vue

@@ -0,0 +1,86 @@
+<template>
+  <RightFillPano>
+    <div class="btns header-btns">
+      <ui-button class="start" @click="start" type="primary">开始录制</ui-button>
+      <ui-input 
+        class="unit" 
+        type="multiple" 
+        :options="setOptions" 
+        v-model="setting" 
+        width="120px" 
+        placeholder="显示设置"
+      >
+      </ui-input>
+    </div>
+
+    <ui-group title="全部视频" class="tree" >
+      <Draggable :list="records" draggable=".sign" itemKey="id">
+        <template #item="{ element: record }">
+          <Sign 
+            :record="getSignRecord(record)" 
+            :key="record.id" 
+            @delete="deleteRecord(record)"
+            @updateTitle="title => record.title = title"
+            @updateCover="cover => record.cover = cover"
+          />
+        </template>
+      </Draggable>
+    </ui-group>
+  </RightFillPano>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue'
+import { showMeasuresStack, showTaggingsStack } from '@/env'
+import { useViewStack } from '@/hook'
+import { diffArrayChange, togetherCallback } from '@/utils'
+import { isTemploraryID } from '@/store'
+import { RecordProcess } from './help'
+import { records, createRecord, Record } from '@/store/record'
+import { RightFillPano } from '@/layout'
+import Draggable from 'vuedraggable'
+import Sign from './sign.vue'
+
+const start = () => records.value.push(createRecord())
+const deleteRecord = (record: Record) => {
+  const index = records.value.indexOf(record)
+  if (~index) {
+    records.value.splice(index, 1)
+  }
+}
+
+const getSignRecord = (record: Record): RecordProcess => ({
+  ...record,
+  immediately: !record.blobs.length && isTemploraryID(record.id)
+})
+
+const setOptions = [
+  { value: 'tagging', label: '标注' },
+  { value: 'measure', label: '测量' },
+] as const
+
+type SetKey = typeof setOptions[number]['value']
+const setting = ref<SetKey[]>([])
+watch(setting, (setting, oldSetting = [], onCleanup) => {
+  const { added } = diffArrayChange(setting, oldSetting)
+  const pops = added.map(value => {
+    if (value === 'measure') {
+      return showMeasuresStack.push(ref(true))
+    } else {
+      return showTaggingsStack.push(ref(true))
+    }
+  })
+  onCleanup(togetherCallback(pops))
+}, { flush: 'sync' })
+
+useViewStack(() => {
+  const pop = showTaggingsStack.push(ref(false))
+  return () => {
+    setting.value = []
+    pop()
+  }
+})
+</script>
+
+<style lang="scss" src="./style.scss" scoped>
+</style>

+ 190 - 0
src/views/record/shot.vue

@@ -0,0 +1,190 @@
+<template>
+  <teleport :to="appEl" v-if="appEl">
+    <div class="countdown strengthen" v-if="!custom.showBottomBar && countdown">
+      <p class="title"><span>{{countdown}}</span>秒后开始录制</p>
+      <p>按ESC可暂停录制</p>
+    </div>
+
+    <ui-editor-toolbar :toolbar="custom.showBottomBar" class="shot-ctrl">
+      <ui-button type="submit" class="btn" @click="close">取消</ui-button>
+      <ui-button type="primary" class="btn" @click="complete" :class="{disabled: blobs.length === 0}">合并视频</ui-button>
+      <div class="other">
+        <ui-icon class="icon" type="video1" ctrl @click="start" tip="继续录制" tipV="top" />
+      </div>
+      <div class="video-list">
+        <div class="layout" :style="{width: `${videoList.length * 130}px`}">
+          <div v-for="video in videoList" :key="video.cover" class="cover">
+            <img :src="video.cover">
+            <ui-icon 
+              type="preview" 
+              ctrl 
+              class="preview" 
+              @click="palyUrl = video.origin"  
+            />
+          </div>
+        </div>
+      </div>
+    </ui-editor-toolbar>
+
+    <Preview 
+      v-if="palyUrl" 
+      :type="MediaType.video" 
+      :url="palyUrl" 
+      @close="palyUrl = null" 
+    />
+  </teleport>
+</template>
+
+<script lang="ts">
+import { ref, defineComponent, onUnmounted, watch, shallowReactive, PropType } from 'vue'
+import { VideoRecorder } from '@simaq/core';
+import { sdk } from '@/sdk'
+import { getVideoCover, togetherCallback } from '@/utils'
+import { MediaType, Preview } from '@/components/static-preview/index.vue'
+import { Record } from '@/store/record'
+import { 
+  getResource, 
+  showRightCtrlPanoStack, 
+  showRightPanoStack, 
+  showBottomBarStack, 
+  custom, 
+  bottomBarHeightStack,
+  showHeadBarStack
+} from '@/env'
+import { appEl } from '@/store';
+
+
+export default defineComponent({
+  props: {
+    record: {
+      type: Object as PropType<Record>,
+      required: true
+    }
+  },
+  emits: {
+    'append': (blobs: Blob[]) => true,
+    'updateCover': (cover: string) => true,
+    'close': () => true,
+    'preview': () => true,
+    'deleteRecord': () => true
+  },
+  setup(props, { emit }) {
+    const config: any = {
+      uploadUrl: '',
+      resolution: '2k',
+      debug: false,
+    }
+  
+    const videoRecorder = new VideoRecorder(config);
+
+    type VideoItem = { origin: Blob | string, cover: string }
+
+    const countdown = ref(0)
+    let interval: NodeJS.Timer
+    const start = () => {
+      custom.showBottomBar = false
+      countdown.value = 3
+      interval = setInterval(() => {
+        if (--countdown.value === 0) {
+          clearInterval(interval)
+          videoRecorder.startRecord()
+        }
+      }, 1000)
+    }
+
+    const pause = () => {
+      if (countdown.value === 0) {
+        videoRecorder.endRecord()
+      }
+      
+      countdown.value = 0
+      custom.showBottomBar = true
+      clearInterval(interval)
+    }
+
+    const blobs: Blob[] = shallowReactive([])
+    videoRecorder.off('*')
+    videoRecorder.on('record', blob => {
+      console.log('完成录屏')
+      blobs.push(new File([blob], '录屏.mp4', { type: 'video/mp4; codecs=h264' }))
+    })
+    videoRecorder.on('cancelRecord', pause)
+
+    const palyUrl = ref<string | Blob | null>(null)
+    const videoList: VideoItem[] = shallowReactive([])
+    watch([blobs, props], async () => {
+      const existsVideos = []
+
+      if (props.record.url) {
+        existsVideos.push(getResource(props.record.url))
+      }
+      existsVideos.push(...props.record.blobs, ...blobs)
+      for (const blob of existsVideos) {
+        if (videoList.some(item => item.origin === blob)) {
+          continue
+        }
+        const cover = await getVideoCover(blob, 3, 120, 80)
+        videoList.push({ origin: blob, cover })
+      }
+      for (let i = 0; i < videoList.length; i++) {
+        if (!existsVideos.some(blob => videoList[i].origin === blob)) {
+          videoList.splice(i--, 1)
+        }
+      }
+      if (!props.record.cover && videoList.length) {
+        emit('updateCover', videoList[0].cover)
+      }
+    }, { immediate: true })
+
+    const upHandler = (ev: KeyboardEvent) => ev.code === `Escape` && pause()
+    document.body.addEventListener('keyup', upHandler, { capture: true })
+
+    const complete = () => {
+      emit('append', blobs)
+      close()
+    }
+
+    const close = () => {
+      pause()
+      emit('close')
+    }
+
+    start()
+    const pop = togetherCallback([
+      showHeadBarStack.push(ref(false)),
+      showRightCtrlPanoStack.push(ref(false)),
+      showRightPanoStack.push(ref(false)),
+      showBottomBarStack.push(ref(false)),
+      bottomBarHeightStack.push(ref('180px'))
+    ])
+    onUnmounted(() => {
+      close()
+      pop()
+      custom.showBottomBar = false
+      document.body.removeEventListener('keyup', upHandler, { capture: true })
+    })
+
+    return {
+      MediaType,
+      complete,
+      pause,
+      close,
+      start,
+      el: sdk.layout,
+      blobs,
+      countdown,
+      custom,
+      videoList,
+      palyUrl,
+      appEl
+    }
+  },
+  components: {
+    Preview
+  }
+})
+
+</script>
+
+<style lang="scss" src="./style.scss" scoped>
+</style>

+ 137 - 0
src/views/record/sign.vue

@@ -0,0 +1,137 @@
+<template>
+  <ui-group-option class="sign">
+    <div class="content">
+      <span class="cover">
+        <img :src="getResource(record.cover)" alt="" v-if="record.cover">
+        <ui-icon 
+          type="preview" 
+          ctrl 
+          class="preview" 
+          @click="actions.play()"  
+          v-if="record.status === RecordStatus.SUCCESS && !record.blobs.length"
+        />
+      </span>
+      <ui-input 
+        type="text" 
+        :modelValue="record.title" 
+        @update:modelValue="(title: string) => $emit('updateTitle', title)"
+        v-show="isEditTitle" 
+        ref="inputRef" 
+        height="28px" 
+      />
+      <div class="title" v-show="!isEditTitle">
+        <p>{{ record.title }}</p>
+        <span v-if="record.status === RecordStatus.RUN">后台正在处理</span>
+      </div>
+    </div>
+    <div class="action">
+      <ui-icon type="order" ctrl />
+      <ui-more 
+        :options="menus" 
+        style="margin-left: 20px" 
+        @click="(action: keyof typeof actions) => actions[action]()" 
+      />
+    </div>
+
+    <Shot 
+      v-if="isShot" 
+      @close="closeHandler"
+      @append="(blobs: Blob[]) => record.blobs.push(...blobs)" 
+      @updateCover="(cover: string) => $emit('updateCover', cover)" 
+      @deleteRecord="$emit('delete')"
+      :record="record" />
+    <Preview 
+      v-if="isPlayVideo" 
+      :type="MediaType.video" 
+      :url="record.url" 
+      @close="isPlayVideo = false" 
+    />
+  </ui-group-option>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, computed } from 'vue'
+import { useFocus } from 'bill/hook/useFocus'
+import { RecordStatus } from '@/store/record'
+import { saveAs, loadPack } from '@/utils'
+import { MediaType, Preview } from '@/components/static-preview/index.vue'
+import { getResource } from '@/env'
+import Shot from './shot.vue'
+
+import type { PropType } from 'vue'
+import type { RecordProcess } from './help'
+import { isTemploraryID } from '@/store'
+
+export default defineComponent({
+  props: {
+    record: {
+      type: Object as PropType<RecordProcess>,
+      required: true
+    }
+  },
+  emits: {
+    'updateCover': (cover: string) => true,
+    'updateTitle': (title: string) => true,
+    'delete': () => true
+  },
+  setup(props, { emit }) {
+    const menus = computed(() => {
+      const base = []
+      if (props.record.status === RecordStatus.SUCCESS) {
+        base.push(
+          { label: '重命名', value: 'rename' },
+          { label: '继续录制', value: 'continue' },
+          { label: '下载', value: 'download' },
+        )
+      }
+      base.push({ label: '删除', value: 'delete' })
+      return base
+    })
+
+    const isShot = ref<boolean>(false)
+    const inputRef = ref()
+    const isEditTitle = useFocus(computed(() => inputRef.value?.vmRef.root))
+    const isPlayVideo = ref(false)
+    const actions = {
+      continue: () => isShot.value = true,
+      delete: () => emit('delete'),
+      rename: () => isEditTitle.value = true,
+      play: () => isPlayVideo.value = true,
+      download() {
+        const url = getResource(props.record.url)
+        const paths = url.split('/')
+        loadPack(saveAs(url, paths[paths.length - 1]))
+      },
+    }
+    props.record.immediately && actions.continue()
+
+    const closeHandler = () => {
+      if (props.record.blobs.length === 0 && isTemploraryID(props.record.id)) {
+        emit('delete')
+      }
+      isShot.value = false
+    }
+
+    return {
+      menus,
+      actions,
+      isShot,
+      isEditTitle,
+      closeHandler,
+      inputRef,
+      RecordStatus,
+      MediaType,
+      isPlayVideo,
+      getResource
+    }
+  },
+  components: {
+    Shot,
+    Preview
+  }
+})
+</script>
+
+
+<style lang="scss" src="./style.scss" scoped>
+</style>

+ 205 - 0
src/views/record/style.scss

@@ -0,0 +1,205 @@
+
+.btns {
+  display: flex;
+
+  .unit,
+  .start {
+    height: 38px;
+  }
+  .unit {
+    flex: none;
+    margin-left: 10px;
+  }
+
+  .start {
+    flex: 1;
+  }
+}
+
+.tree {
+  margin-top: 20px;
+}
+
+.header-btns {
+  margin: 0 -20px;
+  padding: 0 20px 20px;
+  border-bottom: 1px solid rgba(255,255,255,0.1600);;
+}
+
+
+.sign {
+  padding: 20px 0;
+  border-top: 1px solid rgba(255,255,255,0.1600);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0 !important;
+
+  &:last-child {
+    border-bottom: 1px solid rgba(255,255,255,0.1600);
+  }
+}
+
+.content {
+  display: flex;
+  align-items: center;
+
+  .cover {
+    display: flex;
+    position: relative;
+    width: 48px;
+    height: 48px;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    font-size: 16px;
+    margin-right: 10px;
+
+    img,
+    &::before {
+      position: absolute;
+      left: 0;
+      top: 0;
+      bottom: 0;
+      right: 0;
+    } 
+    
+    &::before{
+      content: '';
+      z-index: 2;
+      background: rgba(0,0,0,0.5);
+    }
+
+    img {
+      position: absolute;
+      z-index: 1;
+      left: 0;
+      top: 0;
+      width: 100%;
+      height: 100%;
+    }
+
+    .preview {
+      position: relative;
+      z-index: 3;
+    }
+  }
+
+  .title {
+    p {
+      font-size: 14px;
+    }
+    span {
+      font-size: 12px;
+      color: rgba(255,255,255,0.4000);
+    }
+  }
+}
+
+.action {
+  color: #fff;
+  font-size: 14px;
+}
+
+.countdown {
+  font-size: 14px;
+  color: rgba(255,255,255,0.6);
+  background-color: var(--editor-toolbox-back);
+  position: absolute;
+  z-index: 99;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  padding: 30px 60px;
+  pointer-events: none;
+
+  p:not(:last-child) {
+    margin-bottom: 15px;
+  }
+
+  .title {
+    color: #fff;
+
+    span {
+      font-size: 32px;
+      font-weight: bold;
+      color: #00C8AF;
+      margin-right: 14px;
+    }
+  }
+}
+
+.shot-ctrl {
+  .btn {
+    flex: none;
+    width: 160px;
+
+    &:not(:last-child) {
+      margin-right: 20px;
+    }
+  }
+
+  .other {
+    position: absolute;
+    bottom: calc(100% + 120px);
+    left: 50%;
+    transform: translateX(-50%) ;
+    .icon {
+      margin: 20px;
+      display: inline-block;
+      width: 64px;
+      height: 64px;
+      border-radius: 50%;
+      background-color: var(--editor-toolbox-back);
+      color: rgba(255,255,255,0.6);
+      font-size: 34px;
+      text-align: center;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+    }
+  }
+}
+
+
+.video-list {
+  position: absolute;
+  bottom: 100%;
+  left: 0;
+  width: 100%;
+  height: 120px;
+  overflow-x: auto;
+  background-color: var(--editor-toolbox-back);
+
+  .layout {
+    display: flex;
+    align-items: center;
+    height: 100%;
+    justify-content: space-around;
+  }
+
+  .cover {
+    height: 80px;
+    position: relative;
+    
+    &::before {
+      position: absolute;
+      left: 0;
+      top: 0;
+      bottom: 0;
+      right: 0;
+      content: '';
+      z-index: 2;
+      background: rgba(0,0,0,0.5);
+    } 
+    .preview {
+      position: absolute;
+      z-index: 3;
+      left: 50%;
+      top: 50%;
+      transform: translate(-50%, -50%);
+      font-size: 22px;
+    }
+  }
+}

+ 105 - 0
src/views/tagging-position/index.vue

@@ -0,0 +1,105 @@
+<template>
+  <RightFillPano>
+    <ui-group :title="`标注${tagging?.title}`" class="position-group">
+      <PositionSign 
+        v-for="(position, i) in positions" 
+        :key="position.id" 
+        :position="position"
+        :title="`位置${i + 1}`"
+        @delete="deletePosition(position)"
+        @fixed="flyTaggingPosition(position)"
+      />
+    </ui-group>
+  </RightFillPano>
+</template>
+
+
+<script lang="ts" setup>
+import PositionSign from './sign.vue'
+import { router } from '@/router'
+import { Message } from 'bill/index'
+import { RightFillPano } from '@/layout'
+import { asyncTimeout } from '@/utils'
+import { useViewStack } from '@/hook'
+import { computed, nextTick, ref, watchEffect } from 'vue';
+import { sdk } from '@/sdk'
+import { showTaggingPositionsStack } from '@/env'
+import { 
+  autoSaveTaggings, 
+  getFuseModel,
+  getFuseModelShowVariable,
+  getTaggingPositions,
+  taggingPositions,
+  createTaggingPosition,
+  getTagging,
+  enterEdit
+} from '@/store'
+
+import type { TaggingPosition } from '@/store'
+
+const tagging = computed(() => getTagging(router.currentRoute.value.params.id as string))
+const positions = computed(() => tagging.value && getTaggingPositions(tagging.value))
+
+const flyTaggingPosition = (position: TaggingPosition) => {
+  const model = getFuseModel(position.modelId)
+  if (!model || !getFuseModelShowVariable(model).value) {
+    return;
+  }
+  
+  const pop = showTaggingPositionsStack.push(ref(new WeakSet([position])))
+  sdk.comeTo({ 
+    position: position.localPos, 
+    modelId: position.modelId,
+    dur: 300,
+    distance: 3
+  })
+  
+  setTimeout(pop, 2000)
+}
+
+const deletePosition = (position: TaggingPosition) => {
+  const index = taggingPositions.value.indexOf(position)
+  if (~index) {
+    taggingPositions.value.splice(index, 1)
+  }
+}
+
+watchEffect((onCleanup) => {
+  if (tagging.value) {
+    const clickHandler = async (ev: MouseEvent) => {
+      await nextTick()
+      await asyncTimeout()
+      const position = sdk.getPositionByScreen({
+        x: ev.clientX,
+        y: ev.clientY
+      })
+
+      if (!position) {
+        Message.error('当前位置无法添加')
+      } else {
+        const storePosition = createTaggingPosition({
+          ...position,
+          taggingId: tagging.value!.id
+        })
+        taggingPositions.value.push(storePosition)
+      }
+    }
+    sdk.layout.addEventListener('click', clickHandler, false)
+
+    onCleanup(() => {
+      sdk.layout.removeEventListener('click', clickHandler, false);
+    })
+  }
+})
+useViewStack(autoSaveTaggings)
+useViewStack(() => {
+  enterEdit(() => router.back())
+})
+</script>
+
+<style lang="scss" scoped src="./style.scss"></style>
+<style lang="scss">
+.position-group .group-title{
+  margin-bottom: 0;
+}
+</style>

+ 47 - 0
src/views/tagging-position/sign.vue

@@ -0,0 +1,47 @@
+<template>
+  <ui-group-option class="sign-position">
+    <div class="info">
+      <div>
+        <p>{{title}}</p>
+      </div>
+    </div>
+    <div class="actions" @click.stop>
+      <ui-icon 
+        :class="{disabled: disabledFly}"
+        type="del" 
+        ctrl  
+        @click.stop="$emit('delete')"
+      />
+      <ui-icon 
+        :class="{disabled: disabledFly}"
+        type="pin" 
+        ctrl  
+        @click.stop="$emit('fixed')"
+      />
+    </div>
+  </ui-group-option>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { 
+  getFuseModel,
+  getFuseModelShowVariable
+} from '@/store'
+
+import type { TaggingPosition } from '@/store'
+
+const props = defineProps<{ position: TaggingPosition, title: string }>()
+const disabledFly = computed(() => {
+  const model = getFuseModel(props.position.modelId)
+  return !model || !getFuseModelShowVariable(model).value
+})
+
+defineEmits<{ 
+  (e: 'delete'): void 
+  (e: 'fixed'): void
+}>()
+
+</script>
+
+<style lang="scss" scoped src="./style.scss"></style>

+ 46 - 0
src/views/tagging-position/style.scss

@@ -0,0 +1,46 @@
+
+
+.sign-position {
+  display: flex;
+  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;
+
+    display: flex;
+    align-items: center;
+
+    div {
+      p {
+        color: #fff;
+        font-size: 14px;
+      }
+      span {
+        color: rgba(255,255,255,.6);
+        font-size: 12px;
+      }
+    }
+  }
+  
+  .actions {
+    flex: none;
+    > * {
+      margin-left: 20px;
+    }
+  }  
+}
+

+ 24 - 85
src/views/tagging/index.vue

@@ -31,7 +31,7 @@
         @edit="editTagging = tagging"
         @delete="deleteTagging(tagging)"
         @select="selectTagging = tagging"
-        @flyPositions="flyTaggingPositions(tagging)"
+        @fixed="fixedTagging(tagging)"
       />
     </ui-group>
   </RightFillPano>
@@ -47,36 +47,25 @@
 <script lang="ts" setup>
 import Edit from './edit.vue'
 import TagingSign from './sign.vue'
-import { Message } from 'bill/index'
 import { RightFillPano } from '@/layout'
-import { asyncTimeout, togetherCallback } from '@/utils'
 import { useViewStack } from '@/hook'
-import { computed, nextTick, ref, watch } from 'vue';
+import { computed, nextTick, ref, watchEffect } from 'vue';
 import { sdk } from '@/sdk'
+import { router, RoutesName } from '@/router'
 import { 
   taggings, 
   isTemploraryID, 
   Tagging, 
   autoSaveTaggings, 
   createTagging,
-  enterEdit, 
   getFuseModel,
   getFuseModelShowVariable,
   getTaggingPositions,
   taggingPositions,
-  createTaggingPosition,
-  FuseModel,
-  fuseModels
+  isOld,
+  save
 } from '@/store'
-import { 
-  custom, 
-  showTaggingPositionsStack,
-  showLeftCtrlPanoStack, 
-  showLeftPanoStack,
-  currentModelStack,
-  showRightCtrlPanoStack,
-  showRightPanoStack
-} from '@/env'
+import { custom, showTaggingPositionsStack } from '@/env'
 
 const keyword = ref('')
 const filterTaggings = computed(() => taggings.value.filter(tagging => tagging.title.includes(keyword.value)))
@@ -99,15 +88,20 @@ const deleteTagging = (tagging: Tagging) => {
   taggings.value.splice(index, 1)
 }
 
-let stopFlyTaggingPositions: () => void
-const flyTaggingPositions = (tagging: Tagging) => {
+const fixedTagging = async (tagging: Tagging) => {
+  if (isOld.value) {
+    await save()
+  }
+  router.push({ name: RoutesName.taggingPosition, params: { id: tagging.id } })
+}
+
+const flyTaggingPositions = (tagging: Tagging, callback?: () => void) => {
   const positions = getTaggingPositions(tagging)
-  stopFlyTaggingPositions && stopFlyTaggingPositions()
 
   let isStop = false
-
   const flyIndex = (i: number) => {
     if (isStop || i >= positions.length) {
+      callback && nextTick(callback)
       return;
     }
     const position = positions[i]
@@ -117,7 +111,6 @@ const flyTaggingPositions = (tagging: Tagging) => {
       return;
     }
 
-    
     const pop = showTaggingPositionsStack.push(ref(new WeakSet([position])))
     sdk.comeTo({ 
       position: position.localPos, 
@@ -132,75 +125,21 @@ const flyTaggingPositions = (tagging: Tagging) => {
     }, 2000)
   }
   flyIndex(0)
-  stopFlyTaggingPositions = () => isStop = true
-}
-
-const stopFlyKeyupHandler = (ev: KeyboardEvent) => {
-  ev.code === 'Escape' && stopFlyTaggingPositions && stopFlyTaggingPositions()
+  return () => isStop = true
 }
-useViewStack(() => {
-  document.documentElement.addEventListener('keyup', stopFlyKeyupHandler, false)
-  return () => document.documentElement.removeEventListener('keydown', stopFlyKeyupHandler, false)
-})
 
 const selectTagging = ref<Tagging | null>(null)
-watch(selectTagging, (a, b, onCleanup) => {
+watchEffect((onCleanup) => {
   if (selectTagging.value) {
-    const leave = () => selectTagging.value = null
+    const success = () => selectTagging.value = null
+    const stop = flyTaggingPositions(selectTagging.value, success)
+    const keyupHandler = (ev: KeyboardEvent) => ev.code === 'Escape' && success()
 
-    let currentModel: FuseModel | null = custom.currentModel
-    if (!currentModel) {
-      for (const [model, show] of custom.showModelsMap.entries()) {
-        show && (currentModel = model)
-      }
-      if (!currentModel) {
-        Message.error('请显示要添加热点的模型') 
-        leave()
-        return
-      }
-    }
-
-    const pop = togetherCallback([
-      showLeftCtrlPanoStack.push(ref(true)), 
-      showLeftPanoStack.push(ref(true)),
-      showRightCtrlPanoStack.push(ref(false)),
-      showRightPanoStack.push(ref(false))
-    ])
-
-    const clickHandler = async (ev: MouseEvent) => {
-      await nextTick()
-      await asyncTimeout()
-      const positions = fuseModels.value
-        .map(model => 
-          sdk.getPositionByScreen({
-            x: ev.clientX,
-            y: ev.clientY
-          }, model.id)
-        )
-        .filter(pos => pos)
-
-      console.log(fuseModels.value)
-      if (!positions.length) {
-        Message.error('当前位置无法添加')
-      } else if (selectTagging.value) {
-        const storePosition = createTaggingPosition({
-          ...positions[0],
-          taggingId: selectTagging.value.id
-        })
-        taggingPositions.value.push(storePosition)
-        leave()
-      }
-    }
-    const keyupHandler = (ev: KeyboardEvent) => ev.code === 'Escape' && leave()
-
-    document.documentElement.addEventListener('keyup', keyupHandler, false);
-    sdk.layout.addEventListener('click', clickHandler, false)
-
-    enterEdit(leave)
+    document.documentElement.addEventListener('keyup', keyupHandler, false)
     onCleanup(() => {
-      document.documentElement.removeEventListener('keyup', keyupHandler, false);
-      sdk.layout.removeEventListener('click', clickHandler, false);
-      pop()
+      stop()
+      console.log('removeHandler')
+      document.documentElement.removeEventListener('keyup', keyupHandler, false)
     })
   }
 })

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

@@ -1,19 +1,14 @@
 <template>
-  <ui-group-option class="sign-tagging" :class="{active: selected}" @click="emit('select')">
+  <ui-group-option class="sign-tagging" :class="{active: selected}" @click="!disabledFly && emit('select')">
     <div class="info">
       <img :src="getResource(getFileUrl(tagging.images.length ? tagging.images[0] : style.icon))" v-if="style">
       <div>
         <p>{{ tagging.title }}</p>
-        <a>放置:{{ positions.length }}</a>
+        <span>放置:{{ positions.length }}</span>
       </div>
     </div>
     <div class="actions" @click.stop>
-      <ui-icon 
-        :class="{disabled: disabledFly}"
-        type="pin" 
-        ctrl  
-        @click.stop="$emit('flyPositions')"
-      />
+      <ui-icon type="pin1" ctrl @click.stop="$emit('fixed')" />
       <ui-more 
         :options="menus" 
         style="margin-left: 20px" 
@@ -49,7 +44,7 @@ const emit = defineEmits<{
   (e: 'delete'): void 
   (e: 'edit'): void
   (e: 'select'): void
-  (e: 'flyPositions'): void
+  (e: 'fixed'): void
 }>()
 
 const menus = [

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

@@ -37,7 +37,10 @@
       p {
         color: #fff;
         font-size: 14px;
-        margin-bottom: 6px;
+      }
+      span {
+        color: rgba(255,255,255,.6);
+        font-size: 12px;
       }
     }
   }

+ 45 - 0
src/views/view/index.vue

@@ -0,0 +1,45 @@
+<template>
+  <RightFillPano>
+    <div class="btns header-btns">
+      <ui-button class="start" @click="start">
+        <ui-icon type="add" />
+        视图提取
+      </ui-button>
+    </div>
+
+    <ui-group title="全部视图" class="tree" >
+      <Draggable :list="views" draggable=".sign" itemKey="id">
+        <template #item="{ element: view }">
+          <Sign 
+            :view="view" 
+            :key="view.id" 
+            @delete="() => deleteView(view)"
+            @updateTitle="title => view.title = title"
+            @updateCover="cover => view.cover = cover"
+          />
+        </template>
+      </Draggable>
+    </ui-group>
+  </RightFillPano>
+</template>
+
+<script lang="ts" setup>
+import { views, createView } from '@/store'
+import { RightFillPano } from '@/layout'
+import Draggable from 'vuedraggable'
+import Sign from './sign.vue'
+
+import type { View } from '@/store'
+
+const start = () => views.value.push(createView())
+const deleteView = (record: View) => {
+  const index = views.value.indexOf(record)
+  if (~index) {
+    views.value.splice(index, 1)
+  }
+}
+
+</script>
+
+<style lang="scss" src="./style.scss" scoped>
+</style>

+ 81 - 0
src/views/view/sign.vue

@@ -0,0 +1,81 @@
+<template>
+  <ui-group-option class="sign">
+    <div class="content">
+      <span class="cover">
+        <img :src="getResource(view.cover)" alt="">
+      </span>
+      <ui-input 
+        type="text" 
+        :modelValue="view.title" 
+        @update:modelValue="(title: string) => $emit('updateTitle', title)"
+        v-show="isEditTitle" 
+        ref="inputRef" 
+        height="28px" 
+      />
+      <div class="title" v-show="!isEditTitle">
+        <p>{{ view.title }}</p>
+        <span>{{ view.title }}</span>
+      </div>
+    </div>
+    <div class="action">
+      <ui-icon type="order" ctrl />
+      <ui-more 
+        :options="menus" 
+        style="margin-left: 20px" 
+        @click="(action: keyof typeof actions) => actions[action]()" 
+      />
+    </div>
+  </ui-group-option>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, computed } from 'vue'
+import { useFocus } from 'bill/hook/useFocus'
+import { Preview } from '@/components/static-preview/index.vue'
+import { getResource } from '@/env'
+
+import type { PropType } from 'vue'
+import type { View } from '@/store'
+
+export default defineComponent({
+  props: {
+    view: {
+      type: Object as PropType<View>,
+      required: true
+    }
+  },
+  emits: {
+    'updateCover': (cover: string) => true,
+    'updateTitle': (title: string) => true,
+    'delete': () => true
+  },
+  setup(props, { emit }) {
+    const menus = [
+      { label: '编辑', value: 'rename' },
+      { label: '删除', value: 'delete' },
+    ]
+    
+    const inputRef = ref()
+    const isEditTitle = useFocus(computed(() => inputRef.value?.vmRef.root))
+    const actions = {
+      delete: () => emit('delete'),
+      rename: () => isEditTitle.value = true
+    }
+
+    return {
+      menus,
+      actions,
+      isEditTitle,
+      inputRef,
+      getResource
+    }
+  },
+  components: {
+    Preview
+  }
+})
+</script>
+
+
+<style lang="scss" src="./style.scss" scoped>
+</style>

+ 79 - 0
src/views/view/style.scss

@@ -0,0 +1,79 @@
+
+.btns {
+  display: flex;
+
+  .unit,
+  .start {
+    height: 38px;
+  }
+  .unit {
+    flex: none;
+    margin-left: 10px;
+  }
+
+  .start {
+    flex: 1;
+  }
+}
+
+.tree {
+  margin-top: 20px;
+}
+
+.header-btns {
+  margin: 0 -20px;
+  padding: 0 20px 20px;
+  border-bottom: 1px solid rgba(255,255,255,0.1600);;
+}
+
+
+.sign {
+  padding: 20px 0;
+  border-top: 1px solid rgba(255,255,255,0.1600);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0 !important;
+
+  &:last-child {
+    border-bottom: 1px solid rgba(255,255,255,0.1600);
+  }
+}
+
+.content {
+  display: flex;
+  align-items: center;
+
+  .cover {
+    display: flex;
+    position: relative;
+    width: 48px;
+    height: 48px;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    font-size: 16px;
+    margin-right: 10px;
+
+    img {
+      display: block;
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  .title {
+    p {
+      font-size: 14px;
+    }
+    span {
+      font-size: 12px;
+      color: rgba(255,255,255,0.4000);
+    }
+  }
+}
+
+.action {
+  color: #fff;
+  font-size: 14px;
+}

+ 18 - 0
vite.config.ts

@@ -35,6 +35,24 @@ export default defineConfig({
         target: 'http://192.168.0.47:8808',
         changeOrigin: true,
         rewrite: path => path.replace(/^\/api/, '')
+      },
+      '/swkk': {
+        target: 'https://test.4dkankan.com',
+        changeOrigin: true,
+        rewrite: path => path.replace(/^\/swkk/, '')
+      },
+      '/service': {
+        target: 'https://test.4dkankan.com',
+        changeOrigin: true,
+      },
+      '/swss': {
+        target: 'https://uat-laser.4dkankan.com/',
+        changeOrigin: true,
+        rewrite: path => path.replace(/^\/swss/, '')
+      },
+      '/laser': {
+        target: 'https://uat-laser.4dkankan.com/',
+        changeOrigin: true,
       }
     }
   }